Diese Seite wurde maschinell übersetzt und kann Fehler enthalten. Das englische Original lesen

API

nsh

nsh ist das Kernmodul: die reaktive API (Signals, Computed Values, Effects, Context), mount, token, die Host-Elemente View, Text, Scroll und Overlay, auswählbare Views, die Safe Area und die Tastatur, Animation, das Gerät und seine Umgebung, PanResponder, die Zwischenablage, den Foto-Picker, die Spracherkennung, Storage, den Host-Kanal, Tastenkürzel, native Komponenten und Module, Barrierefreiheit sowie die TypeScript-Typen von Elementen, Styles und Events. Der reaktive Graph selbst läuft nativ in Rust; das Lesen eines Signals ist ein Aufruf in die Runtime, der wenige Nanosekunden kostet, und nur deine eigenen Closures laufen im JavaScript-Interpreter.

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

Die Reaktivität von NSH folgt demselben Modell wie SolidJS: Eine Komponentenfunktion läuft einmal, und die Teile der UI, die Signals lesen, werden an Ort und Stelle aktualisiert, wenn sich diese Signals ändern. Es gibt weder ein erneutes Rendern der Komponente noch ein Virtual DOM. Den Leitfaden findest du unter Reaktivität.

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

Ein Signal hält einen Wert. Ein Aufruf liest den Wert und abonniert dabei, innerhalb eines Tracking-Scopes (JSX-Ausdrücke, computed, effect), diesen Scope darauf. set und update schreiben es; alles, was es gelesen hat, wird aktualisiert.

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

Standardmäßig bewirkt ein Schreibvorgang, der === zum aktuellen Wert ist, nichts. Übergib equals: false, um bei jedem Schreibvorgang zu benachrichtigen (nützlich, wenn du ein Objekt direkt veränderst und dasselbe Objekt erneut setzt), oder einen eigenen Vergleich:

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

Ein aus Signals abgeleiteter Wert. fn läuft erneut, wenn sich ein gelesenes Signal ändert; Leser des Computed Value werden nur aktualisiert, wenn sich sein Ergebnis ändert (verglichen mit equals, standardmäßig ===). fn erhält das vorherige Ergebnis.

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

Verwende computed, wenn eine Ableitung aufwendig ist oder an mehreren Stellen gelesen wird. Bei einem günstigen Ausdruck ist eine einfache Funktion (const full = () => first() + " " + last()) genauso reaktiv.

#effect

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

Führt fn nach dem Rendern aus und erneut, sobald sich ein gelesenes Signal ändert. Verwende Effects für Seiteneffekte: Logging, Timer, Kommunikation mit nativen Modulen. Ein Effect gehört zu der Komponente (oder dem Effect), in der er erstellt wurde, und wird mit ihr entsorgt.

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

Um etwas rückgängig zu machen, bevor der Effect erneut läuft (oder wenn seine Komponente verschwindet), rufe darin cleanup auf.

#batch

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

Führt fn aus und wendet alle Schreibvorgänge gebündelt am Ende an, sodass Leser einmal statt einmal pro Schreibvorgang aktualisiert werden. Gibt zurück, was fn zurückgibt.

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

#untracked

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

Führt fn aus, ohne den aktuellen Scope auf das Gelesene zu abonnieren. Verwende es, um ein Signal in einem Effect oder JSX-Ausdruck zu lesen, ohne davon abhängig zu werden.

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

#cleanup

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

Registriert fn, damit es läuft, bevor der aktuelle Effect erneut läuft oder wenn die aktuelle Komponente bzw. der aktuelle Effect verschwindet.

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

Ein Wert, den eine Komponente allem bereitstellt, was sie rendert, egal wie tief. Der Aufruf des Context (Theme()) liest den Wert des nächstgelegenen Providers, ohne Provider dagegen defaultValue.

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

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

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

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

mount(App);

#mount

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

Rendert app in die Surface (das Fenster oder den NshAppView / NshView, in den die App eingebettet ist) und gibt eine Funktion zurück, die es wieder entfernt. Die Einstiegsdatei jeder App endet mit 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]>];

Teilt ein Props-Objekt in die in keys genannten Props und den Rest auf. Beide Hälften bleiben reaktiv: Props, deren Werte sich ändern, sind Getter, und split behält sie als Getter. Verwende es in einer Komponente, die einige Props selbst verarbeitet und die übrigen weitergibt. Destrukturieren von Props (const { label } = props) würde sie einmal lesen und Aktualisierungen verlieren.

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;

Rendert seine Children oder fallback, sobald darunter etwas einen Fehler wirft: der Body einer Komponente, ein Binding, ein Effect oder ein Event-Handler. Der fehlgeschlagene Teil wird entfernt, und an seiner Stelle erscheint der Fallback. Eine Funktion fallback wird als fallback(error, reset) aufgerufen, mit dem geworfenen Wert und reset, das die Children erneut rendert, unabhängig davon, welche Parameter sie deklariert; ein fallback-Element wird unverändert angezeigt. Ein Fehler geht an die nächste Boundary darüber, und ein Fehler in einem Fallback an die Boundary darüber. Rust-Komponenten verwenden catch_error (Error Boundaries).

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

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

#token

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

Das Design-Token name des Themes der App ("theme" in nsh.json, über den Tokens von shadcn/ui) in der angezeigten Darstellung: eine Farbe als #rrggbbaa, eine Länge als Anzahl von Punkten, eine Schrift als ihr Familienname. Die Namen sind die CSS-Variablen des Themes ohne -- (primary, muted-foreground, radius, font-mono und die eigenen der App, etwa up). Das Lesen ist reaktiv: Eine Komponente, ein Memo oder ein Effect, das bzw. der ein Token liest, läuft erneut, wenn sich das Theme oder die Darstellung ändert. Tokens listet die Tokens der App mit ihren Typen auf; NSH schreibt es aus dem Theme nach .nsh/types/nsh/tokens.d.ts. Für Klassen benennst du die Tokens direkt (bg-primary, rounded-(--radius)): Theme-Tokens.

import { effect, token } from "nsh";

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

#View, Text, Scroll, Overlay

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

Die Host-Elemente, die nsh exportiert, als Tags geschrieben: <View>, <Text>, <Scroll>, <Overlay> (seine Children liegen über dem Fenster und werden an einem Anker ausgerichtet: Overlay). Der Compiler erzeugt die native View direkt an Ort und Stelle für ein Tag, das über seinen Import eines davon benennt (auch per Alias), sodass das Importieren zur Laufzeit nichts kostet. Sie sind Komponenten wie alle anderen und können daher auch herumgereicht werden (const Tag = inline ? Text : View). nsh exportiert die übrigen Primitive auf dieselbe Weise: List, Image, Canvas, WebView und Video, jeweils mit eigener Referenzseite. Ihre Props sind unter Elemente dokumentiert. Das Texteingabefeld, Input, stammt aus nsh/base.

#Safe Area und Tastatur

Die Umgebungsvariablen von CSS halten Inhalte frei von der System-UI, im Padding und Margin jeder View (padding, paddingTop, …, dieselben margin-Schlüssel; die beliebigen Werte p* / m* von Tailwind; die Styled-Methoden .p_safe_area(), .pt_safe_area_plus(..), .pb_keyboard(), … in Rust). Ihre Werte sind EnvValues: env(name), env(name, <length>), calc(env(name) + <length>) und calc(<length> + env(name)).

Variable Wert
safe-area-inset-top, -right, -bottom, -left Die Insets der Root-View, in der der Node gerendert wird: die der nächsten Page (unterhalb ihres Headers), der nächsten Dialog-View oder sonst die Safe Area der Surface. Siehe Safe Area.
keyboard-inset-height Die Höhe der Software-Tastatur über der Surface, 0 solange sie ausgeblendet ist; Änderungen werden nativ mit der Tastatur animiert. Siehe Tastatur.
<View style={{ flex: 1, paddingTop: "env(safe-area-inset-top)", paddingBottom: "env(keyboard-inset-height)" }}>…</View>

#safeArea und safeAreaInsets

safeArea() ist die Safe Area der Surface (safe_area() in Rust); safeAreaInsets() die Insets dort, wo eine Komponente gerendert wird (cx.insets() in Rust), die Werte der safe-area-inset-*-Variablen. Beide sind reaktive Edges ({ top, right, bottom, left } in Punkten). Siehe safeArea und safeAreaInsets.

#Tastatur und keyboardFrame

Keyboard.dismiss() blendet die Software-Tastatur aus (dismiss_keyboard() in Rust), und keyboardFrame() zeigt dir, wie viel vom Bildschirm sie verdeckt (keyboard_height() und keyboard_duration() in Rust). Siehe Die Tastatur ausblenden und Die Höhe der Tastatur.

#Auswählbare Views

<View selectable> (view().selectable(true) in Rust) sorgt dafür, dass die darin enthaltenen Texte gemeinsam ausgewählt werden, wie auf einer Webseite: Eine Auswahl läuft in Lesereihenfolge über Views, Text-Elemente und TextViews hinweg. Siehe Textauswahl.

#Animation

Die Animations-API animiert auf dem UI-Thread. Du erstellst einen animierten Wert, bindest ihn (oder davon abgeleitete Werte) an eine Style-Eigenschaft und startest eine Animation; ab dann wird jeder Frame nativ berechnet, ohne dass Code der App läuft. Eine ausgelastete Engine oder ein ausgelasteter JS-Thread kann die Animation nicht ruckeln lassen, und eine Animation verursacht der App keinen Aufwand pro Frame. Rust-Code verwendet animated_value(), motion(), Animation, Spring, Timing, KeyframeTiming, MotionPreset und Easing aus nsh_core::prelude und bindet Werte mit den .bind_*-Methoden von Elementen; TSX importiert sie aus nsh.

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

Den Leitfaden findest du unter Animation.

#Was ein animierter Wert steuern kann

Wo Rust TSX
opacity .bind_opacity(&value) style={{ opacity: value }}
backgroundColor .bind_background(&value.interpolate_colors(&[0.0, 1.0], &[white(), black()])) style={{ backgroundColor: value.interpolate({ inputRange: [0, 1], outputRange: ["#fff", "#000"] }) }}
borderColor, color .bind_border_color(&node), .bind_color(&node) Dasselbe: ein Node aus Farben. borderColor färbt jede Seite; color eines Text färbt den gesamten Text
transform .bind_translate_x(&value), .bind_translate_y, .bind_scale, .bind_rotate (Grad) translateX, translateY, scale, scaleX, scaleY, rotate, rotateX, rotateY, rotateZ Schritte: transform: [{ translateX: value }]
Scrollposition .scroll_value(&value) an einer Scroll-View oder einer Liste <Scroll scrollValue={value}> und <List scrollValue={value}> schreiben in jedem Frame den Scroll-Offset hinein
Gesten .pan_values(Some(&x), Some(&y)), .pinch_value(&scale), .rotation_value(&angle) pan={{ x, y }}, pinch={{ scale }}, rotation={{ angle }} an jedem Element: Die Geste schreibt die Werte direkt (siehe Touch und Gesten)
Wo Views haften bleiben .sticky().bind_sticky_top(&value) (.bind_sticky_left in einer horizontalen Scroll-View), .bind_sticky_header_offset(&value) an einer Liste style={{ position: "sticky", top: value }}, <List stickyHeaderOffset={value}>: Sticky-Views und -Header haften so weit vom Anfang des Viewports entfernt und folgen in jedem Frame dem Wert
Canvas ein animiertes Argument eines Painter-Aufrufs Jedes Zahlen- oder Farbargument eines Zeichenaufrufs im Canvas
Shader .uniform(name, &value) Ein Member von struct Uniforms eines Shaders: uniforms={{ name: value }} (Zahlen oder ein Node aus Farben für ein vec3f / vec4f)

Andere Style-Eigenschaften nehmen einfache Werte an; um das Layout selbst zu animieren (Größen, Positionen im Fluss), verwende LayoutAnimation.

#createAnimatedValue

function createAnimatedValue(value?: number): AnimatedValue;

Erstellt einen animierten Wert, der bei value startet (Standard 0). Rust: animated_value(value), ein Copy-Handle, das von der Komponente gehalten wird, die ihn erzeugt hat.

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 }>;
}
Methode Rust Beschreibung
set(value) set(value) Springt zu value und stoppt jede laufende Animation.
stop() stop(), dann value().await Stoppt die laufende Animation an der aktuellen Stelle; wird mit { value } aufgelöst, dem Wert an dieser Stelle.
getValue() value().await Wird mit dem aktuellen Wert (samt Offset) aufgelöst, gelesen auf dem UI-Thread.
setOffset(offset) set_offset(offset) Ein Offset, der zum Wert addiert wird: Bindings sehen Wert + Offset; set, Animationen und Drags bewegen den Wert.
flattenOffset() flatten_offset() Addiert den Offset zum Wert und setzt ihn auf 0.
extractOffset() extract_offset() Verschiebt den Wert in den Offset und setzt den Wert auf 0 (ein Drag beginnt dann dort, wo der letzte endete).
timing(to, options) timing(to, ms, easing), timing_with(to, Timing { … }) Animiert entlang einer Easing-Kurve zu to. duration in ms (Standard 300), easing (Standard "easeInOut"), delay in ms (Standard 0).
spring(to, options) spring(to, Spring { … }) Federt zu to. stiffness (Standard 170), damping (Standard 26), mass (Standard 1), velocity (Anfangsgeschwindigkeit, Einheiten pro Sekunde).
decay(options) decay(velocity, deceleration), decay_within(velocity, deceleration, (min, max), bounce) Läuft mit velocity (Einheiten pro Sekunde) aus und wird um deceleration pro Millisekunde langsamer (Standard 0,998). Verwende es nach einem Fling, mit der Geschwindigkeit aus onPanEnd. Mit clamp: [min, max] stoppt es an einer Grenze oder prallt davon ab und behält dabei bounce (0..1, Standard 0) seiner Geschwindigkeit.
keyframes(frames, options) keyframes(frames, KeyframeTiming) Spielt Keyframes so ab, wie CSS-Keyframes abgespielt werden: ein Easing pro Segment, iterations Mal in direction.

Jede Animationsmethode gibt ein Promise zurück, das mit { finished } aufgelöst wird: true, wenn die Animation beendet wurde, false, wenn sie gestoppt wurde (durch stop, set oder eine andere Animation desselben Werts). In Rust gibt sie ein AnimationEnd zurück, das ein Task für dasselbe bool erwartet.

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

Ein Node des nativen Animationsgraphen: ein AnimatedValue oder ein aus anderen abgeleiteter Wert. Abgeleitete Nodes werden auf dem UI-Thread ausgewertet, sobald sich ihre Eingaben ändern.

Methode Rust Beschreibung
interpolate(config) interpolate(&input, &output), interpolate_colors(&input, &colors), interpolate_with(&input, output, Interpolation { … }) Bildet die Eingabe über stückweise Bereiche ab. outputRange enthält Zahlen, Farben ("#ff0000", "rgba(…)") oder Winkel ("45deg", "1rad"). Außerhalb von inputRange wird standardmäßig begrenzt; extrapolate (oder extrapolateLeft / extrapolateRight) kann "extend" (die Linie fortsetzen), "clamp" oder "identity" (die Eingabe durchreichen) sein. easing formt jedes Segment. Rust nimmt Zahlen, Theme-Farben (die der Darstellung folgen) und Winkel als Grad für bind_rotate.
diffClamp(min, max, options?) diff_clamp(min, max), diff_clamp_with(min, max, DiffClamp::snapping()) Folgt den Änderungen der Eingabe, hält das Ergebnis aber innerhalb von min..max: ein Header, der sich beim Hochscrollen des Inhalts ausblendet und sofort wiederkommt, sobald nach unten gescrollt wird. snap: true lässt ihn in 200 ms an der näheren Grenze einrasten, wenn seine Scroll-View zur Ruhe kommt, bei max erst, nachdem die Eingabe max erreicht hat.
add, sub, mul, div add, sub, mul, div Arithmetik mit einem anderen Node oder einer Konstante.
mod(b) modulo(b) Modulo, für ein positives b nie negativ (hält Winkel 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) spielt einen Wert durch Keyframes ab und wird mit { finished } aufgelöst, das angibt, ob sie abgeschlossen wurden; Animated.keyframes(value, { keyframes, ...options }) entspricht einem Schritt einer Komposition. Rust: value.keyframes(frames, KeyframeTiming) und Animation::keyframes(&value, frames, KeyframeTiming) mit Keyframe::new(value) (gleichmäßig verteilt), Keyframe::at(offset, value) und .ease(easing) sowie KeyframeTiming::new(ms) mit .delay(ms), .easing(e), .iterations(Some(n)), .infinite() und .direction(PlaybackDirection::…).

Sie werden abgespielt wie CSS-Keyframes: Ein Easing formt ein Segment (das eines Keyframes ist das Segment, das bei ihm beginnt, das des Timings jedes andere); in Keyframes fehlende Offsets werden gleichmäßig verteilt, der erste bei 0 und der letzte bei 1; jede Iteration beginnt mit einem Sprung zu ihrem ersten Keyframe, und ohne Keyframe bei 0 geht der Wert von seiner aktuellen Position aus; während eines delay hält der Wert den ersten Keyframe (CSS animation-fill-mode: backwards); rückwärts laufende Iterationen spielen die Segmente rückwärts mit umgekehrten Kurven ab; der Wert bleibt dort, wo die letzte Iteration ihn zurücklässt.

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

Erzeugt eine Motion: pro Kanal von Deckkraft und Transform einer View einen animierten Wert, im Ruhezustand (Deckkraft und Skalierungen 1, die übrigen 0), sowie Motions, die nativ darauf abgespielt werden. Binde sie mit style={motion.style} oder verteile motion.style oder motion.transform in einen Style; der Transform wendet zuerst seine Translationen an, dann seine Rotationen, dann seine Skalierungen, wobei scale in scaleX und scaleY eingerechnet wird: 7 der 8 Transform-Schritte einer View, sodass einer für deinen eigenen bleibt ([{ perspective: 600 }, ...motion.transform] für 3D-Flips). initial legt fest, wo die Kanäle beginnen ({ opacity: 0 }; die übrigen beginnen im Ruhezustand). Mit enter beginnen die Kanäle, die sie bewegt, dort, wo diese Motion beginnt (über initial), und sie wird sofort abgespielt, wobei die übrigen Optionen ihr Timing bilden (delay, duration, …). Eine Motion bewegt nur Deckkraft und Transform: Das Verteilen von motion.style setzt das opacity der View, binde daher nur motion.transform, um eine eigene Deckkraft zu behalten, und bewege Farben mit einem animierten Wert und interpolate. Rust: motion(), ein Copy-Handle, das die Komponente hält, die es erzeugt hat, gebunden mit .bind_motion(&m); motion().entering(spec) tritt ein.

Member Rust (Motion) Beschreibung
opacity, translateX, … scaleY value(MotionChannel::Opacity), … Die animierten Werte der Kanäle.
style, transform .bind_motion(&m) am Element Die Bindings.
play(motion, options?) play(spec), play_ignoring_reduce_motion(spec) Spielt ein Preset per Name oder eine MotionSpec auf den Kanälen ab; eine neue unterbricht die laufende. options ändern ihr Timing. Wird mit { finished } aufgelöst: true, wenn sie abgeschlossen wurde, false, wenn etwas sie gestoppt hat (in Rust AnimationEnd).
animation(motion, options?) animation(spec) Dieselbe Motion als CompositeAnimation, nicht gestartet, zum Komponieren: 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)))). Reduzierte Bewegung wird beim Erzeugen entschieden.
stop() stop() Hält sie an der aktuellen Stelle an.
reset() reset() Hält sie an und setzt jeden Kanal in den Ruhezustand zurück.

Wenn der Nutzer weniger Bewegung wünscht (reduceMotion()), landen play, animation und enter sofort dort, wo die Bewegung endet, ohne sich zu bewegen, es sei denn, ignoreReduceMotion ist gesetzt (an den Optionen oder an der 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";
}

Die vorgefertigten Motions als Daten: motion.play("shake") spielt motions.shake ab. MotionName ist eines von fadeIn, fadeInUp, fadeInDown, fadeInLeft, fadeInRight, zoomIn, popIn, bounceIn, slideInUp, slideInDown, flipInX, flipInY (Eintritt); fadeOut, fadeOutUp, fadeOutDown, fadeOutLeft, fadeOutRight, zoomOut, popOut, slideOutDown, slideOutUp (Austritt); shake, headShake, wiggle, swing, pulse, heartbeat, rubberBand, jello, tada, jump, flash (Aufmerksamkeit); spin, float, breathe, sway, bob, glow (Schleife). Animation beschreibt jede einzelne. Per Spread lässt sich ein Preset ändern: motion.play({ ...motions.fadeInUp, duration: 600 }).

Eine MotionSpec ist eine eigene Motion: die Keyframes jedes Kanals ({ opacity: [0, 1], translateY: [24, 0] }) oder eine Liste von Frames, wie die Web Animations API sie schreibt ([{ opacity: 0, translateY: 24 }, { opacity: 1, translateY: 0 }], wobei ein Frame ohne Offset anhand seiner Position in der Liste platziert wird), mit dem Keyframe-Timing. Rust: MotionSpec::new(KeyframeTiming) mit .values(channel, values) (gleichmäßig verteilt), .track(channel, keyframes) und .timing(|t| …); MotionPreset::FadeInUp (oder .spec()) ist ein Preset, und MotionChannel benennt die Kanäle.

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 baut Animationen aus Schritten auf (timing, spring, decay, sequence, parallel, …). Nichts läuft, bis start() aufgerufen wird; dann läuft der gesamte Baum nativ, und jeder Schritt startet im UI-Thread genau in dem Moment, in dem der vorherige endet (keine Frame-Lücke, kein Roundtrip zum Code der App zwischen den Schritten). Rust baut denselben Baum mit Animation.

Funktion Rust Beschreibung
timing, spring, decay Animation::timing(&value, to, Timing), Animation::spring(&value, to, Spring), Animation::decay(&value, velocity, deceleration) Eine Animation eines Werts; dieselben Optionen und Standardwerte wie bei den Methoden von AnimatedValue, wobei das Ziel als toValue übergeben wird.
sequence(list) Animation::sequence(list) Führt die Animationen nacheinander aus.
parallel(list, { stopTogether }) Animation::parallel(list).stop_together(…) Führt sie gleichzeitig aus. Mit stopTogether (Standard true) stoppt das Stoppen einer Animation alle.
stagger(ms, list, { from }) Animation::stagger(ms, list), Animation::stagger_from(ms, StaggerFrom::Center, list) Führt sie parallel aus, wobei jede ms nach der vorherigen startet, gezählt ab from: "first" (Standard), "last", "center" (die mittlere startet sofort, die übrigen breiten sich von ihr aus; bei gerader Anzahl starten beide mittleren sofort) oder ein Index (StaggerFrom::First, Last, Center, Index(i)).
keyframes(value, { keyframes, ...timing }) Animation::keyframes(&value, frames, KeyframeTiming) Ein Wert durch Keyframes.
delay(ms) Animation::delay(ms) Wartet; nützlich innerhalb von sequence.
loop(animation, { iterations, resetBeforeIteration }) animation.looped(iterations).reset_before_iteration(…) Wiederholt animation iterations Mal (standardmäßig endlos) und setzt vor jedem Durchlauf seine Werte zurück, es sei denn, resetBeforeIteration ist false.
add, subtract, multiply, divide, modulo, diffClamp die Node-Methoden Dasselbe wie die Methoden von AnimatedNode, als Funktionen.
sampleSpring, sampleDecay nsh_core::element::sample_spring, sample_decay Die Werte einer Spring- bzw. Decay-Animation, einer pro Frame (siehe Easing).

CompositeAnimation:

Methode Rust (Animation) Beschreibung
start(callback?) start(), ein AnimationEnd zum Abwarten Führt sie (erneut) aus. Wird einmal für den gesamten Baum aufgelöst und ruft callback auf, mit { finished }: ob sie abgeschlossen wurde, statt gestoppt zu werden.
stop() stop() Hält sie an der aktuellen Stelle an.
reset() reset() Hält sie an und setzt ihre Werte auf den Stand zurück, den sie beim Start hatten.
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;
};

Ein Easing sind Daten, keine Funktion der App: Die Runtime wertet es nativ aus. In Rust ist es das Enum Easing: Linear, EaseIn, EaseOut, EaseInOut, Bezier(x1, y1, x2, y2), Steps(count, StepPosition) und Curve(curve, mode), mit einem Curve (Quad, Cubic, Poly(n), Sin, Circle, Exp, Elastic(bounciness), Back(s), Bounce) und einem EaseMode (In, Out, InOut). Zum Zeichnen liefert Easing.sample(easing, count) (Rust: nsh_core::element::sample_easing) seine Werte an count gleichmäßig verteilten Punkten von 0 bis 1; Animated.sampleSpring und Animated.sampleDecay (sample_spring, sample_decay) liefern die Werte einer Spring- bzw. Decay-Animation, einen pro Frame (bei fps, standardmäßig 60), ab from (0) bis zu ihrem Ende. Alle drei stammen aus dem nativen Code, in dem die Animationen laufen. Überall, wo in TSX ein Easing akzeptiert wird, kannst du einen Namen ("easeOut"), eine kubische Bézier-Kurve als vier Zahlen oder eine Kurve aus dem Objekt Easing übergeben.

Kurve Rust Form
quad Curve::Quad t²
cubic Curve::Cubic t³
poly(n) Curve::Poly(n) tⁿ (n ist standardmäßig 2)
sin Curve::Sin Sinusförmig
circle Curve::Circle Kreisförmig
exp Curve::Exp Exponentiell
elastic(bounciness) Curve::Elastic(bounciness) Federndes Überschwingen (bounciness ist standardmäßig 1)
back(s) Curve::Back(s) Zieht sich zurück, bevor es losgeht; s ist das Überschwingen (Standard 1.70158)
bounce Curve::Bounce Springt am Ende nach

Easing.steps(count, position) entspricht CSS steps(): Der Wert springt zwischen count Stufen, statt sich zu bewegen (ein Sprite Frame für Frame, ein tickender Zeiger). position legt fest, wo die Sprünge liegen: "jump-end" (Standard, auch "end"), "jump-start" ("start"), "jump-none" oder "jump-both"; Rust: Easing::Steps(count, StepPosition::JumpEnd), JumpStart, JumpNone, JumpBoth.

Die Kurven liegen in ihrer „in“-Form vor (langsam am Anfang). Easing.out(curve) kehrt eine um, Easing.inOut(curve) macht sie symmetrisch, Easing.in(curve) belässt sie unverändert; in Rust ist das der EaseMode von 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) animiert die Layout-Änderungen des nächsten Commits nativ: Views, die sich bewegen oder ihre Größe ändern, gleiten zu ihren neuen Frames, neue Views erscheinen (blenden ein oder skalieren ein, je nach property) und entfernte Views verschwinden. Rufe es direkt vor der Zustandsänderung auf, die das Layout ändert. Rust: animate_layout(LayoutAnimation) mit den Presets von nsh_core::element::LayoutAnimation oder dessen Übergängen create, update und delete.

Member Rust Beschreibung
configureNext(config, onEnd?) animate_layout(config) Animiert die Änderungen des nächsten Commits; onEnd wird nach Abschluss ausgeführt.
create(duration, type?, property?) LayoutAnimation::all(LayoutTransition::new(curve, duration)) Eine Konfiguration, die dieselbe Kurve für alle drei Phasen verwendet.
Presets.easeInEaseOut LayoutAnimation::EASE_IN_EASE_OUT 300 ms, Views blenden ein und aus.
Presets.linear LayoutAnimation::LINEAR 500 ms mit konstanter Geschwindigkeit.
Presets.spring LayoutAnimation::SPRING 700 ms; Frames federn (Dämpfung 0.4), Views blenden linear ein und aus.
easeInEaseOut(onEnd?), linear(onEnd?), spring(onEnd?) animate_layout(LayoutAnimation::…) Kurzformen für configureNext(Presets.…).
Types, Properties LayoutCurve, LayoutProperty Die Namen als Konstanten.
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

Die Device-API informiert die App über das Gerät und seine Umgebung. Rust-Code ruft die Funktionen von nsh_core::prelude auf (window(), dark(), font_scale(), locale(), lifecycle(), os(), idiom(), pixel_ratio(), set_color_scheme(), …); TSX importiert sie aus nsh. Die Fenstergröße, das Farbschema, die Schriftskalierung, das Pixelverhältnis, die Locale und der App-Zustand sind reaktiv: Lies sie in einem Binding (in Rust eine Closure, die an ein Element übergeben wird, in TSX JSX), computed oder effect, und alles, was sie liest, wird aktualisiert, wenn die Plattform eine Änderung meldet (Drehung, Größenänderung des Fensters, Wechsel in den Dark Mode, Änderung der Textgröße oder der Sprache, Wechsel auf einen Bildschirm mit anderer Dichte, Verlassen der App und Rückkehr).

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

Den Leitfaden findest du unter Gerät und Umgebung.

#windowSize

const windowSize: Accessor<Size>;

Die Größe der Fläche in Punkten: das Fenster oder die eingebettete NshAppView / NshView. Reaktiv. 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">;

Das angezeigte Erscheinungsbild: das des Systems, sofern die App kein eigenes festgelegt hat (nsh.json "appearance", setColorScheme). Reaktiv. Rust: dark() -> bool; Theme-Farben (AdaptiveColor) und die Style-Variante .dark(…) folgen ihm, ohne es zu lesen. Auch die Variante dark: von Tailwind folgt ihm (siehe Tailwind).

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

#fontScale

const fontScale: Accessor<number>;

Die bevorzugte Textgröße des Nutzers relativ zum Standard (1 ist die Standardgröße; Dynamic Type unter iOS, Schriftgröße unter Android) oder stattdessen die Textgröße, die die einbettende App festlegt. Reaktiv. Rust: font_scale() -> f32. Text skaliert bereits damit, außer bei allowFontScaling={false}; lies sie, wenn andere Größen folgen sollen, etwa Icons neben Text.

#locale

const locale: Accessor<string>;

Die Locale des Nutzers als BCP-47-Tag ("en-US", "zh-Hans-CN") oder stattdessen die Locale, die die einbettende App festlegt. Reaktiv. Rust: locale() -> Locale (locale().as_str() ist der Tag). Intl formatiert standardmäßig dafür; lies sie, um die Strings deiner eigenen Übersetzungen auszuwählen (Gerät und Umgebung).

#lifecycle

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

Ob die App in Benutzung ist, in den Begriffen von React Native AppState: "active" im Vordergrund und Eingaben entgegennehmend, "inactive" solange sie sichtbar ist, aber nicht den Fokus hat (eine andere App oder ein anderes Fenster hat ihn, oder das System zeigt etwas darüber an, etwa den App-Umschalter oder einen eingehenden Anruf), "background" solange sie nicht sichtbar ist (im Hintergrund, minimiert, ein verborgener Browser-Tab). Eine App, die innerhalb einer nativen App angezeigt wird, folgt deren Zustand. Reaktiv. Rust: lifecycle() -> Lifecycle (Lifecycle::Active, Inactive, Background).

Lies es in einem Effect, um Arbeit zu pausieren, die niemand sieht, und sie fortzusetzen, wenn die App zurückkommt:

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

#setColorScheme

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

Zeigt die App sofort in scheme an, unabhängig vom Erscheinungsbild des Systems: colorScheme(), dark:-Klassen, das Theme der Komponenten und die Steuerelemente der Plattform (Alerts, der Standardstil der Statusleiste) folgen ihm; "system" folgt wieder dem System. nsh.json "appearance" legt es von Anfang an fest. 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;
}

Die Plattform, auf der die App läuft; sie bleibt konstant, solange die App läuft.

Member Rust Beschreibung
os os() -> Os (os().name() ist der TS-String) Das Betriebssystem. iOS, der iOS-Simulator und Mac Catalyst melden "ios", Browser "web".
version os_version() -> (u16, u16) iOS und Desktops: die OS-Version ("17.5"); Android: das API-Level ("35"); das Web: "0.0". Rust liefert die Haupt- und Nebenversion bzw. auf Android das API-Level und 0.
idiom idiom() -> Idiom Die Art des Geräts: "desktop" genau dort, wo desktop true ist; sonst "phone", "pad" (ein iPad, ein Android-Tablet, Mac Catalyst im iPad-Idiom), "mac" (Mac Catalyst im Mac-Idiom von UIKit, ein mobiles Idiom) oder "tv" (Apple TV, Android TV).
desktop is_desktop() -> bool Desktop-Verhalten greift: macOS, Windows, Linux und Browser mit Maus und breitem Fenster. desktop={{…}}-Props, select({ desktop }) und idiom "desktop" richten sich alle danach. Mac Catalyst nutzt das iOS-Backend und ist daher mobil: ios={{…}}-Props gelten dort. Das isAvailable von nsh/desktop ist die engere Prüfung für dessen Fenster- und Menü-APIs, die macOS, Windows und Linux bieten.
catalyst is_catalyst() -> bool Die App läuft unter Mac Catalyst, im Mac- oder im iPad-Idiom (os ist "ios").
select(spec) match os() { … } Der Wert für diese Plattform (siehe unten).
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;
}

Gibt den Wert für das aktuelle OS zurück (ios, android, web, …), sonst desktop oder mobile (je nach platform.desktop), sonst native (jede Plattform außer dem Web), sonst default, sonst undefined. Rust-Code matcht auf os() (und is_desktop() für 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>;

Geräte-Pixel pro Punkt (z. B. 3 auf den meisten iPhones). Reaktiv: Der Wert ändert sich, wenn das Fenster auf einen Bildschirm mit anderer Dichte wechselt (auf Mac Catalyst, den Desktops oder einem externen Display) und wenn die Einstellung für die Anzeigegröße von Android die Dichte ändert. Rust: pixel_ratio() -> f32 und round_to_nearest_pixel(points) für eine Länge in ganzen Geräte-Pixeln; in TSX ist diese Länge Math.round(x * pixelRatio()) / pixelRatio().

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

#openUrl

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

interface OpenUrlResult {
  opened: boolean;
}

Öffnet url außerhalb der App: eine Webseite im Browser, einen mailto:-Link in der Mail-App, einen tel:-Link in der Telefon-App und jedes Schema, das eine andere App registriert hat (maps:, die eigenen lb://-Links einer Host-App). Es wird mit { opened } aufgelöst, also damit, ob etwas es geöffnet hat: false, wenn keine App das Schema behandelt. Rust: open_url(url) von nsh_core::prelude, eine Antwort, die man in einem Task awaitet (oder verwirft), dasselbe bool.

button()
    .child(text("Contact us"))
    .on_press(|| {
        spawn(async {
            if !open_url("mailto:help@example.com").await {
                log::warn!("no mail app");
            }
        });
    })
  • Die URL ist absolut (sie hat ein Schema). URLs, die Code ausführen oder auf die eigenen Daten der App zugreifen würden, javascript:, data:, blob:, file:, about: und asset:, lehnen das Promise mit einem TypeError ab, ebenso relative Adressen. openUrl wirft nie: Jeder Fehler lehnt ab.
  • Eine App, die Capabilities deklariert, braucht openUrl: "openUrl": true für jedes Schema oder "openUrl": { "schemes": ["https", "mailto"] } für die aufgeführten; andere URLs lehnen mit einem Error ab, dessen code "ERR_ACCESS_DENIED" ist, permission "openUrl" und resource die URL (siehe Sandbox und Capabilities). In Rust antwortet open_url dafür mit false und protokolliert den Grund.
  • Die einbettende App sieht die URL zuerst und kann sie stattdessen über ihren eigenen Router leiten (Swift NshAppView.onOpenURL, Java NshView.setOpenUrlHandler, Rust AppView::host().handle_open_url; siehe iOS und Android). opened ist dann true.
  • Jede Plattform öffnet sie mit ihrem eigenen Opener: UIApplication.open auf iOS (die Application der Host-App, wenn eingebettet), ein ACTION_VIEW-Intent auf Android, NSWorkspace auf macOS, ShellExecuteW auf Windows, GtkUriLauncher auf Linux, open_url von GPUI, ein neuer Tab im Web. nsh test zeichnet die URLs auf, statt sie zu öffnen.
  • Ein TextView öffnet seine Links auf diese Weise mit links="open".

#Layout-Richtung

Die Layout-Richtung folgt der Sprache des Benutzers: In einer Rechts-nach-links-Sprache (Arabisch, Hebräisch, …) wird die App von rechts nach links angeordnet, Zeilen beginnen rechts, und die Style-Eigenschaften left / right (marginLeft, paddingRight, …) bedeuten Anfang und Ende. Änderungen wirken sofort, ohne Neustart.

Der Style direction einer View ("ltr", "rtl" oder "inherit", der Standard) legt die Richtung ihres eigenen Teilbaums fest; in Rust .direction(LayoutDirection::..).

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

#Siehe auch

  • Safe-Area-Insets (Rust safe_area(); TSX safeArea(), safeAreaInsets()) und die Software-Tastatur (Rust keyboard_height(); TSX keyboardFrame()) stehen unter Safe Area und Tastatur.
  • Einstellungen für Screenreader und reduzierte Bewegung stehen unter Barrierefreiheit.

#PanResponder

Pan Responder sind Drag-Gesten auf dem Gesture-Responder-System: Das Element beansprucht die Touches einer Geste und hört sie dann allein, zusammen mit dem Gesture-State, der den Schwerpunkt der Touches, die zurückgelegte Distanz und die Geschwindigkeit automatisch nachverfolgt. Rust-Code beansprucht sie mit .claim_touches(TouchClaim::…) und empfängt sie mit .on_responder_*-Listenern, deren ResponderEvent einen PanGesture trägt, alles in nsh_core::prelude; TSX importiert PanResponder aus nsh, dessen onPanResponder…-Callbacks das Touch-Event und den Gesture-State erhalten.

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

#PanResponder.create

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

interface PanResponderInstance {
  panHandlers: TouchProps;
}

Erstellt einen Pan Responder. Sein panHandlers wird per Spread auf die View gesetzt, die die Geste erhalten soll. In Rust beansprucht die View die Touches und hört selbst zu:

use nsh_core::prelude::*;

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

#Callbacks

Jeder Callback erhält das Touch-Event (GestureResponderEvent, aus nsh) und den Gesture-State. In Rust entscheidet ein Claim mit .ask(|request: &ClaimRequest| …) und ruft request.claim() auf, um die Touches zu übernehmen (ohne ask übernimmt er sie immer), und jeder Listener erhält einen ResponderEvent, dessen gesture der State ist.

type PanHandler<R = void> = (e: GestureResponderEvent, gestureState: PanResponderGestureState) => R;
Callback Rust Rückgabe Aufgerufen, wenn
onStartShouldSetPanResponder .claim_touches(TouchClaim::on_start()) boolean Ein Finger setzt auf: true zurückgeben, um Responder zu werden. Wird von der berührten View nach außen abgefragt.
onStartShouldSetPanResponderCapture TouchClaim::on_start().capture() boolean Dasselbe, aber von außen nach innen und vor der Bubbling-Variante abgefragt (ein Parent übernimmt die Geste von seinen Children).
onMoveShouldSetPanResponder TouchClaim::on_move().ask(…) boolean Ein Finger bewegt sich: true zurückgeben, um Responder zu werden (z. B. erst bei Math.abs(g.dx) > 10).
onMoveShouldSetPanResponderCapture TouchClaim::on_move().capture() boolean Dasselbe, Capture-Phase.
onPanResponderGrant .on_responder_grant(…) Diese View ist jetzt der Responder: Die Geste beginnt.
onPanResponderReject .on_responder_reject(…) Eine andere View ist der Responder und gibt ihn nicht ab.
onPanResponderStart .on_responder_start(…) Ein weiterer Finger setzt auf, während die View Responder ist.
onPanResponderMove .on_responder_move(…) Die Finger haben sich bewegt.
onPanResponderEnd .on_responder_end(…) Ein Finger wurde angehoben, während die View Responder ist.
onPanResponderRelease .on_responder_release(…) Der letzte Finger wurde angehoben: Die Geste ist beendet.
onPanResponderTerminate .on_responder_terminate(…) Eine andere View (oder eine native Geste, etwa eine Scroll View) hat die Touches übernommen.
onPanResponderTerminationRequest .keep_touches(true) behält sie boolean Eine andere View will die Touches: false zurückgeben, um sie zu behalten (Standard true).
onShouldBlockNativeResponder TouchClaim::block_native(…) boolean Ob native Scroll Views daran gehindert werden, die Touches zu übernehmen, solange die View Responder ist (Standard 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, mit Punkten als Point<Points>.

Feld Rust Beschreibung
stateID Identifiziert die Geste.
moveX, moveY position Letzte Position des Schwerpunkts der Touches, in Punkten der Oberfläche.
x0, y0 origin Wo der Schwerpunkt war, als der Responder gewährt wurde.
dx, dy delta Zurückgelegte Distanz seit der Gewährung.
vx, vy vx, vy Geschwindigkeit in Punkten pro Sekunde.
numberActiveTouches active_touches Aufgesetzte Finger.

#In Scroll Views

Solange ein Pan Responder der Responder ist, scrollen native Scroll Views um ihn herum nicht (onShouldBlockNativeResponder ist standardmäßig true). Damit ein horizontaler Swipe mit vertikalem Scrollen koexistieren kann, wird die View nur bei horizontalen Bewegungen zum Responder:

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

Bei einem Pan, den die Plattform erkennt, entscheidet TouchClaim::on_pan(PanActivation::horizontal(10.0, 10.0)) im Main-Thread, während sich die Finger bewegen. Die zugrunde liegenden Responder-Props (onStartShouldSetResponder, onResponderGrant, onTouchStart, …) lassen sich auch direkt auf jedem Element verwenden; siehe Elemente.

#Clipboard

Die Clipboard-API liest und schreibt die System-Zwischenablage: Text überall (UIPasteboard auf iOS, ClipboardManager auf Android, NSPasteboard, GDK und die Win32-Zwischenablage auf Desktops, navigator.clipboard im Web), Bilder auf iOS, den Desktops und im Web sowie die in einem Dateimanager kopierten Dateien auf den Desktops und Mac Catalyst. Rust-Code ruft die Funktionen von nsh_core::prelude auf (set_clipboard, clipboard_string, …); TSX importiert Clipboard aus nsh.

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

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

Die Rust-Lesefunktionen liefern Antworten, die man in einem Task awaitet (spawn oder eine Modulfunktion von async); ClipboardImage ist nsh_core::element::ClipboardImage, mit url und size (ein Size<DevicePixels>).

Bilder laufen auf iOS, den Desktops und im Web über die Zwischenablage. Auf Android enthält die Zwischenablage Text: writeImage und readImage lehnen mit einem Error ab, dessen code "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM" ist, und hasImage wird mit false aufgelöst. In Rust lässt set_clipboard_image dort die Zwischenablage unverändert, und clipboard_image antwortet None.

Der Benutzer kann auch selbst aus einem auswählbaren Text kopieren; die Clipboard-API dient dem Kopieren aus Code, etwa für einen Kopieren-Button.

#Clipboard.writeText

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

Legt text in die Zwischenablage und ersetzt deren bisherigen Inhalt. 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>;

Wird mit dem Text der Zwischenablage aufgelöst, oder mit "", wenn sie keinen enthält. Rust: clipboard_string(). Auf iOS kann das Lesen der Zwischenablage den System-Hinweis zum Einfügen anzeigen („App pasted from …“), und auf Android 10 und höher kann nur die App im Vordergrund sie lesen. Sie sollte gelesen werden, wenn der Benutzer das Einfügen anfordert, nicht auf jedem Screen.

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

#Clipboard.hasText

hasText(): Promise<boolean>;

Wird damit aufgelöst, ob die Zwischenablage Text enthält, ohne ihn zu lesen: kein Einfüge-Hinweis auf iOS. Rust: clipboard_has_string(). Damit lässt sich entscheiden, ob ein Einfügen-Button angezeigt wird. Im Web erfahren Browser es nur durch Lesen, daher kann dabei der Benutzer um Erlaubnis gefragt werden.

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

#Clipboard.readImage

readImage(): Promise<ClipboardImage | null>;

Wird mit dem Bild der Zwischenablage aufgelöst, oder mit null, wenn sie keines enthält: ein eingefügter Screenshot oder ein in einem Browser oder Bildeditor kopiertes Bild. Rust: clipboard_image(), None, wenn sie keines enthält. url ist eine URL des Bildes (PNG oder das eigene JPEG, GIF, WebP oder BMP der Zwischenablage), das ein Image als sein src anzeigt und fetch liest: fetch(url) liefert seine Bytes, für einen Upload. size (width und height) ist seine Größe in Pixeln, wie dekodiert. Wie bei readText sollte es gelesen werden, wenn der Benutzer das Einfügen anfordert. Bilder bis 48 MB (kodiert) werden gelesen, damit das Bild in den Speicher der App passt; ein größeres lehnt mit einem Error ab, dessen code "ERR_NSH_QUOTA_EXCEEDED" ist (die Meldung nennt die Größe des Bildes und das Limit), während hasImage mit true aufgelöst wird. Rust: clipboard_image() antwortet dafür None.

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

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

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

Eine Chat-App, in der Benutzer Screenshots in ein Eingabefeld einfügen, bindet den Einfügen-Shortcut, solange ihr Input den Fokus hat, und übernimmt das Bild, falls vorhanden:

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

Wird damit aufgelöst, ob die Zwischenablage ein Bild enthält, ohne es zu lesen: kein Einfüge-Hinweis auf iOS. Rust: clipboard_has_image(). Auf Android wird mit false aufgelöst.

#Clipboard.writeImage

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

Legt das Bild von src, einer base64-data:-URL oder der url, die readImage lieferte, in die Zwischenablage und ersetzt deren Inhalt. Rust: set_clipboard_image(src). Auf Windows nimmt es ein PNG, das Format, das Browser, Office und das Snipping Tool lesen. Auf Android lehnt es mit "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM" ab.

set_clipboard_image(image.url.clone());

#Clipboard.readFiles

readFiles(): Promise<string[]>;

Wird mit den file:-URLs der in einem Dateimanager (Finder, Explorer, Files) kopierten Dateien und Ordner aufgelöst, in ihrer Reihenfolge, oder mit []: dieselben URLs, die ein Datei-Drop liefert. Rust: clipboard_files(). Eine URL lässt sich parsen, um ihren Pfad zu erhalten.

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

#Picker

Die Picker-API zeigt den Foto-Picker des Systems an und gibt der App die Bilder, die der Nutzer auswählt: den Photos-Picker unter iOS (PHPickerViewController), den Photo Picker unter Android, einen Öffnen-Dialog bzw. Dateidialog auf den Desktop-Plattformen und ein File-Input im Browser. Unter iOS und Android läuft der Picker außerhalb der App und übergibt nur das, was der Nutzer auswählt. Deshalb braucht er keine Berechtigung für die Fotomediathek. Jedes ausgewählte Bild kommt als File (TSX) bzw. PickedFile (Rust) an, dessen Bytes nativ bleiben: Zeige es mit URL.createObjectURL(file) in einem Image an, lade es mit fetch und FormData hoch. Rust-Code ruft pick_images von nsh_core::prelude auf; TSX importiert Picker aus nsh.

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

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

interface PickedImage {
  file: File; // name, type, size in bytes and the bytes
  size: PixelSize; // upright
}
TS Rust
await Picker.pickImages(options) pick_images(request).await -> Vec<PickedFile>
multiple, limit PickRequest::new(multiple, limit) (limit: 0 für bis zu 100)
maxDimension request.max_dimension = Some(DevicePixels(2048))
quality request.quality = Some(Percentage(0.8))
format request.format = PickFormat::Current
image.file.name, .type, .size file.name, file.mime, file.data.len()
image.size file.size (Size<DevicePixels>)

#Picker.pickImages

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

Zeigt den Picker an und löst mit den Bildern auf, die der Nutzer ausgewählt hat, in der Reihenfolge der Auswahl, oder mit [], wenn der Nutzer abbricht. Rust: pick_images(request), eine Antwort, auf die in einem Task gewartet wird (spawn oder eine Modulfunktion von async). pickImages wirft nie: Optionen außerhalb des gültigen Bereichs (limit: 0, ein quality außerhalb von 0 bis 1) lehnen das Promise mit einem RangeError ab, und eine Auswahl, die die Capabilities der App nicht erlauben, lehnt mit ERR_ACCESS_DENIED ab (siehe unten). Ein Abbruch ist die Antwort des Nutzers, kein Fehler: Das Promise wird dann mit [] aufgelöst.

Die Bilder kommen versandfertig an:

  • Formate, die jeder Browser und Server akzeptiert. JPEG, PNG, GIF und WebP kommen unverändert an; ein Foto, das in einem anderen Format gespeichert ist (HEIC auf iPhones, HEIF, TIFF), kommt als JPEG an. format: "current" behält das gespeicherte Format bei.
  • Nativ verkleinert. Mit maxDimension wird ein Bild, dessen längere Seite größer ist, aufrecht auf diesen Wert verkleinert und neu kodiert: ein PNG als PNG, alles andere als JPEG mit quality. Handyfotos haben 12 bis 48 Megapixel; wenn man sie vor einem Upload auf 2048 Pixel verkleinert, werden daraus wenige hundert Kilobyte. Auch ein quality kodiert JPEGs neu.
  • Was angezeigt wird. size ist die Größe des Bildes in Pixeln, wie es angezeigt wird, mit angewendeter Kameraausrichtung. Ein Bild, das NSH neu kodiert, enthält keine Metadaten (keinen Standort); ein Bild, das unverändert ankommt, behält seine eigenen.

Unter Windows, Linux und in GPUI-Fenstern bietet der Dialog die Formate an, die die Anfrage akzeptiert (mit "compatible": JPEG, PNG, GIF und WebP), und die Dateien kommen so an, wie sie gespeichert sind, in ihrer eigenen Größe.

Ein Chat-Composer hängt ein Foto an, zeigt es an, während die Nachricht geschrieben wird, und lädt es hoch, wenn sie gesendet wird:

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
}

Mehrere Fotos auf einmal:

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

#Einbettende Apps

Eine App, die NSH einbettet und einen eigenen Picker hat (zum Beispiel einen mit den eigenen Alben der App), beantwortet die Auswahlen selbst: In Rust erhält ein PickerBackend (nsh_core::picker), das der Engine übergeben wird (EngineConfig::picker, oder AppView::set_picker in GPUI- und GPUI-Kit-Apps), das PickRequest jeder Auswahl und gibt die Dateien zurück; der Picker des Systems wird dann nicht angezeigt. Die zurückgegebenen Dateien werden wie die der Plattform vervollständigt: auf das Limit der Anfrage gekürzt, ihre Größen, Typen und Erweiterungen aus ihren Bytes gelesen.

#Tests

nsh test beantwortet Auswahlen in Headless-Läufen: app.onPick(handler) gibt die Dateien zurück, die der Nutzer auswählt ({ name, type, data }), und ohne Handler wird eine Auswahl abgebrochen. Siehe Testing.

#Speech

Die Speech-API wandelt das, was der Nutzer sagt, in Text um (Diktat): Die App startet eine Sitzung, die Plattform hört das Mikrofon ab, und das Transkript kommt an, während es wächst. macOS und iOS erkennen mit dem Speech-Framework des Systems; das System fragt den Nutzer einmalig nach dem Mikrofon und nach der Spracherkennung. Auf anderen Plattformen endet eine Sitzung sofort mit dem Fehler "unavailable". Prüfe daher Speech.available(), bevor du eine Mikrofon-Schaltfläche anzeigst. Rust-Code ruft recognize_speech von nsh_core::prelude auf; TSX importiert Speech aus nsh.

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

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

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

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

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

#Speech.start

Startet eine Sitzung und gibt sie sofort zurück. Eine Sitzung meldet in dieser Reihenfolge: start, wenn das Mikrofon an ist und der Recognizer zuhört, result jedes Mal, wenn sich das Transkript ändert (text ist immer das gesamte bisherige Transkript, die App zeigt es also unverändert an), und genau ein end, danach kommt nichts mehr. stop() hört auf zuzuhören und lässt den Recognizer verarbeiten, was er gehört hat: ein letztes Ergebnis mit isFinal: true, dann end. abort() beendet die Sitzung sofort und ohne Fehler und verwirft, was nicht gemeldet wurde. Es läuft jeweils nur eine Sitzung: Wer eine weitere startet, bricht die laufende ab.

end enthält das letzte Transkript und, wenn die Sitzung fehlgeschlagen ist, error, benannt wie in der Web Speech API:

error Wann
"not-allowed" Der Nutzer oder das System erlaubt das Mikrofon oder die Spracherkennung nicht.
"no-speech" Es wurde nichts gesagt.
"audio-capture" Das Mikrofon konnte nicht verwendet werden.
"network" Der Recognizer braucht das Netzwerk und konnte es nicht erreichen.
"language-not-supported" Der Recognizer kennt lang nicht.
"unavailable" Diese Plattform erkennt keine Sprache, oder gerade nicht.
"failed" Der Recognizer ist aus einem anderen Grund fehlgeschlagen.

start wirft einen TypeError bei Optionen mit falschen Typen und einen RangeError bei einem lang, das kein Sprach-Tag ist.

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

Löst mit true auf, wo eine Sitzung laufen kann: unter macOS und iOS sowie überall dort, wo die einbettende App Sprache selbst erkennt.

#Info.plist

Unter macOS und iOS zeigt das System an, warum die App das Mikrofon und die Spracherkennung möchte. nsh package und nsh run schreiben NSMicrophoneUsageDescription und NSSpeechRecognitionUsageDescription in die Info.plist der App, wenn die App Sprache verwenden darf (ihre capabilities-Freigabe speech, oder sie deklariert keine).

#Einbettende Apps

Eine App, die NSH einbettet und Sprache auf eigene Weise erkennt (mit einer eigenen Engine oder einem eigenen Dienst), beantwortet die Sitzungen selbst: In Rust erhält ein SpeechBackend (nsh_core::speech), das der Engine übergeben wird (EngineConfig::speech), das SpeechRequest jeder Sitzung und einen SpeechSink, an den es aus jedem Thread berichtet, und gibt das SpeechControl zurück, mit dem der Core die Sitzung stoppt und abbricht. Der Recognizer der Plattform wird dann nicht verwendet.

#Tests

nsh test führt Sitzungen in Headless-Läufen auf einem Fake-Recognizer aus: Eine Sitzung startet sofort, app.hear(text) meldet text als bisheriges Transkript, app.endSpeech(error?) beendet sie, und app.speechRequests() listet die Sitzungen auf, die die App gestartet hat. Siehe Testing.

#Storage

Die Storage-API hält die Daten der App auf dem Gerät: Strings unter einem String-Schlüssel, die beim nächsten Start der App noch da sind. Verwende sie für Einstellungen, Entwürfe, den zuletzt geöffneten Tab des Nutzers, einen Cache dessen, was der Server gesendet hat – jeden Zustand, der den Prozess der App überdauern soll. Rust-Code verwendet nsh_core::storage::Storage, TSX importiert Storage aus nsh; beide lesen und schreiben dieselben Einträge, sodass eine App mit einem Rust-Einstiegspunkt und einem TSX-Einstiegspunkt einen gemeinsamen Datenbestand sieht.

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

Die Rust-Funktionen sind synchron, da die Einträge im Speicher liegen: Rufe sie dort auf, wo Komponenten laufen (in ihren Rümpfen, Handlern und Tasks).

Jede App hat einen eigenen Storage mit bis zu 5 MB an Schlüsseln und Werten. Aufrufe laufen in der Reihenfolge, in der die App sie macht, sodass ein getItem nach einem setItem den neuen Wert sieht, ohne auf das Schreiben zu warten. Lesezugriffe kommen aus dem Speicher; Schreibzugriffe erreichen den Datenträger im Hintergrund, jeweils einige auf einmal.

Plattform Wo die Daten liegen
iOS Library/Application Support/nsh/<app id>/storage.json im Container der App
Android files/nsh/<app id>/storage.json im privaten Verzeichnis der App
macOS ~/Library/Application Support/<app id>/storage.json
Windows %APPDATA%\<app id>\storage.json
Linux $XDG_DATA_HOME/<app id>/storage.json (~/.local/share/…)
Web Der localStorage des Browsers, unter Schlüsseln, die nach der App-ID benannt sind (nsh:com.example.todo:…)

Eine beschädigte storage.json wird daneben als storage.json.corrupt aufbewahrt, und die App startet mit einem leeren Storage; eine Datei, die nicht gelesen werden kann, lässt die Änderungen der App im Speicher, sodass die Datei nie ersetzt wird.

Die App-ID ist die id in nsh.json und reist mit der App: nsh build und nsh package schreiben sie neben das Bundle, der Dev-Server sendet sie, und ein signiertes Paket trägt die ID, für die das Zertifikat seines Release-Schlüssels bürgt. So behält jede App ihre eigenen Daten ohne Einstellung, wo auch immer sie läuft; eine App ohne ID behält sie unter default. Eine einbettende App kann einen anderen Ort wählen (siehe Einbetten unter iOS und Einbetten unter Android).

#Storage.getItem

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

Löst mit dem Wert von key auf, oder mit null, wenn er keinen hat. Rust: Storage::get_item(key), None, wenn er keinen hat.

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

#Storage.setItem

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

Setzt key auf value und ersetzt, was er enthielt. Lehnt mit einem Error ab, dessen code "ERR_NSH_QUOTA_EXCEEDED" ist, wenn der Storage über 5 MB hinauswachsen würde; der Wert wird dann nicht behalten. Rust: Storage::set_item(key, value), das dann Err(StorageFull) zurückgibt. Werte sind Strings: Halte strukturierte Daten als JSON (serde_json::to_string in Rust, JSON.stringify in TSX) und lies sie wieder ein (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>;

Entfernt key und seinen Wert. Das Entfernen eines Schlüssels ohne Wert bewirkt nichts. Rust: Storage::remove_item(key).

#Storage.keys

keys(): Promise<string[]>;

Löst mit allen Schlüsseln auf, in der Reihenfolge. Rust: Storage::get_all_keys().

#Storage.clear

clear(): Promise<void>;

Entfernt jeden Schlüssel der App, zum Beispiel wenn sich der Nutzer abmeldet. Rust: Storage::clear().

#Ein Signal auf dem Gerät behalten

Lies den gespeicherten Wert beim Start der App und schreibe ihn zurück, sobald sich das Signal ändert:

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 erreicht die App, die NSH einbettet: die native App um ein NshAppView / NshView herum, oder im Web die Seite, die den Player ausführt. Die App ruft die Funktionen des Hosts per Name mit JSON-Argumenten auf und hört auf die Ereignisse, die der Host sendet.

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;

Gibt an, ob die Plattform einen Host hat, der Aufrufe beantwortet. Im Web ist es true, wenn die Seite dem Player einen Host gibt (Der Host im 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>;

Ruft die Funktion name des Hosts mit args auf (ein beliebiger JSON-Wert; null ohne einen) und löst mit dem auf, was sie zurückgibt. host.call wirft nie: Jeder Fehler lehnt das Promise ab, und zwar mit einem Error, dessen code angibt, was passiert ist (die Meldung ist für Menschen gedacht und kann sich ändern):

code Wann
ERR_NSH_HOST_ERROR Die Funktion des Hosts ist fehlgeschlagen, oder der Host hat keine Funktion name; die Meldung stammt vom Host
ERR_ACCESS_DENIED name steht nicht in der host-Capability der App; permission ist "host", resource der Name
ERR_FEATURE_UNAVAILABLE_ON_PLATFORM Die Plattform hat keinen Host: host.available() ist false
ETIMEDOUT Der Host hat nicht rechtzeitig geantwortet
ERR_NSH_QUOTA_EXCEEDED 1024 Aufrufe warten bereits auf die Antworten der einbettenden App

Ein Name, der kein gültiger Name ist (1 bis 128 aus A–Z, a–z, 0–9, _, ., :, -, ohne ..), lehnt mit einem TypeError ab.

Ein Aufruf wartet 30 Sekunden auf die Antwort des Hosts und lehnt dann mit ETIMEDOUT ab; eine Antwort, die später kommt, wird verworfen. options.timeout legt die Wartezeit in Millisekunden fest, 0 für kein Limit; ein Timeout, der so groß ist, dass die Uhr ihn nie erreicht (etwa 1e22 ms), wartet ebenfalls ohne Limit, und ein negativer, NaN- oder unendlicher Wert lehnt den Aufruf mit einem RangeError ab. Die Zeit ist die der App, wie bei ihren Timern: In nsh test mit einer manuellen Uhr bewegt sie sich mit der Uhr.

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;

Plattformereignisse rufen listener mit der Nutzlast jedes event auf, das der Host sendet, bis die Funktion aufgerufen wird, die host.on zurückgibt; ein Listener, der während des Renderns einer Komponente hinzugefügt wird, endet mit der Komponente. Ereignisse, die der Host sendet, bevor die App startet, kommen an, sobald sie startet, sodass Listener, die auf der obersten Ebene der App registriert werden, sie erhalten; ein Ereignis, das vor der Registrierung eines Listeners gesendet wurde, wird ihm nicht erneut zugestellt. Lies den Zustand, mit dem die App startet, über host.call und verfolge seine Änderungen mit host.on.

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

#Capability

Jede Funktion, die die App aufruft, muss im capabilities.host von nsh.json aufgeführt sein: genaue Namen oder Präfixe, die auf * enden, oder true für alle. Das gilt für jede App, auch für eine ohne weitere capabilities: Die Funktionen des Hosts werden nie standardmäßig freigegeben. Ein nicht freigegebener Aufruf wird mit ERR_ACCESS_DENIED abgelehnt und protokolliert, ein host.post eines solchen Namens an eine einbettende App löst denselben Fehler aus, und das Bauen einer App, die host aus nsh importiert, ohne capabilities.host gibt eine Warnung aus. Events benötigen keine Freigabe. Siehe Sandbox und Capabilities.

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

#Der Host im Web

Im Web ist die Seite der Host. Die Option host von createPlayer beantwortet die Aufrufe, und player.hostEvent(event, payload) sendet Events:

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

Eine veröffentlichte App benennt ihren Host in nsh.json: web.host ist ein JS-Modul im Verzeichnis der App, relativ zu nsh.json, das createHost(player) exportiert; dieses gibt das Objekt mit call zurück. nsh package --target web liefert es mit der Site aus, nsh run --target web führt es auf der Entwicklungsseite aus, und nsh test führt es für die Apps aus, die seine Tests öffnen. web.connect listet die Hosts auf, die die eigenen Anfragen des Hosts erreichen und die die Content-Security-Policy der Seite neben dem capabilities.network der App erlaubt. Siehe Web.

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

#host.params

Die Parameter, die die einbettende App gesetzt hat, bevor sie Ihre App gestartet hat, synchron lesbar ab der ersten Zeile Ihres Codes; {}, wenn sie keine gesetzt hat. Sie bleiben gleich, solange Ihre App läuft: Spätere Anfragen kommen als Events.

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

#Native-SDK-Kanal

Der send des nativen SDK behält Events, bis die App zuhört, bis zu 256. Stoppt der letzte Listener während der Zustellung, warten die verbleibenden Events der Reihe nach weiter; Events, die während des Herunterfahrens gesendet werden, warten auf die nächste geladene App. host.post(name, data) übergibt Daten an den Handler des nativen SDK, dessen isPost Posts von Aufrufen unterscheidet.

#Die Seite der einbettenden App

Jede View führt einen Kanal für jede App, die sie lädt. Setzen Sie Parameter vor dem Laden, senden Sie Events jederzeit von jedem Thread aus und registrieren Sie Handler nach Namen. Aufrufe und Posts erfordern die capabilities.host-Freigabe der App. Handler antworten einmal, sofort oder später; eine Antwort, die nie kommt, lässt den Aufruf fehlschlagen.

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

Siehe Einbettung unter iOS, Einbettung unter Android und GPUI.

#keymap

Die Keymap bindet die Tastenkürzel der App: einen Tastenanschlag wie "mod+k" und die Funktion, die er ausführt. Rust-Code ruft on_key, on_key_with und key_label aus nsh_core::prelude auf; TSX importiert keymap aus nsh. Beide akzeptieren dieselben Tastenanschläge und folgen denselben Regeln.

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

interface KeyBindingOptions {
  when?: "always" | "notEditing";
  repeat?: boolean;
}
TS Rust
keymap.bind(keystroke, handler) on_key(keystroke, handler)
keymap.bind(keystroke, handler, { when, repeat }) on_key_with(keystroke, KeyOptions { when, repeat }, handler); when ist nsh_core::keymap::When (Auto, Always, NotEditing)
keymap.bind({ … }) ein on_key pro Tastenanschlag
keymap.label(keystroke) key_label(keystroke)

Eine Rust-Bindung lebt so lange wie die Komponente (der Owner), die sie erstellt hat; der Handler erhält den Tastenanschlag so, wie er geschrieben wurde.

Tastenkürzel funktionieren unter macOS, Windows, Linux und in Browsern (die browsereigenen Tasten wie Ctrl+T oder Cmd+W erreichen die Seite nie) sowie unter iOS und Android mit einer Hardware-Tastatur: der eines iPad, der eines Macs, der eine iPad-App ausführt, der eines Tablets oder Chromebooks, der des Simulators und des Emulators. Derselbe Code läuft überall; ohne Tastatur löst keine Taste die Bindungen aus.

#keymap.bind

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

Führt handler aus, wenn keystroke gedrückt wird, und gibt eine Funktion zurück, die die Bindung entfernt. Der Handler erhält { keystroke }: den Tastenanschlag so, wie Sie ihn geschrieben haben, in dessen Schreibweise. Rust: on_key(keystroke, handler), dessen Handler den Tastenanschlag selbst erhält.

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

Übergeben Sie ein Objekt, um mehrere Tastenanschläge auf einmal zu binden; die zurückgegebene Funktion entfernt sie alle. In Rust ist jeder Tastenanschlag ein eigenes on_key:

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

Eine Bindung, die erstellt wird, während eine Komponente rendert, wird mit der Komponente entfernt, sodass die Tastenkürzel eines Screens so lange leben wie der Screen:

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

Beim Dev-Server entfernt das Bearbeiten eines Moduls die Bindungen, die sein Top-Level-Code erstellt hat, bevor er erneut läuft, sodass Hot Reload nie veraltete Tastenkürzel zurücklässt.

#keymap.label

label(keystroke: string): string;

Gibt keystroke so zurück, wie Menüs und Hinweise es dort anzeigen, wo die App läuft: "⇧⌘L" unter macOS und iOS (und in einem Browser auf einem Mac, iPhone oder iPad), "Ctrl+Shift+L" unter Windows, Linux und in anderen Browsern, für "mod+shift+l". Rust: key_label(keystroke). Verwenden Sie es, um die Tastenkürzel einer App mit den Tasten anzuzeigen, die tatsächlich funktionieren:

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

Bei einem ungültigen Tastenanschlag löst es einen Fehler (TypeError) aus, ebenso wie bind; key_label und on_key lösen eine Panic mit derselben Meldung aus.

#Tastenanschläge

Ein Tastenanschlag besteht aus Modifikatoren und einer Taste, verbunden durch +: "mod+k", "ctrl+shift+p", "alt+enter", "escape", "f5", "cmd+-" (Command und Minus).

Modifikator macOS Windows Linux
mod (auch cmdorctrl) Command (⌘) Ctrl Ctrl
cmd (auch command, meta, super, win) Command (⌘) Windows-Taste Super
ctrl (auch control) Control (⌃) Ctrl Ctrl
alt (auch option, opt) Option (⌥) Alt Alt
shift Shift (⇧) Shift Shift

iOS folgt macOS (mod ist Command) und Android folgt Windows und Linux (mod ist Ctrl, cmd die Meta-Taste). In einem Browser richten sich die Tasten nach der Plattform, auf der der Browser läuft: mod ist Command auf einem Mac, iPhone oder iPad und anderswo Ctrl.

Verwenden Sie mod für die eigenen Tastenkürzel einer App: Es ist die Taste, die die Apps jeder Plattform dafür nutzen (⌘K unter macOS, Ctrl+K unter Windows und Linux), sodass eine Schreibweise überall passt. Die Windows-Taste und Super gehören meist dem System, daher ist cmd außerhalb von macOS selten richtig.

Die Taste ist eine Buchstaben-, Ziffern- oder Satzzeichentaste, geschrieben als das Zeichen, das sie auf einer US-Tastatur ohne Shift erzeugt (k, 1, ,, /, [; "shift+/" für ?, "shift+k" für Shift+K), oder ein Name: plus (die Taste = / +, auch = geschrieben), minus (auch -), space, enter (return), escape (esc), tab, backspace, delete (del), insert, die Pfeiltasten up, down, left, right (arrowup, …), home, end, pageup, pagedown, f1 bis f24. Groß- und Kleinschreibung spielt keine Rolle: "Mod+Shift+K" ist "mod+shift+k", und ein Großbuchstabe ist dieselbe Taste, nicht Shift ("mod+K" ist "mod+k"). Kleinschreibung ist der empfohlene Stil, den diese Seiten verwenden. "mod-k" und "mod++" sind Fehler, deren Meldungen die richtige Schreibweise nennen. Menütastenkürzel in nsh/desktop akzeptieren dieselben Tastenanschläge.

Tastenkürzel funktionieren mit jedem Tastaturlayout: Bei einem kyrillischen oder griechischen Layout ist mod+k die Taste, die auf einem US-Layout K erzeugt, genau wie bei Menütastenkürzeln.

bind löst einen Fehler (TypeError) aus, der bei einem ungültigen Tastenanschlag sagt, was falsch ist (keymap.bind: invalid keystroke "cmd+kk": unknown key "kk"), und lehnt die Tastenanschläge ab, die das System für sich behält, sodass eine App sie nie außer Kraft setzen kann:

Plattform Reserviert
macOS, iOS Cmd+Q (Beenden), Cmd+H (Ausblenden), Option+Cmd+H (Andere ausblenden), Cmd+M (Minimieren), Ctrl+Cmd+F (Vollbild)
Linux, Windows, Android Ctrl+Q (Beenden), Alt+F4 (Schließen), F11 (Vollbild)
Web Keine (der Browser behält seine eigenen Tasten)

#Während der Texteingabe

Ein Texteingabefeld mit Tastaturfokus braucht seine Tasten: Tippen, Cursor bewegen, Löschen sowie Alles auswählen, Ausschneiden, Kopieren, Einfügen, Rückgängig und Wiederholen. Daher wird eine Bindung standardmäßig nicht ausgelöst, solange ein Texteingabefeld (oder eine Web View) den Fokus hat und ihr Tastenanschlag einer dieser ist: einfache Tasten und Tasten nur mit Shift oder Option, die Pfeiltasten, Home, End, Page Up / Down, Return, Tab, Escape, Backspace, Delete sowie mod+a, mod+c, mod+v, mod+x, mod+z. Andere Tastenanschläge mit Command oder Control und die Funktionstasten werden unabhängig vom Fokus ausgelöst.

when ändert das (Rust: KeyOptions { when: When::Always, .. }):

when Wird ausgelöst
(Standard) Wie oben
"always" Auch in Texteingabefeldern, bevor das Feld die Taste sieht
"notEditing" Nie, solange ein Texteingabefeld den Fokus hat
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());

Unter iOS und Android bleibt eine Taste, mit der eine Eingabemethode gerade komponiert (Kandidat auswählen, bestätigen), bei der Eingabemethode. Im GPUI-Backend zählt eine native Komponente mit Tastaturfokus als Texteingabefeld, da sie ein Texteditor sein kann. Sie sieht jede Taste zuerst: Eine Taste, die sie selbst verarbeitet, erreicht die Bindungen nie, und die Bindungen, die beim Bearbeiten von Text ausgelöst werden, erhalten die Tasten, die sie durchlässt.

#Welche Bindung ausgeführt wird

Eine Taste, die eine Bindung auslöst, wird verbraucht: Sie wird nicht getippt, das System gibt keinen Signalton aus, und die eigenen Tasten von NSH sehen sie nicht (Escape für Zurück, Tab zum Verschieben des Fokus, die Pfeiltasten zum Scrollen). Haben mehrere Bindungen denselben Tastenanschlag, wird die neueste ausgeführt, die ausgelöst werden darf. So kann ein Dialog Escape über die Bindung des Screens legen, und die des Screens kommt zurück, wenn die des Dialogs entfällt. Bindungen werden nativ in dem Moment geprüft, in dem die Taste gedrückt wird; der Handler läuft direkt danach, auf dem Engine-Thread (Rust) oder dem JS-Thread (TSX).

Hält man eine Taste gedrückt, wird ihre Bindung einmal ausgelöst, bis die Taste losgelassen wird; mit repeat: true wird sie bei jeder automatischen Wiederholung erneut ausgelöst (etwa um durch eine Liste zu schalten):

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

Menütastenkürzel weichen einer Bindung desselben Tastenanschlags: die des Anwendungsmenüs (nsh/desktop) auf jedem Desktop und unter macOS auch die der integrierten Menüs (Cmd+W, Cmd+C, …). Während Sie mit nsh run entwickeln, lädt Cmd+R / Ctrl+R die App immer neu, auch wenn sie mod+r bindet; eine paketierte App kann es binden.

#nativeComponent und nativeModule

nativeComponent und nativeModule verbinden Ihre App zur Laufzeit über Namen mit dem, was die Host-App bereitstellt. Eine iOS- oder Android-App, die NSH einbettet, kann eigene Views (eine Karte, ein Diagramm, eine Kameravorschau) unter einem Namen registrieren; nativeComponent liefert Ihnen eine typisierte Komponente für eine davon. Da NSH mit echten nativen Views rendert, sitzt eine solche Komponente wie jede andere View in der View-Hierarchie: NSH legt sie aus, sie wird mit ihren Eltern beschnitten und gescrollt und überlappt korrekt. Eine App, die NSH aus Rust einbettet, kann auch Funktionen, Klassen und Rust-Komponenten registrieren; nativeModule liefert sie als ein typisiertes Objekt. Ihre App baut in beiden Fällen mit dem Standard-nsh build: Der Host verbindet, was er bereitstellt, wenn er die App ausführt. Rust-Code platziert eine registrierte View mit native(name) aus nsh_core::prelude, setzt ihre Props mit .prop(name, value) (ein JSON-Wert oder ein Signal oder eine Closure davon) oder bindet eine davon mit .bind_prop(name, &value) an einen animierten Wert, empfängt ihre Events mit .on_native_event(…) und ruft ihre Commands über ElementRef auf; TSX importiert nsh.

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

Siehe Native Components zum Registrieren der nativen Seite sowie Einbettung unter iOS / Einbettung unter 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)[];
}

Gibt eine Komponente für die native Komponente zurück, die die einbettende App als name registriert hat (Swift NshAppView.register(component:), Java NshView.registerComponent). Rust: native(name), eine View, deren Props JSON-Werte sind (.prop("text", json!(…))) und deren Events als NativeEvent { name, data } eintreffen.

  • P ist der Typ der eigenen Props. Jede in spec.props aufgeführte Prop wird als JSON an die native Seite gesendet, wenn sie sich ändert; sie auf undefined zu setzen entfernt sie.
  • E ordnet Event-Namen ihren Payloads zu. Ein Event regionChange wird zur Handler-Prop onRegionChange.
  • Die Komponente akzeptiert außerdem jede View-Prop außer children: style, class, Layout, Barrierefreiheit, Touch-Handler und 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 };

#Größe

Die Komponente wird wie eine View ausgelegt, in der Größe, die ihr Style vorgibt. Wo der Style eine Dimension offen lässt, verwendet sie die Größe, die die native Komponente meldet (wie ein Bild seine natürliche Größe verwendet).

#Commands

Ihr ref erhält ein NativeComponentRef; ref.command(name, args) ruft den Command name der nativen Komponente mit args auf (ein beliebiger JSON-Wert). Rust: ElementRef::command(name, args).

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

#Wenn nichts registriert ist

Ist unter name nichts registriert (eine andere Host-App, der Dev-Host, der Browser), rendert die Komponente eine leere View, und es wird einmalig eine Warnung protokolliert. Halten Sie die App auch ohne sie benutzbar oder prüfen Sie Ihren Host, bevor Sie sie verwenden.

#Beispiel

Das Badge hier ist eine native View der Host-App, registriert als embed.badge. NSH legt es aus, sendet ihm text und color, empfängt seine tap-Events und ruft seinen pulse-Command auf:

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;

Gibt die Exporte des Sets zurück, das die einbettende App als specifier registriert hat: ihre Module, Klassen, Funktionen und Rust-Komponenten als ein Objekt. Den Typ T schreibt die App selbst, daher wird beim Build nichts über den Host benötigt. Rust-Code, der zusammen mit dem Crate des Hosts gebaut wird, ruft diese Funktionen als Rust auf; nativeModule ist der Weg, auf dem TSX sie erreicht.

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));
  • Aufrufe funktionieren wie bei einem Import eines Sets: Eine einfache Rust-Funktion gibt ihren Wert sofort zurück, eine async-Funktion gibt ein Promise zurück, und eine übergebene Funktion ist ein Callback, den Rust später aufrufen kann. Rust prüft jedes Argument und wirft bei einem falschen einen TypeError.
  • Jeder Aufruf mit demselben specifier gibt dasselbe Objekt zurück.
  • Ist unter specifier nichts registriert, wirft nativeModule einen ReferenceError, der es benennt. Rufen Sie es dort auf, wo die App das behandeln kann, oder nur im Host, für den es gedacht ist.

Der Host registriert das Set aus Rust mit set_ui_sets des Platform-Crates (neben den eigenen Sets von NSH), bevor er seine Views startet, oder mit 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 ist der Weg, wie der Host jede App mit seinen eigenen Daten synchron hält: Der dabei gestartete Task wartet auf Änderungen des Hosts und sendet jede davon an die App, sodass eine Änderung durch den Host oder durch eine beliebige App über eine andere Funktion jede App erreicht, die zuhört. Was Stop zurückgibt, ist die Funktion, die Trading.on in TSX zurückgibt: Sie läuft einmal, wenn die App sie aufruft oder wenn das, was on aufgerufen hat, endet (eine Komponente oder der Lauf des Moduls bei Hot Reload), und entfernt hier den Listener, sodass der Task endet.

#Accessibility

Die Accessibility API liest die Bedienungshilfen-Einstellungen des Nutzers (läuft ein Screenreader, wurde weniger Bewegung gewünscht) und kommuniziert mit dem Screenreader. Rust-Code ruft screen_reader(), reduce_motion() und announce_for_accessibility() aus nsh_core::prelude auf und bewegt den Fokus mit einem ElementRef; TSX importiert screenReaderEnabled(), reduceMotion(), announce() und setAccessibilityFocus() aus nsh. Die Einstellungen sind reaktiv: Lesen Sie sie in einem Binding, computed oder effect, und was sie liest, wird aktualisiert, wenn der Nutzer sie ändert. Die Accessibility-Props von Elementen (accessibilityLabel, role, …) sind unter Elements dokumentiert; die Anleitung ist Accessibility.

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

#screenReaderEnabled

const screenReaderEnabled: Accessor<boolean>;

Ob ein Screenreader (VoiceOver, TalkBack) läuft. Reaktiv: Was ihn liest, wird aktualisiert, wenn der Nutzer den Screenreader ein- oder ausschaltet. Rust: screen_reader() -> bool, auf dieselbe Weise reaktiv.

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

#reduceMotion

const reduceMotion: Accessor<boolean>;

Ob der Nutzer weniger Bewegung angefordert hat (Reduce Motion unter iOS, Remove animations unter Android). Reaktiv. Rust: reduce_motion() -> bool. Tailwind-Klassen folgen ihr ebenfalls mit der 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;

Der Screenreader liest message vor (eine VoiceOver-Ansage, TalkBack). Verwenden Sie es für Ergebnisse, die erscheinen, ohne dass der Fokus wandert, etwa „Gespeichert”. Rust: announce_for_accessibility(message).

#setAccessibilityFocus

function setAccessibilityFocus(node: ViewRef): void;

Verschiebt den Fokus des Screenreaders auf node (die ref eines Elements). 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))
}

#Typen

nsh exportiert außerdem die Typen der reaktiven API und der Host-Elemente. Die am häufigsten verwendeten:

Typ Was es ist
Accessor<T> Ein lesbarer Wert: () => T
Signal<T> Ein Accessor<T> mit set und update
SignalOptions<T> Optionen von signal und computed (equals)
Context<T> Was context zurückgibt
Component<P> (props: P) => JSX.Element
JSX Der JSX-Namespace (JSX.Element)
ViewRef, InputRef, ScrollRef, ListRef, WebViewRef, VideoRef, NativeComponentRef Was ein ref erhält, jeweils der eigene Typ des Elements (ViewRef und die darüberliegenden Typen): Fokus, Scrollen und weitere Commands. ViewRef ist auch das, was ein Element ist (JSX.Element). Siehe Ref-Methoden
ViewProps, TextProps, ImageProps, ScrollProps, CommonProps, TouchProps Props von Host-Elementen
ViewStyle, TextStyle, InputStyle, ImageStyle, LayoutStyle, StyleProp<T> Style-Objekte; siehe die Style-Referenz
ColorValue, DimensionValue, LengthValue, TransformStep Werte innerhalb von Styles
GestureResponderEvent, MouseEvent, MouseButton, ContextMenuEvent, PressEvent, ScrollEvent, PanEvent, TouchPoint Event-Payloads
Point, Size, Bounds, Edges, Modifiers Die Strukturen, aus denen Payloads und Props bestehen, mit den Feldern von GPUI (nsh_core::geometry)
PixelSize Die eigene Größe eines Bildes oder Videos in Pixeln (onLoad, Image.getSize, die Bilder der Zwischenablage und des Pickers), wobei Size in Punkten angegeben ist
ImageAsset Was der Import einer Bilddatei liefert ({ url, size: Size, scales? }; scales listet ihre Pixeldichte-Varianten)
OS "ios" | "android" | "macos" | "windows" | "linux" | "web"
AccessibilityRole, AccessibilityState Accessibility-Props
import { type GestureResponderEvent, type InputRef, signal } from "nsh";

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

StyleProp<T> akzeptiert einen Style oder eine Liste, die von links nach rechts zusammengeführt wird, wobei falsy-Einträge übersprungen werden:

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