Questa pagina è stata tradotta automaticamente e può contenere errori. Leggi l'originale in inglese

API

nsh

nsh è il modulo core: l’API reattiva (signal, valori computed, effect, context), mount, token, gli elementi host View, Text, Scroll e Overlay, le viste selezionabili, l’area sicura e la tastiera, l’animazione, il dispositivo e il suo ambiente, PanResponder, gli appunti, il selettore di foto, il riconoscimento vocale, lo storage, il canale host, le scorciatoie da tastiera, i componenti e moduli nativi, l’accessibilità e i tipi TypeScript di elementi, stili ed eventi. Il grafo reattivo stesso viene eseguito nativamente in Rust; leggere un signal è una chiamata al runtime che costa pochi nanosecondi, e solo le tue closure vengono eseguite nell’interprete JavaScript.

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

La reattività di NSH segue lo stesso modello di SolidJS: una funzione componente viene eseguita una sola volta, e le parti della UI che leggono signal vengono aggiornate sul posto quando quei signal cambiano. Non c’è alcun nuovo render del componente e non c’è un DOM virtuale. Consulta Reattività per la guida.

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

Un signal contiene un valore. Chiamarlo legge il valore e, all’interno di uno scope di tracciamento (espressioni JSX, computed, effect), vi sottoscrive quello scope. set e update lo scrivono; tutto ciò che lo ha letto viene aggiornato.

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

Per impostazione predefinita, una scrittura === al valore corrente non fa nulla. Passa equals: false per notificare a ogni scrittura (utile quando modifichi un oggetto sul posto e imposti di nuovo lo stesso oggetto), oppure passa un tuo confronto:

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

Un valore derivato da signal. fn viene eseguita di nuovo quando cambia un signal che ha letto; i lettori del valore computed si aggiornano solo quando il suo risultato cambia (secondo equals, === per impostazione predefinita). fn riceve il risultato precedente.

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

Usa computed quando una derivazione è costosa o viene letta in più punti. Per un’espressione economica, una semplice funzione (const full = () => first() + " " + last()) è altrettanto reattiva.

#effect

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

Esegue fn dopo il rendering, e di nuovo ogni volta che cambia un signal che ha letto. Usa gli effect per gli effetti collaterali: logging, timer, comunicazione con i moduli nativi. Un effect appartiene al componente (o effect) in cui è stato creato e viene eliminato insieme a esso.

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

Per annullare qualcosa prima che l’effect venga eseguito di nuovo (o quando il suo componente scompare), chiama cleanup al suo interno.

#batch

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

Esegue fn e applica tutte le sue scritture insieme alla fine, così i lettori si aggiornano una volta sola invece che una volta per scrittura. Restituisce ciò che restituisce fn.

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

#untracked

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

Esegue fn senza sottoscrivere lo scope corrente a ciò che legge. Usalo per leggere un signal all’interno di un effect o di un’espressione JSX senza dipendere da esso.

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

#cleanup

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

Registra fn da eseguire prima che l’effect corrente venga eseguito di nuovo, o quando il componente o l’effect corrente scompare.

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

Un valore che un componente fornisce a tutto ciò che renderizza, a qualsiasi profondità. Chiamare il context (Theme()) legge il valore del provider più vicino, oppure defaultValue se non ce n’è nessuno.

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;

Renderizza app nella superficie (la finestra, oppure NshAppView / NshView in cui l’app è incorporata) e restituisce una funzione che lo rimuove. Il file di ingresso di ogni app termina con 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 un oggetto props nelle props indicate in keys e nel resto. Entrambe le metà restano reattive: le props i cui valori cambiano sono getter, e split le mantiene come getter. Usalo in un componente che gestisce da sé alcune props e passa le altre. Destrutturare le props (const { label } = props) le leggerebbe una sola volta e perderebbe gli aggiornamenti.

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;

Renderizza i suoi figli, oppure fallback non appena qualcosa al di sotto di essi genera un errore: il corpo di un componente, un binding, un effect o un gestore di eventi. La parte che ha fallito viene rimossa e al suo posto compare il fallback. Una funzione fallback viene chiamata come fallback(error, reset) con il valore lanciato e con reset, che renderizza di nuovo i figli, qualunque parametro dichiari; un elemento fallback viene mostrato così com’è. Un errore va al boundary più vicino sopra di esso, e uno in un fallback va al boundary sopra quello. I componenti Rust usano catch_error (Error boundary).

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

Il design token name del tema dell’app ("theme" in nsh.json, sopra i token di shadcn/ui) nell’aspetto mostrato: un colore come #rrggbbaa, una lunghezza come numero di punti, un font come nome della sua famiglia. I nomi sono le variabili CSS del tema senza -- (primary, muted-foreground, radius, font-mono, e quelli propri dell’app, come up). La lettura è reattiva: un componente, memo o effect che legge un token viene eseguito di nuovo quando cambiano il tema o l’aspetto. Tokens elenca i token dell’app con i loro tipi; NSH lo scrive in .nsh/types/nsh/tokens.d.ts a partire dal tema. Per le classi, indica direttamente i token (bg-primary, rounded-(--radius)): Token del 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;

Gli elementi host esportati da nsh, scritti come tag: <View>, <Text>, <Scroll>, <Overlay> (i suoi figli sopra la finestra, posizionati rispetto a un’ancora: Overlay). Il compilatore crea la vista nativa sul posto per un tag che ne nomina uno tramite il proprio import (anche con un alias), quindi importarli non costa nulla a run time. Sono componenti come tutti gli altri, quindi possono anche essere passati in giro (const Tag = inline ? Text : View). nsh esporta allo stesso modo le altre primitive: List, Image, Canvas, WebView e Video, ciascuna con la propria pagina di riferimento. Le loro props sono documentate in Elementi. L’input di testo, Input, proviene da nsh/base.

#Area sicura e tastiera

Le variabili d’ambiente di CSS mantengono il contenuto libero dalla UI di sistema, nel padding e nel margin di qualsiasi vista (padding, paddingTop, …, le stesse chiavi di margin; i valori arbitrari p* / m* di Tailwind; i metodi di Styled .p_safe_area(), .pt_safe_area_plus(..), .pb_keyboard(), … in Rust). I loro valori sono EnvValue: env(name), env(name, <length>), calc(env(name) + <length>) e calc(<length> + env(name)).

VariabileValore
safe-area-inset-top, -right, -bottom, -leftGli inset della vista radice in cui viene renderizzato il nodo: quelli della pagina più vicina (sotto il suo header), del dialog più vicino, oppure l’area sicura della superficie. Consulta Area sicura.
keyboard-inset-heightL’altezza della tastiera software sopra la superficie, 0 mentre è nascosta; le variazioni sono animate nativamente insieme alla tastiera. Consulta Tastiera.
<View style={{ flex: 1, paddingTop: "env(safe-area-inset-top)", paddingBottom: "env(keyboard-inset-height)" }}>…</View>

#safeArea e safeAreaInsets

safeArea() è l’area sicura della superficie (safe_area() in Rust); safeAreaInsets() sono gli inset del punto in cui un componente viene renderizzato (cx.insets() in Rust), i valori delle variabili safe-area-inset-*. Entrambi sono Edges reattivi ({ top, right, bottom, left } in punti). Consulta safeArea e safeAreaInsets.

#Keyboard e keyboardFrame

Keyboard.dismiss() nasconde la tastiera software (dismiss_keyboard() in Rust) e keyboardFrame() indica quanta parte dello schermo copre (keyboard_height() e keyboard_duration() in Rust). Consulta Nascondere la tastiera e L’altezza della tastiera.

#Viste selezionabili

<View selectable> (view().selectable(true) in Rust) fa sì che i testi al suo interno vengano selezionati insieme, come quelli di una pagina web: una sola selezione attraversa viste, elementi Text e TextView, in ordine di lettura. Consulta Selezione del testo.

#Animazione

L’API di animazione anima sul thread della UI. Crei un valore animato, lo leghi (o leghi valori derivati da esso) a una proprietà di stile e avvii un’animazione; da quel momento ogni frame viene calcolato nativamente, senza eseguire il codice dell’app. Un engine o un thread JS occupato non può far scattare l’animazione, e un’animazione non costa all’app alcun lavoro per frame. Il codice Rust usa animated_value(), motion(), Animation, Spring, Timing, KeyframeTiming, MotionPreset e Easing da nsh_core::prelude e lega i valori con i metodi .bind_* degli elementi; TSX li importa da nsh.

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

Consulta Animazione per la guida.

#Cosa può pilotare un valore animato

DoveRustTSX
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)Lo stesso: un nodo di colori. borderColor colora ogni lato; il color di un Text colora l’intero testo
transform.bind_translate_x(&value), .bind_translate_y, .bind_scale, .bind_rotate (gradi)translateX, translateY, scale, scaleX, scaleY, rotate, rotateX, rotateY, rotateZ passi: transform: [{ translateX: value }]
Posizione di scroll.scroll_value(&value) su una vista scroll o una lista<Scroll scrollValue={value}> e <List scrollValue={value}> scrivono in esso l’offset di scroll a ogni frame
Gesti.pan_values(Some(&x), Some(&y)), .pinch_value(&scale), .rotation_value(&angle)pan={{ x, y }}, pinch={{ scale }}, rotation={{ angle }} su qualsiasi elemento: il gesto scrive direttamente i valori (vedi Tocco e gesti)
Dove si fissano le viste.sticky().bind_sticky_top(&value) (.bind_sticky_left in una vista scroll orizzontale), .bind_sticky_header_offset(&value) su una listastyle={{ position: "sticky", top: value }}, <List stickyHeaderOffset={value}>: le viste e gli header sticky si fissano a quella distanza dall’inizio del viewport, seguendo il valore a ogni frame
Canvasun argomento animato della chiamata di un painterQualsiasi argomento numerico o di colore di una chiamata di disegno di un canvas
Shader.uniform(name, &value)Un membro di struct Uniforms di uno shader: uniforms={{ name: value }} (numeri, oppure un nodo di colori per vec3f / vec4f)

Le altre proprietà di stile accettano valori semplici; per animare il layout stesso (dimensioni, posizioni nel flusso), usa LayoutAnimation.

#createAnimatedValue

function createAnimatedValue(value?: number): AnimatedValue;

Crea un valore animato che parte da value (predefinito 0). Rust: animated_value(value), un handle Copy mantenuto dal componente che lo ha creato.

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 }>;
}
MetodoRustDescrizione
set(value)set(value)Salta a value, interrompendo qualsiasi animazione in corso.
stop()stop(), poi value().awaitInterrompe l’animazione in corso dove si trova; si risolve con { value }, il valore in quel punto.
getValue()value().awaitSi risolve con il valore corrente (con il suo offset), letto sul thread della UI.
setOffset(offset)set_offset(offset)Un offset aggiunto al valore: i binding vedono valore + offset; set, le animazioni e i trascinamenti muovono il valore.
flattenOffset()flatten_offset()Aggiunge l’offset al valore e lo porta a 0.
extractOffset()extract_offset()Sposta il valore nell’offset e porta il valore a 0 (un trascinamento parte poi da dove è finito l’ultimo).
timing(to, options)timing(to, ms, easing), timing_with(to, Timing { … })Anima verso to lungo una curva di easing. duration in ms (predefinito 300), easing (predefinito "easeInOut"), delay in ms (predefinito 0).
spring(to, options)spring(to, Spring { … })Raggiunge to con una molla. stiffness (predefinito 170), damping (predefinito 26), mass (predefinito 1), velocity (velocità iniziale, unità al secondo).
decay(options)decay(velocity, deceleration), decay_within(velocity, deceleration, (min, max), bounce)Procede per inerzia da velocity (unità al secondo) e rallenta di deceleration per millisecondo (predefinito 0.998). Usalo dopo un fling, con la velocità di onPanEnd. Con clamp: [min, max] si ferma a un limite, oppure rimbalza indietro da esso mantenendo bounce (0..1, predefinito 0) della sua velocità.
keyframes(frames, options)keyframes(frames, KeyframeTiming)Riproduce i keyframe come vengono riprodotti i keyframe CSS: un easing per segmento, iterations volte in direction.

Ogni metodo di animazione restituisce una promise che si risolve con { finished }: true quando l’animazione è terminata, false quando è stata interrotta (da stop, set o da un’altra animazione dello stesso valore). In Rust restituisce un AnimationEnd, che un task attende per lo stesso 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";

Un nodo del grafo di animazione nativo: un AnimatedValue, oppure un valore derivato da altri. I nodi derivati vengono valutati sul thread della UI ogni volta che cambiano i loro input.

MetodoRustDescrizione
interpolate(config)interpolate(&input, &output), interpolate_colors(&input, &colors), interpolate_with(&input, output, Interpolation { … })Mappa l’input attraverso intervalli a tratti. outputRange contiene numeri, colori ("#ff0000", "rgba(…)") o angoli ("45deg", "1rad"). Fuori da inputRange applica per impostazione predefinita un clamp; extrapolate (oppure extrapolateLeft / extrapolateRight) può essere "extend" (continua la retta), "clamp" o "identity" (lascia passare l’input). easing modella ogni segmento. Rust accetta numeri, colori del tema (che seguono l’aspetto) e angoli in gradi per bind_rotate.
diffClamp(min, max, options?)diff_clamp(min, max), diff_clamp_with(min, max, DiffClamp::snapping())Segue le variazioni dell’input ma mantiene il risultato entro min..max: un header che si nasconde mentre il contenuto scorre verso l’alto e ritorna non appena scorre verso il basso. snap: true lo porta al limite più vicino in 200 ms quando la sua vista scroll si ferma, a max solo quando l’input ha raggiunto max.
add, sub, mul, divadd, sub, mul, divAritmetica con un altro nodo o una costante.
mod(b)modulo(b)Modulo, mai negativo per un b positivo (mantiene gli angoli in 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) riproduce un valore attraverso i keyframe e si risolve con { finished }, che indica se sono terminati; Animated.keyframes(value, { keyframes, ...options }) equivale a un passo di una composizione. Rust: value.keyframes(frames, KeyframeTiming) e Animation::keyframes(&value, frames, KeyframeTiming), con Keyframe::new(value) (distribuiti uniformemente), Keyframe::at(offset, value) e .ease(easing), e KeyframeTiming::new(ms) con .delay(ms), .easing(e), .iterations(Some(n)), .infinite() e .direction(PlaybackDirection::…).

Vengono riprodotti come i keyframe CSS: un easing modella un segmento (quello di un keyframe è il segmento che parte da esso, quello del timing è ogni altro segmento); gli offset mancanti nei keyframe vengono distribuiti uniformemente, il primo a 0 e l’ultimo a 1; ogni iterazione inizia con un salto al suo primo keyframe, e senza un keyframe a 0 il valore parte da dove si trova; durante un delay il valore mantiene il primo keyframe (CSS animation-fill-mode: backwards); le iterazioni all’indietro eseguono i segmenti al contrario, con le curve invertite; il valore resta dove lo lascia l’ultima iterazione.

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

Crea un motion: un valore animato per ciascun canale di opacità e transform di una view, a riposo (opacità e scale a 1, gli altri a 0), e motion da riprodurre su di essi in modo nativo. Si associa con style={motion.style}, oppure si fa lo spread di motion.style o motion.transform in uno stile; il transform applica prima le traslazioni, poi le rotazioni, poi le scale, con scale inglobato in scaleX e scaleY: 7 degli 8 passi di transform di una view, il che ne lascia uno per il tuo ([{ perspective: 600 }, ...motion.transform] per i flip 3D). initial imposta da dove partono i canali ({ opacity: 0 }; gli altri partono a riposo). Con enter, i canali che muove partono da dove parte quel motion (su initial) e viene riprodotto subito, con le altre opzioni come timing (delay, duration, …). Un motion muove solo opacità e transform: lo spread di motion.style imposta il opacity della view, quindi associa solo motion.transform per mantenere un’opacità tua, e muovi i colori con un valore animato e interpolate. Rust: motion(), un handle Copy conservato dal componente che lo ha creato, associato con .bind_motion(&m); motion().entering(spec) entra.

MembroRust (Motion)Descrizione
opacity, translateX, … scaleYvalue(MotionChannel::Opacity), …I valori animati dei canali.
style, transform.bind_motion(&m) sull’elementoLe associazioni.
play(motion, options?)play(spec), play_ignoring_reduce_motion(spec)Riproduce un preset per nome o un MotionSpec sui canali; uno nuovo interrompe quello in riproduzione. options ne modificano il timing. Si risolve con { finished }: true se è terminato, false se qualcosa lo ha fermato (AnimationEnd in Rust).
animation(motion, options?)animation(spec)Lo stesso motion come CompositeAnimation, non avviato, da comporre: 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)))). La riduzione del movimento viene decisa quando viene creato.
stop()stop()Lo ferma dov’è.
reset()reset()Lo ferma e riporta ogni canale a riposo.

Quando l’utente chiede meno movimento (reduceMotion()), play, animation e enter arrivano subito dove termina il motion, senza muoversi, a meno che ignoreReduceMotion sia impostato (nelle opzioni o sul 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";
}

I motion predefiniti come dati: motion.play("shake") riproduce motions.shake. MotionName è uno tra fadeIn, fadeInUp, fadeInDown, fadeInLeft, fadeInRight, zoomIn, popIn, bounceIn, slideInUp, slideInDown, flipInX, flipInY (ingresso); fadeOut, fadeOutUp, fadeOutDown, fadeOutLeft, fadeOutRight, zoomOut, popOut, slideOutDown, slideOutUp (uscita); shake, headShake, wiggle, swing, pulse, heartbeat, rubberBand, jello, tada, jump, flash (attenzione); spin, float, breathe, sway, bob, glow (ciclo). Animation descrive ciascuno di essi. Fai lo spread di un preset per modificarlo: motion.play({ ...motions.fadeInUp, duration: 600 }).

Un MotionSpec è un motion tutto tuo: i keyframe di ogni canale ({ opacity: [0, 1], translateY: [24, 0] }), oppure un elenco di frame come li scrive la Web Animations API ([{ opacity: 0, translateY: 24 }, { opacity: 1, translateY: 0 }], un frame senza offset viene collocato in base alla sua posizione nell’elenco), con il timing dei keyframe. Rust: MotionSpec::new(KeyframeTiming) con .values(channel, values) (distribuiti uniformemente), .track(channel, keyframes) e .timing(|t| …); MotionPreset::FadeInUp (o .spec()) è un preset, e MotionChannel nomina i canali.

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 costruisce animazioni a partire da passi (timing, spring, decay, sequence, parallel, …). Nulla viene eseguito finché non si chiama start(); a quel punto l’intero albero viene eseguito in modo nativo, e ogni passo parte sul thread UI nell’istante esatto in cui termina il precedente (nessun intervallo tra i frame, nessun round trip verso il codice dell’app tra un passo e l’altro). Rust costruisce lo stesso albero con Animation.

FunzioneRustDescrizione
timing, spring, decayAnimation::timing(&value, to, Timing), Animation::spring(&value, to, Spring), Animation::decay(&value, velocity, deceleration)Un’animazione di un valore; stesse opzioni e valori predefiniti dei metodi di AnimatedValue, con il target come toValue.
sequence(list)Animation::sequence(list)Esegue le animazioni una dopo l’altra.
parallel(list, { stopTogether })Animation::parallel(list).stop_together(…)Le esegue contemporaneamente. Con stopTogether (predefinito true), fermarne una le ferma tutte.
stagger(ms, list, { from })Animation::stagger(ms, list), Animation::stagger_from(ms, StaggerFrom::Center, list)Le esegue in parallelo, ciascuna con avvio ms dopo la precedente, contando da from: "first" (predefinito), "last", "center" (quella centrale parte subito, le altre si distribuiscono a partire da essa; con un numero pari di elementi partono subito entrambe quelle centrali) o un indice (StaggerFrom::First, Last, Center, Index(i)).
keyframes(value, { keyframes, ...timing })Animation::keyframes(&value, frames, KeyframeTiming)Un valore attraverso i keyframe.
delay(ms)Animation::delay(ms)Attende; utile dentro sequence.
loop(animation, { iterations, resetBeforeIteration })animation.looped(iterations).reset_before_iteration(…)Ripete animation per iterations volte (all’infinito per impostazione predefinita), ripristinando i suoi valori prima di ogni esecuzione a meno che resetBeforeIteration sia false.
add, subtract, multiply, divide, modulo, diffClampi metodi del nodoGli stessi metodi di AnimatedNode, come funzioni.
sampleSpring, sampleDecaynsh_core::element::sample_spring, sample_decayI valori di una spring o di un decay, uno per frame (vedi Easing).

CompositeAnimation:

MetodoRust (Animation)Descrizione
start(callback?)start(), un AnimationEnd su cui fare awaitLa esegue (di nuovo). Si risolve, e chiama callback, una volta per l’intero albero con { finished }: indica se è terminata anziché essere stata fermata.
stop()stop()La ferma dov’è.
reset()reset()La ferma e riporta i suoi valori a dove erano quando è partita.
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;
};

Un easing è un dato, non una funzione dell’app: il runtime lo valuta in modo nativo. In Rust è l’enum Easing: Linear, EaseIn, EaseOut, EaseInOut, Bezier(x1, y1, x2, y2), Steps(count, StepPosition) e Curve(curve, mode), con un Curve (Quad, Cubic, Poly(n), Sin, Circle, Exp, Elastic(bounciness), Back(s), Bounce) e un EaseMode (In, Out, InOut). Per disegnarne uno, Easing.sample(easing, count) (Rust: nsh_core::element::sample_easing) restituisce i suoi valori in count punti equidistanti da 0 a 1; Animated.sampleSpring e Animated.sampleDecay (sample_spring, sample_decay) restituiscono i valori di una spring o di un decay, uno per frame (a fps, 60 per impostazione predefinita) da from (0) fino alla sua fine. Tutti e tre provengono dal codice nativo su cui girano le animazioni. Ovunque in TSX sia accettato un Easing puoi passare un nome ("easeOut"), una Bézier cubica come quattro numeri, o una curva dall’oggetto Easing.

CurvaRustForma
quadCurve::Quadt²
cubicCurve::Cubict³
poly(n)Curve::Poly(n)tⁿ (n vale 2 per impostazione predefinita)
sinCurve::SinSinusoidale
circleCurve::CircleCircolare
expCurve::ExpEsponenziale
elastic(bounciness)Curve::Elastic(bounciness)Overshoot elastico (bounciness vale 1 per impostazione predefinita)
back(s)Curve::Back(s)Arretra prima di partire; s è l’overshoot (predefinito 1.70158)
bounceCurve::BounceRimbalza alla fine

Easing.steps(count, position) è il CSS steps(): il valore salta tra count livelli invece di muoversi (uno sprite frame per frame, una lancetta che scatta). position indica dove cadono i salti: "jump-end" (il valore predefinito, anche "end"), "jump-start" ("start"), "jump-none" o "jump-both"; Rust: Easing::Steps(count, StepPosition::JumpEnd), JumpStart, JumpNone, JumpBoth.

Le curve sono nella loro forma “in” (lente all’inizio). Easing.out(curve) ne inverte una, Easing.inOut(curve) la rende simmetrica, Easing.in(curve) la lascia così com’è; in Rust si tratta del EaseMode di 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 in modo nativo le modifiche di layout del commit successivo: le view che si spostano o cambiano dimensione scivolano verso i nuovi frame, le nuove view compaiono (con dissolvenza o scala in ingresso, in base a property) e quelle rimosse scompaiono. Chiamalo subito prima del cambio di stato che modifica il layout. Rust: animate_layout(LayoutAnimation), con i preset di nsh_core::element::LayoutAnimation o le sue transizioni create, update e delete.

MembroRustDescrizione
configureNext(config, onEnd?)animate_layout(config)Anima le modifiche del commit successivo; onEnd viene eseguito al termine.
create(duration, type?, property?)LayoutAnimation::all(LayoutTransition::new(curve, duration))Una configurazione che usa la stessa curva per tutte e tre le fasi.
Presets.easeInEaseOutLayoutAnimation::EASE_IN_EASE_OUT300 ms, view in dissolvenza in ingresso e in uscita.
Presets.linearLayoutAnimation::LINEAR500 ms a velocità costante.
Presets.springLayoutAnimation::SPRING700 ms; i frame hanno un effetto spring (damping 0.4), le view sfumano in modo lineare.
easeInEaseOut(onEnd?), linear(onEnd?), spring(onEnd?)animate_layout(LayoutAnimation::…)Scorciatoie per configureNext(Presets.…).
Types, PropertiesLayoutCurve, LayoutPropertyI nomi, come costanti.
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

L’API Device informa l’app sul dispositivo e sul suo ambiente. Il codice Rust chiama le funzioni di nsh_core::prelude (window(), dark(), font_scale(), locale(), lifecycle(), os(), idiom(), pixel_ratio(), set_color_scheme(), …); TSX le importa da nsh. La dimensione della finestra, lo schema di colori, la scala del font, il rapporto di pixel, il locale e lo stato dell’app sono reattivi: leggili in un binding (una closure passata a un elemento in Rust, JSX in TSX), computed o effect, e ciò che li legge si aggiorna quando la piattaforma segnala un cambiamento (rotazione, ridimensionamento della finestra, passaggio alla modalità scura, modifica della dimensione del testo o della lingua, spostamento su uno schermo di densità diversa, uscita dall’app e ritorno).

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

Vedi Device e ambiente per la guida.

#windowSize

const windowSize: Accessor<Size>;

La dimensione della superficie in punti: la finestra, oppure l’elemento NshAppView / NshView incorporato. Reattivo. 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">;

L’aspetto mostrato: quello del sistema, a meno che l’app non ne imposti uno proprio (nsh.json "appearance", setColorScheme). Reattivo. Rust: dark() -> bool; i colori del tema (AdaptiveColor) e la variante di stile .dark(…) lo seguono senza bisogno di leggerlo. Anche la variante dark: di Tailwind lo segue (vedi Tailwind).

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

#fontScale

const fontScale: Accessor<number>;

La dimensione del testo preferita dall’utente rispetto a quella predefinita (1 è la dimensione predefinita; Dynamic Type su iOS, dimensione del font su Android), oppure la dimensione del testo impostata al suo posto dall’app che incorpora. Reattivo. Rust: font_scale() -> f32. Il testo si adatta già ad essa a meno che allowFontScaling={false}; leggila quando altre dimensioni devono seguirla, come le icone accanto al testo.

#locale

const locale: Accessor<string>;

Il locale dell’utente come tag BCP 47 ("en-US", "zh-Hans-CN"), oppure il locale impostato al suo posto dall’app che incorpora. Reattivo. Rust: locale() -> Locale (locale().as_str() è il tag). Intl formatta per esso in modo predefinito; leggilo per scegliere le stringhe delle tue traduzioni (Device e ambiente).

#lifecycle

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

Indica se l’app è in uso, nei termini di AppState di React Native: "active" in primo piano e in grado di ricevere input, "inactive" mentre è visibile senza il focus (un’altra app o finestra lo ha, oppure il sistema mostra qualcosa sopra di essa, come il selettore delle app o una chiamata in arrivo), "background" mentre non è visibile (in background, ridotta a icona, una scheda del browser nascosta). Un’app mostrata all’interno di un’app nativa segue quella di quest’ultima. Reattivo. Rust: lifecycle() -> Lifecycle (Lifecycle::Active, Inactive, Background).

Leggilo in un effect per mettere in pausa il lavoro che nessuno vede e riprenderlo quando l’app torna:

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

#setColorScheme

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

Mostra l’app in scheme qualunque sia l’aspetto del sistema, subito: colorScheme(), le classi dark:, il tema dei componenti e i controlli della piattaforma (gli alert, lo stile predefinito della status bar) lo seguono; "system" torna a seguire il sistema. nsh.json "appearance" lo imposta fin dall’inizio. 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;
}

La piattaforma su cui gira l’app, costante per tutta l’esecuzione.

MembroRustDescrizione
osos() -> Os (os().name() è la stringa TS)Il sistema operativo. iOS, il simulatore iOS e Mac Catalyst riportano "ios", i browser "web".
versionos_version() -> (u16, u16)iOS e desktop: la versione del sistema operativo ("17.5"); Android: il livello API ("35"); il web: "0.0". Rust fornisce la versione major e minor, oppure il livello API e 0 su Android.
idiomidiom() -> IdiomIl tipo di dispositivo: "desktop" esattamente dove desktop è true; altrove "phone", "pad" (un iPad, un tablet Android, Mac Catalyst con l’idioma iPad), "mac" (Mac Catalyst con l’idioma Mac di UIKit, un idioma mobile) o "tv" (Apple TV, Android TV).
desktopis_desktop() -> boolVale il comportamento desktop: macOS, Windows, Linux e i browser con un mouse e una finestra ampia. Le prop desktop={{…}}, select({ desktop }) e idiom "desktop" lo seguono tutte. Mac Catalyst esegue il backend iOS, quindi è mobile: qui si applicano le prop ios={{…}}. Il valore isAvailable di nsh/desktop è il controllo più ristretto per le sue API di finestre e menu, che hanno macOS, Windows e Linux.
catalystis_catalyst() -> boolL’app gira sotto Mac Catalyst, nell’idioma Mac o iPad (os è "ios").
select(spec)match os() { … }Il valore per questa piattaforma (vedi sotto).
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;
}

Restituisce il valore per il sistema operativo corrente (ios, android, web, …), altrimenti desktop o mobile (in base a platform.desktop), altrimenti native (ogni piattaforma tranne il web), altrimenti default, altrimenti undefined. Il codice Rust usa il match su os() (e su is_desktop() per 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>;

Pixel del dispositivo per punto (ad es. 3 sulla maggior parte degli iPhone). Reattivo: cambia quando la finestra si sposta su uno schermo di densità diversa (su Mac Catalyst, sui desktop o su un display esterno) e quando l’impostazione della dimensione dello schermo di Android cambia la densità. Rust: pixel_ratio() -> f32, e round_to_nearest_pixel(points) per una lunghezza in pixel interi del dispositivo; in TSX quella lunghezza è Math.round(x * pixelRatio()) / pixelRatio().

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

#openUrl

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

interface OpenUrlResult {
  opened: boolean;
}

Apre url fuori dall’app: una pagina web nel browser, un link mailto: nell’app di posta, un link tel: nell’app del telefono e qualsiasi schema registrato da un’altra app (maps:, i link lb:// propri di un’app host). Si risolve con { opened }, cioè se qualcosa l’ha aperto: false quando nessuna app gestisce lo schema. Rust: open_url(url) di nsh_core::prelude, una risposta da attendere (await) in un task (o da scartare), lo stesso bool.

button()
    .child(text("Contact us"))
    .on_press(|| {
        spawn(async {
            if !open_url("mailto:help@example.com").await {
                log::warn!("no mail app");
            }
        });
    })
  • L’URL è assoluto (ha uno schema). Gli URL che eseguirebbero codice o raggiungerebbero i dati dell’app stessa, javascript:, data:, blob:, file:, about: e asset:, rifiutano la promise con un TypeError, così come gli indirizzi relativi. openUrl non lancia mai eccezioni: ogni errore rifiuta la promise.
  • Un’app che dichiara capability richiede openUrl: "openUrl": true per ogni schema, oppure "openUrl": { "schemes": ["https", "mailto"] } per quelli elencati; gli altri URL vengono rifiutati con un Error il cui code è "ERR_ACCESS_DENIED", permission "openUrl" e resource l’URL (vedi Sandbox e capability). In Rust, open_url risponde false per questi e registra il motivo nel log.
  • L’app che incorpora vede l’URL per prima e può instradarlo invece tramite il proprio router (Swift NshAppView.onOpenURL, Java NshView.setOpenUrlHandler, Rust AppView::host().handle_open_url; vedi iOS e Android). opened è allora true.
  • Ogni piattaforma lo apre con il proprio opener: UIApplication.open su iOS (l’application dell’app host quando è incorporata), un intent ACTION_VIEW su Android, NSWorkspace su macOS, ShellExecuteW su Windows, GtkUriLauncher su Linux, open_url di GPUI, una nuova scheda sul web. nsh test registra gli URL invece di aprirli.
  • Un TextView apre i suoi link in questo modo con links="open".

#Direzione del layout

La direzione del layout segue la lingua dell’utente: in una lingua da destra a sinistra (arabo, ebraico, …) l’app viene disposta da destra a sinistra, le righe partono da destra e le proprietà di stile left / right (marginLeft, paddingRight, …) significano inizio e fine. Le modifiche si applicano subito, senza riavvio.

Lo stile direction di una view ("ltr", "rtl" o "inherit", il valore predefinito) imposta la direzione del proprio sottoalbero; in Rust, .direction(LayoutDirection::..).

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

#Correlati

  • Gli inset dell’area sicura (Rust safe_area(); TSX safeArea(), safeAreaInsets()) e la tastiera software (Rust keyboard_height(); TSX keyboardFrame()) sono in Area sicura e tastiera.
  • Le impostazioni dello screen reader e della riduzione del movimento sono in Accessibilità.

#PanResponder

I pan responder sono gesture di trascinamento costruite sul sistema dei gesture responder: l’elemento rivendica i tocchi di una gesture, poi li ascolta da solo insieme allo stato della gesture, che traccia per te il centroide dei tocchi, la distanza percorsa e la velocità. Il codice Rust li rivendica con .claim_touches(TouchClaim::…) e li ascolta con listener .on_responder_* il cui ResponderEvent porta un PanGesture, il tutto in nsh_core::prelude; TSX importa PanResponder da nsh, i cui callback onPanResponder… ricevono l’evento di tocco e lo stato della gesture.

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

#PanResponder.create

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

interface PanResponderInstance {
  panHandlers: TouchProps;
}

Crea un pan responder. Applica il suo panHandlers con lo spread sulla view che deve ricevere la gesture. In Rust, la view rivendica i tocchi e ascolta da sé:

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

#Callback

Ogni callback riceve l’evento di tocco (GestureResponderEvent, da nsh) e lo stato della gesture. In Rust, una rivendicazione decide con .ask(|request: &ClaimRequest| …), chiamando request.claim() per prendere i tocchi (senza ask li prende sempre), e ogni listener riceve un ResponderEvent il cui gesture è lo stato.

type PanHandler<R = void> = (e: GestureResponderEvent, gestureState: PanResponderGestureState) => R;
CallbackRustRestituisceChiamato quando
onStartShouldSetPanResponder.claim_touches(TouchClaim::on_start())booleanUn dito tocca lo schermo: restituisci true per diventare il responder. Viene chiesto dalla view toccata verso l’esterno.
onStartShouldSetPanResponderCaptureTouchClaim::on_start().capture()booleanLo stesso, ma chiesto dall’esterno verso l’interno prima di quello in bubbling (un genitore che toglie la gesture ai figli).
onMoveShouldSetPanResponderTouchClaim::on_move().ask(…)booleanUn dito si muove: restituisci true per diventare il responder (ad es. solo dopo Math.abs(g.dx) > 10).
onMoveShouldSetPanResponderCaptureTouchClaim::on_move().capture()booleanLo stesso, nella fase di capture.
onPanResponderGrant.on_responder_grant(…)Questa view è ora il responder: la gesture inizia.
onPanResponderReject.on_responder_reject(…)Un’altra view è il responder e non vuole cederlo.
onPanResponderStart.on_responder_start(…)Un dito ha toccato lo schermo mentre si stava rispondendo.
onPanResponderMove.on_responder_move(…)Le dita si sono mosse.
onPanResponderEnd.on_responder_end(…)Un dito si è sollevato mentre si stava rispondendo.
onPanResponderRelease.on_responder_release(…)L’ultimo dito si è sollevato: la gesture è terminata.
onPanResponderTerminate.on_responder_terminate(…)Un’altra view (o una gesture nativa, come una scroll view) ha preso i tocchi.
onPanResponderTerminationRequest.keep_touches(true) li mantienebooleanUn’altra view vuole i tocchi: restituisci false per mantenerli (predefinito true).
onShouldBlockNativeResponderTouchClaim::block_native(…)booleanSe impedire alle scroll view native di prendere i tocchi mentre si risponde (predefinito 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, con i punti come Point<Points>.

CampoRustDescrizione
stateIDIdentifica la gesture.
moveX, moveYpositionUltima posizione del centroide dei tocchi, in punti della superficie.
x0, y0originDove si trovava il centroide quando il responder è stato concesso.
dx, dydeltaDistanza percorsa dalla concessione.
vx, vyvx, vyVelocità in punti al secondo.
numberActiveTouchesactive_touchesDita appoggiate.

#Dentro le scroll view

Mentre un pan responder è il responder, le scroll view native che lo circondano non scorrono (onShouldBlockNativeResponder vale true per impostazione predefinita). Per far coesistere uno swipe orizzontale con lo scorrimento verticale, diventa il responder solo sui movimenti orizzontali:

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

Per un pan che la piattaforma riconosce, TouchClaim::on_pan(PanActivation::horizontal(10.0, 10.0)) decide sul main thread mentre le dita si muovono. Le prop responder sottostanti (onStartShouldSetResponder, onResponderGrant, onTouchStart, …) possono anche essere usate direttamente su qualsiasi elemento; vedi Elementi.

#Clipboard

L’API clipboard legge e scrive gli appunti di sistema: testo ovunque (UIPasteboard su iOS, ClipboardManager su Android, NSPasteboard, GDK e gli appunti Win32 sui desktop, navigator.clipboard sul web), immagini su iOS, sui desktop e sul web, e i file copiati in un file manager sui desktop e su Mac Catalyst. Il codice Rust chiama le funzioni di nsh_core::prelude (set_clipboard, clipboard_string, …); TSX importa Clipboard da nsh.

use nsh_core::prelude::*; // set_clipboard, clipboard_string, clipboard_image, ...
use nsh_core::element::ClipboardImage;
const Clipboard: {
  writeText(text: string): Promise<void>;
  readText(): Promise<string>;
  hasText(): Promise<boolean>;
  writeImage(src: string): Promise<void>;
  readImage(): Promise<ClipboardImage | null>;
  hasImage(): Promise<boolean>;
  readFiles(): Promise<string[]>;
};

interface ClipboardImage {
  url: string; // a URL that Image shows and fetch reads
  size: PixelSize; // in pixels
}
TSRust (nsh_core::prelude)
await Clipboard.writeText(text)set_clipboard(text)
await Clipboard.readText()clipboard_string().await -> String
await Clipboard.hasText()clipboard_has_string().await -> bool
await Clipboard.writeImage(src)set_clipboard_image(src)
await Clipboard.readImage()clipboard_image().await -> Option<ClipboardImage>
await Clipboard.hasImage()clipboard_has_image().await -> bool
await Clipboard.readFiles()clipboard_files().await -> Vec<String>

Le letture Rust restituiscono risposte da attendere (await) in un task (spawn, o una funzione di modulo async); ClipboardImage è nsh_core::element::ClipboardImage, con url e size (un Size<DevicePixels>).

Le immagini passano dagli appunti su iOS, sui desktop e sul web. Su Android gli appunti trasportano testo: writeImage e readImage vengono rifiutate con un Error il cui code è "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM", e hasImage si risolve con false. In Rust, set_clipboard_image lascia gli appunti com’erano e clipboard_image risponde None.

L’utente può anche copiare da un testo selezionabile per conto proprio; l’API clipboard serve per copiare dal codice, ad esempio con un pulsante Copia.

#Clipboard.writeText

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

Mette text negli appunti, sostituendo ciò che c’era. 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>;

Si risolve con il testo degli appunti, oppure con "" quando non ne contengono. Rust: clipboard_string(). Su iOS, leggere gli appunti può mostrare l’avviso di incolla del sistema (“App ha incollato da …”), e su Android 10 e successivi solo l’app in primo piano può leggerli. Leggili quando l’utente chiede di incollare, non a ogni schermata.

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

#Clipboard.hasText

hasText(): Promise<boolean>;

Si risolve indicando se gli appunti contengono testo, senza leggerlo: nessun avviso di incolla su iOS. Rust: clipboard_has_string(). Usalo per decidere se mostrare un pulsante Incolla. Sul web i browser lo sanno solo leggendo, quindi può chiedere il permesso all’utente.

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

#Clipboard.readImage

readImage(): Promise<ClipboardImage | null>;

Si risolve con l’immagine degli appunti, oppure con null quando non ne contengono: uno screenshot incollato, o un’immagine copiata in un browser o in un editor di immagini. Rust: clipboard_image(), None quando non ne contengono. url è un URL dell’immagine (PNG, oppure il JPEG, GIF, WebP o BMP degli appunti) che un Image mostra come src e che fetch legge: fetch(url) ne restituisce i byte, per un upload. size (width e height) è la sua dimensione in pixel, dopo la decodifica. Come per readText, leggila quando l’utente chiede di incollare. Vengono lette immagini fino a 48 MB (codificate), così che l’immagine stia nella memoria dell’app; una più grande viene rifiutata con un Error il cui code è "ERR_NSH_QUOTA_EXCEEDED" (il suo messaggio indica la dimensione dell’immagine e il limite), mentre hasImage si risolve con true. Rust: clipboard_image() risponde None in questo 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
}

Un’app in stile chat in cui gli utenti incollano screenshot in un composer associa la scorciatoia di incolla mentre il suo input ha il focus e prende l’immagine quando ce n’è una:

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

Si risolve indicando se gli appunti contengono un’immagine, senza leggerla: nessun avviso di incolla su iOS. Rust: clipboard_has_image(). Su Android si risolve con false.

#Clipboard.writeImage

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

Mette negli appunti l’immagine di src, un URL data: in base64 o il url restituito da readImage, sostituendo ciò che contengono. Rust: set_clipboard_image(src). Su Windows accetta un PNG, il formato che leggono i browser, Office e lo Strumento di cattura. Su Android viene rifiutata con "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM".

set_clipboard_image(image.url.clone());

#Clipboard.readFiles

readFiles(): Promise<string[]>;

Si risolve con gli URL file: dei file e delle cartelle copiati in un file manager (Finder, Explorer, Files), nel loro ordine, oppure con []: gli stessi URL che fornisce un file drop. Rust: clipboard_files(). Analizza un URL per ottenerne il percorso.

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

#Picker

L’API picker mostra il selettore di sistema per le foto e consegna all’app le immagini scelte dall’utente: il Photos picker su iOS (PHPickerViewController), il Photo Picker su Android, un pannello di apertura o una finestra di dialogo file sui desktop e un file input in un browser. Su iOS e Android il selettore viene eseguito fuori dall’app e consegna solo ciò che l’utente sceglie, quindi non richiede alcun permesso per la libreria foto. Ogni immagine scelta arriva come File (TSX) o PickedFile (Rust) i cui byte restano nativi: mostrala con URL.createObjectURL(file) in un Image, caricala con fetch e FormData. Il codice Rust chiama pick_images di nsh_core::prelude; TSX importa Picker da nsh.

use nsh_core::picker::{PickRequest, PickedFile};
use nsh_core::prelude::*; // pick_images
const Picker: {
  pickImages(options?: PickImagesOptions): Promise<PickedImage[]>;
};

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

interface PickedImage {
  file: File; // name, type, size in bytes and the bytes
  size: PixelSize; // upright
}
TSRust
await Picker.pickImages(options)pick_images(request).await -> Vec<PickedFile>
multiple, limitPickRequest::new(multiple, limit) (limit: 0 per un massimo di 100)
maxDimensionrequest.max_dimension = Some(DevicePixels(2048))
qualityrequest.quality = Some(Percentage(0.8))
formatrequest.format = PickFormat::Current
image.file.name, .type, .sizefile.name, file.mime, file.data.len()
image.sizefile.size (Size<DevicePixels>)

#Picker.pickImages

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

Mostra il selettore e si risolve con le immagini scelte dall’utente, nell’ordine di scelta, oppure con [] quando l’utente annulla. Rust: pick_images(request), una risposta da attendere in un task (spawn, o una funzione di modulo async). pickImages non lancia mai eccezioni: le opzioni fuori intervallo (limit: 0, un quality fuori dall’intervallo da 0 a 1) rifiutano la promise con un RangeError, e una selezione che le capability dell’app non consentono la rifiuta con ERR_ACCESS_DENIED (sotto). Un annullamento è una risposta dell’utente, non un fallimento: si risolve con [].

Le immagini arrivano pronte per essere inviate:

  • Formati accettati da ogni browser e server. JPEG, PNG, GIF e WebP arrivano così come sono; una foto memorizzata in un altro formato (HEIC su iPhone, HEIF, TIFF) arriva come JPEG. format: "current" mantiene il formato memorizzato.
  • Ridimensionate in modo nativo. Con maxDimension, un’immagine il cui lato più lungo supera il valore viene ridimensionata per rientrarvi, in posizione verticale, e codificata di nuovo: un PNG come PNG, qualsiasi altro formato come JPEG con quality. Le foto dei telefoni sono da 12 a 48 megapixel; ridimensionarle a 2048 pixel prima di un upload le riduce a poche centinaia di kilobyte. Anche quality codifica di nuovo i JPEG.
  • Ciò che mostra. size è la dimensione in pixel dell’immagine come appare, con l’orientamento della fotocamera applicato. Un’immagine che NSH codifica di nuovo non contiene metadati (nessuna posizione); quella che arriva così com’è mantiene i propri.

Su Windows, Linux e nelle finestre GPUI, la finestra di dialogo offre i formati accettati dalla richiesta (con "compatible": JPEG, PNG, GIF e WebP) e i file arrivano così come sono memorizzati, alla loro dimensione originale.

Un composer di chat allega una foto, la mostra mentre il messaggio viene scritto e la carica quando il messaggio viene inviato:

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
}

Più foto contemporaneamente:

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

#App che incorporano NSH

Un’app che incorpora NSH e ha un proprio selettore (ad esempio uno con gli album dell’app) risponde da sé alle selezioni: in Rust, un PickerBackend (nsh_core::picker) passato al motore (EngineConfig::picker, o AppView::set_picker nelle app GPUI e GPUI Kit) riceve il PickRequest di ogni selezione e restituisce i file, e il selettore di sistema non viene mostrato. I file che restituisce vengono completati come quelli della piattaforma: tagliati al limite della richiesta, con dimensioni, tipi e estensioni letti dai loro byte.

#Test

nsh test risponde alle selezioni nelle esecuzioni headless: app.onPick(handler) restituisce i file scelti dall’utente ({ name, type, data }), e senza un handler una selezione viene annullata. Vedi Testing.

#Speech

L’API speech trasforma ciò che l’utente dice in testo (dettatura): l’app avvia una sessione, la piattaforma ascolta il microfono e la trascrizione arriva man mano che cresce. macOS e iOS riconoscono la voce con il framework Speech di sistema; il sistema chiede all’utente una sola volta l’accesso al microfono e al riconoscimento vocale. Sulle altre piattaforme una sessione termina subito con l’errore "unavailable", quindi controlla Speech.available() prima di mostrare un pulsante del microfono. Il codice Rust chiama recognize_speech di nsh_core::prelude; TSX importa Speech da nsh.

use nsh_core::speech::{SpeechEvent, SpeechRequest};
const Speech: {
  available(): Promise<boolean>;
  start(options?: SpeechOptions): SpeechSession;
};

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

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

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

type SpeechErrorCode =
  | "not-allowed" | "no-speech" | "audio-capture" | "network"
  | "language-not-supported" | "unavailable" | "failed";
TSXRust
await Speech.available()speech_available().await -> bool
Speech.start(options)recognize_speech(request) -> SpeechRecognition
session.on("result", ...)recognition.next().await -> Option<SpeechEvent>
session.stop() / abort()recognition.stop() / abort() (eliminarlo interrompe la sessione)

#Speech.start

Avvia una sessione e la restituisce subito. Una sessione segnala, in quest’ordine: start quando il microfono è attivo e il riconoscitore ascolta, result ogni volta che la trascrizione cambia (text è sempre l’intera trascrizione finora, quindi l’app la mostra così com’è) e un solo end, dopo il quale non arriva più nulla. stop() smette di ascoltare e lascia che il riconoscitore termini ciò che ha sentito: un ultimo risultato con isFinal: true, poi end. abort() termina subito la sessione, senza errore, e scarta ciò che non è stato segnalato. Una sola sessione è attiva alla volta: avviarne un’altra interrompe quella in corso.

end contiene l’ultima trascrizione e, quando la sessione è fallita, error, denominato come nella Web Speech API:

errorQuando
"not-allowed"L’utente o il sistema non consente l’uso del microfono o del riconoscimento vocale.
"no-speech"Non è stato detto nulla.
"audio-capture"Non è stato possibile usare il microfono.
"network"Il riconoscitore richiede la rete e non è riuscito a raggiungerla.
"language-not-supported"Il riconoscitore non conosce lang.
"unavailable"Questa piattaforma non riconosce la voce, o non in questo momento.
"failed"Il riconoscitore è fallito per un altro motivo.

start lancia un TypeError per opzioni di tipo errato e un RangeError per un lang che non è un tag di lingua.

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

Si risolve con true dove una sessione può essere eseguita: su macOS e iOS, e ovunque l’app che incorpora NSH riconosca la voce da sé.

#Info.plist

Su macOS e iOS il sistema mostra perché l’app vuole accedere al microfono e al riconoscimento vocale. nsh package e nsh run scrivono NSMicrophoneUsageDescription e NSSpeechRecognitionUsageDescription nell’Info.plist dell’app quando l’app può usare la voce (la sua concessione capabilities speech, oppure non ne dichiara nessuna).

#App che incorporano NSH

Un’app che incorpora NSH e riconosce la voce a modo suo (con un proprio motore o servizio) risponde da sé alle sessioni: in Rust, un SpeechBackend (nsh_core::speech) passato al motore (EngineConfig::speech) riceve il SpeechRequest di ogni sessione e un SpeechSink a cui può riferire da qualsiasi thread, e restituisce il SpeechControl con cui il core interrompe e termina la sessione. Il riconoscitore della piattaforma non viene usato in questo caso.

#Test

nsh test esegue le sessioni su un riconoscitore fittizio nelle esecuzioni headless: una sessione si avvia subito, app.hear(text) segnala text come trascrizione finora, app.endSpeech(error?) la termina e app.speechRequests() elenca le sessioni che l’ app ha avviato. Vedi Testing.

#Storage

L’API storage conserva i dati dell’app sul dispositivo: stringhe identificate da una chiave stringa, ancora presenti al successivo avvio dell’app. Usala per impostazioni, bozze, l’ultima scheda aperta dall’utente, una cache di ciò che il server ha inviato: qualsiasi stato che debba sopravvivere al processo dell’app. Il codice Rust usa nsh_core::storage::Storage, TSX importa Storage da nsh; entrambi leggono e scrivono le stesse voci, quindi un’app con una voce Rust e una voce TSX vede un unico insieme di dati.

use nsh_core::storage::Storage;
const Storage: {
  getItem(key: string): Promise<string | null>;
  setItem(key: string, value: string): Promise<void>;
  removeItem(key: string): Promise<void>;
  keys(): Promise<string[]>;
  clear(): Promise<void>;
};
TSRust (nsh_core::storage::Storage)
await Storage.getItem(key)Storage::get_item(key) -> Option<String>
await Storage.setItem(key, value)Storage::set_item(key, value) -> Result<(), StorageFull>
await Storage.removeItem(key)Storage::remove_item(key)
await Storage.keys()Storage::get_all_keys() -> Vec<String>
await Storage.clear()Storage::clear()

Le funzioni Rust sono sincrone, poiché le voci sono in memoria: chiamale dove vengono eseguiti i componenti (i loro corpi, gli handler e i task).

Ogni app ha uno storage proprio, fino a 5 MB di chiavi e valori. Le chiamate vengono eseguite nell’ordine in cui l’app le effettua, quindi un getItem dopo un setItem vede il nuovo valore senza attendere la scrittura. Le letture provengono dalla memoria; le scritture raggiungono il disco in background, poche alla volta.

PiattaformaDove sono conservati i dati
iOSLibrary/Application Support/nsh/<app id>/storage.json nel container dell’app
Androidfiles/nsh/<app id>/storage.json nella directory privata dell’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/…)
WebIl localStorage del browser, sotto chiavi che prendono il nome dall’id dell’app (nsh:com.example.todo:…)

Un storage.json danneggiato viene conservato come storage.json.corrupt accanto all’originale, e l’app si avvia con uno storage vuoto; uno che non può essere letto lascia le modifiche dell’app in memoria, così il file non viene mai sostituito.

L’id dell’app è il id in nsh.json, e viaggia con l’app: nsh build e nsh package lo scrivono accanto al bundle, il dev server lo invia, e un pacchetto firmato porta quello garantito dal certificato della sua chiave di rilascio. Così ogni app conserva i propri dati senza alcuna configurazione, ovunque venga eseguita; un’app senza id li conserva sotto default. Un’app che incorpora NSH può scegliere un altro percorso (vedi Embedding su iOS ed Embedding su Android).

#Storage.getItem

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

Si risolve con il valore di key, oppure con null quando non ne ha. Rust: Storage::get_item(key), None quando non ne ha.

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

#Storage.setItem

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

Imposta key a value, sostituendo ciò che conteneva. Rifiuta con un Error il cui code è "ERR_NSH_QUOTA_EXCEEDED" quando lo storage supererebbe i 5 MB; il valore non viene conservato in tal caso. Rust: Storage::set_item(key, value), che in tal caso restituisce Err(StorageFull). I valori sono stringhe: conserva i dati strutturati come JSON (serde_json::to_string in Rust, JSON.stringify in TSX) e rileggili (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>;

Rimuove key e il suo valore. Rimuovere una chiave che non ha valore non fa nulla. Rust: Storage::remove_item(key).

#Storage.keys

keys(): Promise<string[]>;

Si risolve con tutte le chiavi, in ordine. Rust: Storage::get_all_keys().

#Storage.clear

clear(): Promise<void>;

Rimuove ogni chiave dell’app, ad esempio quando l’utente si disconnette. Rust: Storage::clear().

#Conservare un signal sul dispositivo

Leggi il valore conservato all’avvio dell’app, poi riscrivilo ogni volta che il signal cambia:

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 raggiunge l’app che incorpora NSH: l’app nativa attorno a un NshAppView / NshView, oppure, sul web, la pagina che esegue il player. L’app chiama le funzioni dell’host per nome con argomenti JSON e ascolta gli eventi inviati dall’host.

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 la piattaforma ha un host che risponde alle chiamate. Sul web è true quando la pagina fornisce un host al player (L’host sul 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>;

Chiama la funzione name dell’host con args (qualsiasi valore JSON; null se assente) e si risolve con ciò che restituisce. host.call non lancia mai eccezioni: ogni fallimento rifiuta la promise, con un Error il cui code dice cosa è successo (il messaggio è destinato alle persone e può cambiare):

codeQuando
ERR_NSH_HOST_ERRORLa funzione dell’host è fallita, oppure l’host non ha alcuna funzione name; il messaggio è quello dell’host
ERR_ACCESS_DENIEDname non è nella capability host dell’app; permission è "host", resource il nome
ERR_FEATURE_UNAVAILABLE_ON_PLATFORMLa piattaforma non ha un host: host.available() è false
ETIMEDOUTL’host non ha risposto in tempo
ERR_NSH_QUOTA_EXCEEDED1024 chiamate sono già in attesa delle risposte dell’app che incorpora NSH

Un nome non valido (da 1 a 128 caratteri tra A–Z, a–z, 0–9, _, ., :, -, senza ..) rifiuta con un TypeError.

Una chiamata attende 30 secondi la risposta dell’host, poi rifiuta con ETIMEDOUT; una risposta che arriva dopo viene scartata. options.timeout imposta l’attesa in millisecondi, 0 per nessun limite; anche un timeout così grande che l’orologio non lo raggiunge mai (circa 1e22 ms) attende senza limite, mentre un valore negativo, NaN o infinito rifiuta la chiamata con un RangeError. Il tempo è quello dell’app, come i suoi timer: in nsh test con un orologio manuale si muove con l’orologio.

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;

Gli eventi della piattaforma chiamano listener con il payload di ogni event inviato dall’host, finché non viene chiamata la funzione restituita da host.on; un listener aggiunto durante il rendering di un componente si interrompe con il componente. Gli eventi inviati dall’host prima dell’avvio dell’app arrivano non appena questa si avvia, quindi i listener registrati al livello superiore dell’app li ricevono; un evento inviato prima che un listener si registri non viene riproposto a quest’ultimo. Leggi lo stato con cui l’app si avvia tramite host.call, e segui i suoi cambiamenti con host.on.

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

#Capability

Ogni funzione chiamata dall’app deve essere elencata in capabilities.host di nsh.json: nomi esatti o prefissi che terminano con *, oppure true per tutte. Questo vale per ogni app, anche per una senza altri capabilities: le funzioni dell’host non sono mai concesse per impostazione predefinita. Una chiamata non concessa viene rifiutata con ERR_ACCESS_DENIED e registrata nel log, un host.post verso un’app che incorpora con un nome di questo tipo genera lo stesso errore, e la build di un’app che importa host da nsh senza capabilities.host emette un avviso. Gli eventi non richiedono alcuna concessione. Vedi Sandbox e capability.

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

#L’host sul web

Sul web l’host è la pagina. L’opzione host di createPlayer risponde alle chiamate e player.hostEvent(event, payload) invia gli eventi:

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

Un’app pubblicata indica il proprio host in nsh.json: web.host è un modulo JS nella directory dell’app, relativo a nsh.json, che esporta createHost(player), il quale restituisce l’oggetto con call. nsh package --target web lo include nel sito, nsh run --target web lo esegue nella pagina di sviluppo e nsh test lo esegue per le app aperte dai suoi test. web.connect elenca gli host raggiunti dalle richieste dell’host stesso, che la Content-Security-Policy della pagina consente insieme a capabilities.network dell’app. Vedi Web.

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

#host.params

I parametri impostati dall’app che incorpora prima di avviare la tua app, letti in modo sincrono dalla prima riga del tuo codice; {} se non ne ha impostati. Restano invariati mentre l’app è in esecuzione: le richieste successive arrivano come eventi.

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

#Canale del Native SDK

send del Native SDK conserva gli eventi finché l’app non si mette in ascolto, fino a 256. Quando l’ultimo listener si ferma durante la consegna, gli eventi rimanenti restano in attesa in ordine; gli eventi inviati durante lo spegnimento attendono la successiva app caricata. host.post(name, data) consegna i dati al gestore del Native SDK, il cui isPost distingue i post dalle chiamate.

#Il lato dell’app che incorpora

Ogni vista mantiene un canale per ogni app che carica. Imposta i parametri prima del caricamento, invia eventi in qualsiasi momento da qualsiasi thread e registra i gestori per nome. Chiamate e post richiedono la concessione capabilities.host dell’app. I gestori rispondono una sola volta, subito o in seguito; una risposta che non arriva mai fa fallire la chiamata.

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

Vedi Embedding su iOS, Embedding su Android e GPUI.

#keymap

Il keymap associa le scorciatoie da tastiera dell’app: una sequenza di tasti come "mod+k" e la funzione che esegue. Il codice Rust chiama on_key, on_key_with e key_label da nsh_core::prelude; TSX importa keymap da nsh. Entrambi accettano le stesse sequenze di tasti e seguono le stesse regole.

use nsh_core::prelude::*; // on_key, on_key_with, KeyOptions, key_label
const keymap: {
  bind(keystroke: string, handler: (e: { keystroke: string }) => void, options?: KeyBindingOptions): () => void;
  bind(bindings: Record<string, (e: { keystroke: string }) => void>, options?: KeyBindingOptions): () => void;
  label(keystroke: string): string;
};

interface KeyBindingOptions {
  when?: "always" | "notEditing";
  repeat?: boolean;
}
TSRust
keymap.bind(keystroke, handler)on_key(keystroke, handler)
keymap.bind(keystroke, handler, { when, repeat })on_key_with(keystroke, KeyOptions { when, repeat }, handler); when è nsh_core::keymap::When (Auto, Always, NotEditing)
keymap.bind({ … })un on_key per ogni sequenza di tasti
keymap.label(keystroke)key_label(keystroke)

Un binding Rust vive finché vive il componente (il proprietario) che lo ha creato; il gestore riceve la sequenza di tasti così come è scritta.

Le scorciatoie funzionano su macOS, Windows, Linux e nei browser (i tasti propri del browser, come Ctrl+T o Cmd+W, non raggiungono mai la pagina), e su iOS e Android con una tastiera fisica: quella di un iPad, di un Mac che esegue un’app per iPad, di un tablet o di un Chromebook, quella del simulatore e quella dell’emulatore. Lo stesso codice funziona ovunque; senza tastiera, nessun tasto attiva i binding.

#keymap.bind

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

Esegue handler quando viene premuto keystroke e restituisce una funzione che rimuove il binding. Il gestore riceve { keystroke }: la sequenza di tasti come l’hai scritta, con le sue maiuscole o minuscole. Rust: on_key(keystroke, handler), il cui gestore riceve la sequenza di tasti stessa.

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

Passa un oggetto per associare più sequenze di tasti in una volta; la funzione restituita le rimuove tutte. In Rust, ogni sequenza di tasti è un on_key:

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

Un binding creato mentre un componente viene renderizzato viene rimosso insieme al componente, quindi le scorciatoie di una schermata durano quanto la schermata:

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

Con il dev server, modificare un modulo rimuove i binding creati dal suo codice di primo livello prima che venga eseguito di nuovo, così l’hot reload non lascia mai scorciatoie obsolete.

#keymap.label

label(keystroke: string): string;

Restituisce keystroke come lo mostrano menu e suggerimenti dove l’app è in esecuzione: "⇧⌘L" su macOS e iOS (e in un browser su Mac, iPhone o iPad), "Ctrl+Shift+L" su Windows, Linux e altri browser, per "mod+shift+l". Rust: key_label(keystroke). Usalo per mostrare le scorciatoie di un’app con i tasti che funzionano:

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

Genera un TypeError per una sequenza di tasti non valida, come fa bind; key_label e on_key vanno in panic con lo stesso messaggio.

#Sequenze di tasti

Una sequenza di tasti è composta da modificatori e un tasto uniti da +: "mod+k", "ctrl+shift+p", "alt+enter", "escape", "f5", "cmd+-" (Command e meno).

ModificatoremacOSWindowsLinux
mod (anche cmdorctrl)Command (⌘)CtrlCtrl
cmd (anche command, meta, super, win)Command (⌘)Tasto WindowsSuper
ctrl (anche control)Control (⌃)CtrlCtrl
alt (anche option, opt)Option (⌥)AltAlt
shiftShift (⇧)ShiftShift

iOS segue macOS (mod è Command) e Android segue Windows e Linux (mod è Ctrl, cmd il tasto Meta). In un browser, i tasti seguono la piattaforma su cui il browser è in esecuzione: mod è Command su Mac, iPhone o iPad e Ctrl altrove.

Usa mod per le scorciatoie proprie dell’app: è il tasto che le app di ogni piattaforma usano a tale scopo (⌘K su macOS, Ctrl+K su Windows e Linux), quindi un’unica scrittura va bene ovunque. Il tasto Windows e Super appartengono perlopiù al sistema, quindi cmd raramente è la scelta giusta al di fuori di macOS.

Il tasto è una lettera, una cifra o un segno di punteggiatura, scritto come il carattere che produce su una tastiera US senza Shift (k, 1, ,, /, [; "shift+/" per ?, "shift+k" per Shift+K), oppure un nome: plus (il tasto = / +, scritto anche =), minus (anche -), space, enter (return), escape (esc), tab, backspace, delete (del), insert, le frecce up, down, left, right (arrowup, …), home, end, pageup, pagedown, da f1 a f24. Maiuscole e minuscole non contano: "Mod+Shift+K" equivale a "mod+shift+k", e una lettera maiuscola è lo stesso tasto, non Shift ("mod+K" equivale a "mod+k"). Lo stile minuscolo è quello consigliato e viene usato in queste pagine. "mod-k" e "mod++" sono errori i cui messaggi indicano la scrittura corretta. Le scorciatoie di menu in nsh/desktop accettano le stesse sequenze di tasti.

Le scorciatoie funzionano con qualsiasi layout di tastiera: con un layout cirillico o greco, mod+k è il tasto che digita K su un layout US, come avviene per le scorciatoie di menu.

bind genera un TypeError che indica cosa non va in una sequenza di tasti non valida (keymap.bind: invalid keystroke "cmd+kk": unknown key "kk") e rifiuta le sequenze che il sistema riserva a sé, così un’app non può mai romperle:

PiattaformaRiservate
macOS, iOSCmd+Q (esci), Cmd+H (nascondi), Option+Cmd+H (nascondi altre), Cmd+M (riduci a icona), Ctrl+Cmd+F (schermo intero)
Linux, Windows, AndroidCtrl+Q (esci), Alt+F4 (chiudi), F11 (schermo intero)
WebNessuna (il browser mantiene i propri tasti)

#Durante la digitazione

Un input di testo con il focus della tastiera ha bisogno dei propri tasti: digitare, spostare il cursore, cancellare, e selezionare tutto, tagliare, copiare, incollare, annullare e ripristinare. Perciò, per impostazione predefinita, un binding non si attiva mentre un input di testo (o una web view) ha il focus se la sua sequenza di tasti è una di queste: tasti semplici e tasti con solo Shift oppure Option, le frecce, Home, End, Page Up / Down, Return, Tab, Escape, Backspace, Delete, e mod+a, mod+c, mod+v, mod+x, mod+z. Le altre sequenze con Command o Control, e i tasti funzione, si attivano ovunque si trovi il focus.

when modifica questo comportamento (Rust: KeyOptions { when: When::Always, .. }):

whenSi attiva
(predefinito)Come sopra
"always"Anche negli input di testo, prima che l’input veda il tasto
"notEditing"Mai mentre un input di testo ha il focus
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());

Su iOS e Android, un tasto con cui un metodo di input sta componendo (scelta di un candidato, conferma) resta del metodo di input. Sul backend GPUI, un componente nativo con il focus della tastiera conta come input di testo, perché potrebbe essere un editor di testo. Vede ogni tasto per primo: un tasto che gestisce da sé non raggiunge mai i binding, e i binding che si attivano durante la modifica del testo ricevono i tasti che lascia passare.

#Quale binding viene eseguito

Un tasto che attiva un binding viene consumato: non viene digitato, il sistema non emette alcun segnale acustico e i tasti propri di NSH non lo vedono (Escape che torna indietro, Tab che sposta il focus, le frecce che scorrono). Quando più binding hanno la stessa sequenza di tasti, viene eseguito il più recente tra quelli che possono attivarsi, quindi una finestra di dialogo può associare Escape sopra il binding della schermata, e quello della schermata torna attivo quando scompare quello della finestra. I binding vengono controllati in modo nativo nel momento in cui il tasto è premuto; il gestore viene eseguito subito dopo, sul thread del motore (Rust) o sul thread JS (TSX).

Tenere premuto un tasto attiva il suo binding una sola volta, finché il tasto non viene rilasciato; con repeat: true si attiva di nuovo a ogni ripetizione automatica (per scorrere una lista, ad esempio):

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

Le scorciatoie di menu cedono il passo a un binding con la stessa sequenza di tasti: quelle del menu dell’applicazione (nsh/desktop) su ogni desktop e, su macOS, anche quelle dei menu integrati (Cmd+W, Cmd+C, …). Durante lo sviluppo con nsh run, Cmd+R / Ctrl+R ricarica sempre l’app, anche se questa associa mod+r; un’app pacchettizzata può associarlo.

#nativeComponent e nativeModule

nativeComponent e nativeModule collegano la tua app a ciò che l’app host fornisce, per nome, a runtime. Un’app iOS o Android che incorpora NSH può registrare le proprie viste (una mappa, un grafico, un’anteprima della fotocamera) per nome; nativeComponent ti dà un componente tipizzato per una di esse. Poiché NSH esegue il rendering con vere viste native, un componente di questo tipo si trova nella gerarchia delle viste come qualsiasi altra vista: viene disposto da NSH, viene ritagliato e scorre insieme ai suoi genitori e si sovrappone correttamente. Un’app che incorpora NSH da Rust può anche registrare funzioni, classi e componenti Rust; nativeModule te li restituisce come un unico oggetto tipizzato. In entrambi i casi la tua app viene compilata con nsh build standard: l’host collega ciò che fornisce quando esegue l’app. Il codice Rust posiziona una vista registrata con native(name) da nsh_core::prelude, ne imposta le prop con .prop(name, value) (un valore JSON, oppure un signal o una closure che lo produce) oppure ne associa una a un valore animato con .bind_prop(name, &value), ne ascolta gli eventi con .on_native_event(…) e ne chiama i comandi tramite un ElementRef; TSX importa nsh.

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

Vedi Componenti nativi per registrare il lato nativo, e Embedding su iOS / Embedding su 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)[];
}

Restituisce un componente per il componente nativo che l’app che incorpora ha registrato come name (Swift NshAppView.register(component:), Java NshView.registerComponent). Rust: native(name), una vista le cui prop sono valori JSON (.prop("text", json!(…))) e i cui eventi arrivano come NativeEvent { name, data }.

  • P è il tipo delle sue prop. Ogni prop elencata in spec.props viene inviata al lato nativo come JSON quando cambia; impostarla a undefined la rimuove.
  • E associa i nomi degli eventi ai loro payload. Un evento regionChange diventa la prop del gestore onRegionChange.
  • Il componente accetta anche ogni prop di vista tranne children: style, class, layout, accessibilità, gestori del tocco 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 };

#Dimensioni

Il componente viene disposto come una vista, con la dimensione indicata dal suo stile. Dove lo stile lascia una dimensione aperta, usa la dimensione riportata dal componente nativo (come un’immagine usa la propria dimensione naturale).

#Comandi

Il suo ref riceve un NativeComponentRef; ref.command(name, args) chiama il comando name del componente nativo con args (qualsiasi valore JSON). Rust: ElementRef::command(name, args).

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

#Quando non è registrato nulla

Dove non è registrato nulla sotto name (un’altra app host, l’host di sviluppo, il browser), il componente esegue il rendering di una vista vuota e viene registrato un avviso una sola volta. Mantieni l’app utilizzabile anche senza di esso, oppure controlla il tuo host prima di usarlo.

#Esempio

Il badge qui è una vista nativa dell’app host, registrata come embed.badge. NSH la dispone, le invia text e color, riceve i suoi eventi tap e chiama il suo 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;

Restituisce gli export del set che l’app ospitante ha registrato come specifier: i suoi moduli, classi, funzioni e componenti Rust, come un unico oggetto. Il tipo T lo scrive l’app stessa, quindi per costruirlo non serve sapere nulla dell’host. Il codice Rust compilato insieme al crate dell’host chiama queste funzioni come normali funzioni Rust; nativeModule è il modo in cui TSX le raggiunge.

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));
  • Le chiamate funzionano come con l’import di un set: una normale funzione Rust restituisce subito il proprio valore, una funzione async restituisce una promise, e una funzione che passi è una callback che Rust può chiamare in seguito. Rust controlla ogni argomento e genera un TypeError se uno è errato.
  • Ogni chiamata con lo stesso specifier restituisce lo stesso oggetto.
  • Se non è registrato nulla sotto specifier, nativeModule genera un ReferenceError che lo indica. Chiamalo dove l’app può gestire questo caso, oppure solo nell’host per cui è stato creato.

L’host registra il set da Rust, con il set_ui_sets del crate della piattaforma (accanto ai set propri di NSH) prima di avviare le proprie view, oppure con 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 è il modo in cui l’host mantiene ogni app allineata ai propri dati: il task che avvia attende le modifiche dell’host e invia ciascuna all’app, così una modifica fatta dall’host, o da una qualunque app tramite un’altra funzione, raggiunge ogni app in ascolto. Il Stop che restituisce è la funzione che Trading.on restituisce in TSX: viene eseguita una sola volta, quando l’app la chiama o quando termina ciò che ha chiamato on (un componente, oppure l’esecuzione del modulo con l’hot reload), e qui rimuove il listener, quindi il task termina.

#Accessibility

L’API di accessibilità legge le impostazioni di accessibilità dell’utente (se è attivo uno screen reader, se ha chiesto meno movimento) e dialoga con lo screen reader. Il codice Rust chiama screen_reader(), reduce_motion() e announce_for_accessibility() da nsh_core::prelude e sposta il focus con un ElementRef; TSX importa screenReaderEnabled(), reduceMotion(), announce() e setAccessibilityFocus() da nsh. Le impostazioni sono reattive: leggile in un binding, in computed o in effect, e ciò che le legge si aggiorna quando l’utente le modifica. Le props di accessibilità degli elementi (accessibilityLabel, role, …) sono documentate in Elements; la guida è Accessibility.

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

#screenReaderEnabled

const screenReaderEnabled: Accessor<boolean>;

Indica se uno screen reader (VoiceOver, TalkBack) è in esecuzione. Reattivo: ciò che lo legge si aggiorna quando l’utente attiva o disattiva lo screen reader. Rust: screen_reader() -> bool, reattivo allo stesso modo.

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 l’utente ha chiesto meno movimento (Reduce Motion su iOS, Remove animations su Android). Reattivo. Rust: reduce_motion() -> bool. Anche le classi Tailwind lo seguono, con la 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;

Lo screen reader legge message (un annuncio di VoiceOver, TalkBack). Usalo per i risultati che compaiono senza che il focus si sposti, come “Salvato”. Rust: announce_for_accessibility(message).

#setAccessibilityFocus

function setAccessibilityFocus(node: ViewRef): void;

Sposta il focus dello screen reader su node (il ref di un 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))
}

#Types

nsh esporta anche i tipi dell’API reattiva e degli elementi host. I più usati:

TipoCos’è
Accessor<T>Un valore leggibile: () => T
Signal<T>Un Accessor<T> con set e update
SignalOptions<T>Opzioni di signal e computed (equals)
Context<T>Ciò che restituisce context
Component<P>(props: P) => JSX.Element
JSXIl namespace JSX (JSX.Element)
ViewRef, InputRef, ScrollRef, ListRef, WebViewRef, VideoRef, NativeComponentRefCiò che riceve un ref, il tipo specifico di ciascun elemento (ViewRef e i tipi che vi si basano): focus, scroll e altri comandi. ViewRef è anche ciò che è un elemento (JSX.Element). Vedi i metodi dei ref
ViewProps, TextProps, ImageProps, ScrollProps, CommonProps, TouchPropsProps degli elementi host
ViewStyle, TextStyle, InputStyle, ImageStyle, LayoutStyle, StyleProp<T>Oggetti di stile; vedi il riferimento degli stili
ColorValue, DimensionValue, LengthValue, TransformStepValori all’interno degli stili
GestureResponderEvent, MouseEvent, MouseButton, ContextMenuEvent, PressEvent, ScrollEvent, PanEvent, TouchPointPayload degli eventi
Point, Size, Bounds, Edges, ModifiersLe strutture di cui sono fatti payload e props, con i campi di GPUI (nsh_core::geometry)
PixelSizeLa dimensione propria di un’immagine o di un video in pixel (onLoad, Image.getSize, le immagini degli appunti e del selettore), dove Size è in punti
ImageAssetCiò che si ottiene importando un file immagine ({ url, size: Size, scales? }; scales elenca le sue varianti per densità di pixel)
OS"ios" | "android" | "macos" | "windows" | "linux" | "web"
AccessibilityRole, AccessibilityStateProps di accessibilità
import { type GestureResponderEvent, type InputRef, signal } from "nsh";

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

StyleProp<T> accetta uno stile o un elenco di stili uniti da sinistra a destra, ignorando i valori falsy:

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