このページは機械翻訳のため、誤りを含む場合があります。 英語の原文を読む

ドキュメント

Rustでビルドする

Native Shell アプリは、Rust、TSX、またはその両方で書けます。3つとも同じエンジン上で動作します。リアクティブグラフ、レンダラー、Taffy レイアウト、ネイティブモジュール(ナビゲーション、ダイアログ、キーボード、ステータスバー)はいずれの場合も Rust で書かれており、各プラットフォームはそれぞれ独自の UI でレンダリングします。違いは自分のコードの言語であり、それに伴ってアプリが JavaScript VM を持つかどうかが変わります。

RustTSXRust と TSX
UI、状態、ハンドラーRustQuickJS 上の TSXRust のコンポーネントとモジュール、それらを使う TSX の画面
アプリの実行環境エンジンと自分のクレートのみ(全プラットフォームおよびブラウザ)QuickJS とエンジン(ブラウザではその JavaScript エンジン)QuickJS とエンジン、および自分のクレート(ブラウザではその JavaScript エンジン)
向いている用途高度なビジネスロジックを持つ中規模・大規模アプリ実行時に配信されるアプリやモジュール、素早い反復Rust のコアに、頻繁に変わる部分を持つアプリ
編集再ビルドして再起動ホットアップデートTSX はホットアップデート、Rust は再ビルドして再起動
開始nsh init my-app --template rustnsh init my-app --template tsnsh init my-app --template mixed

#選び方

Rust は、ビジネスロジックが大規模で高度なアプリ(トレーディング、エディター、データ集約型ツールなど)に向いています。アプリ全体にわたって Rust の型システムとコンパイル時チェックを利用でき、crates.io のエコシステムを使え、NSH だけでなく自分のコードもネイティブの性能で動作し、cargo test で実際の UI をヘッドレスでテストできます。アプリは最初から最後までRustとネイティブコードで構成され、iOS、Android、macOS、Windows、Linux、GPUI、Web のいずれでも JavaScript VM は同梱されず、実行もされません。コンポーネントは一度だけ実行される関数で、TSX と同様にシグナルを表示内容にバインドします(Reactivity)。スタイルはクラス文字列の代わりに型付きスタイルで記述します。

TSX は、アプリの出荷後に届く UI に向いています。たとえば、ネイティブアプリが実行時に読み込むモジュール、モデルが生成したものや別のチームが書いたものを、署名付きパッケージとして配信し、ホストが設定したサンドボックスで実行するといった用途です。開発中はホットアップデートも使えます。

Rust と TSX は、Rust のコアを維持しつつ、効果のある部分に TSX を加える方法です。画面、設定、オンボーディング、実験的な機能など、ホットアップデートで反復したいものや、実行時に署名付きパッケージとして配信したいものが対象です。TSX は、他の NSH モジュールと同様に自分の Rust コンポーネントやモジュールをインポートするため、2つの側は型付きインターフェースで接続されます。TSX は QuickJS 上で動作するため、アプリは QuickJS を抱えることになります。

#Rust で書くアプリ

nsh init my-app --template rust
cd my-app
nsh run                        # this machine's backend; --target ios, android, web, macos, linux or windows
nsh package --target web       # or ios, ios-simulator, android, macos, linux, windows

src/lib.rs がアプリ(コンポーネント、状態、ハンドラー、タスク)を保持します。

use nsh_base::{button, theme};
use nsh_core::prelude::*;

/// The components available to TSX through `app/native`.
#[native_module("app/native")]
fn native(registry: &mut Registry) {
    registry.register::<App>();
}

#[component]
pub fn App() -> impl IntoElement {
    let count = signal(0_u32);
    let quote = signal(SharedString::from("…"));
    // Async work runs in tasks; requests go through NSH's network stack.
    spawn(async move {
        if let Ok(response) = fetch("https://api.example.com/quote").await {
            quote.set(response.text().into());
        }
    });
    view()
        .flex_1()
        .items_center()
        .justify_center()
        .gap_4()
        .child(text(move || format!("Count: {}", count.get())))
        .child(text(quote))
        .child(
            // nsh_base's unstyled button, drawn with the theme's colors.
            button()
                .px(px(20.0))
                .py(px(12.0))
                .rounded(move || theme().radius)
                .bg(move || theme().primary)
                .active(|s| s.bg(move || theme().primary.opacity(0.9)))
                .on_press(move || count.update(|c| *c += 1))
                .child(text("Tap").text_color(move || theme().primary_foreground)),
        )
}

要素とコンポーネントは同じ方法で作ります。関数が1つを作り(view()、text(..)、nsh_base の button()、自分の #[component] fn Chart の chart())、そのメソッドで props(prop ごとに1つのセッターで、値、シグナル、クロージャを受け取れます)とリスナー(イベントごとに1つのメソッド)を設定します。リスナーはそのイベントを受け取るクロージャで(move |text: &str| ..)、データのないイベントには move || .. を使います。.child(..) は、要素、または Option、Vec、配列、要素のイテレーターを受け取ります(.child(rows.iter().map(|r| row(r))))。

nsh.json がルートを指定し、nsh がそれをマウントします。

{
  "name": "My App",
  "rust": ".",
  "root": "App",
  "theme": "theme.json",
  "appearance": "system"
}

"root" はクレート内のコンポーネントのパスです(App、shell::Gallery。最初のものは複数のクレートの場合)。"theme" はそのコンポーネントが描画に使うテーマで、"appearance" は TSX アプリと同様にそのテーマに適用されます。これがあれば、アプリのディレクトリで nsh run と nsh package を実行すると、ルートをマウントした、QuickJS をリンクしない、そのターゲット向けのアプリ専用ランナーがビルドされます。

ターゲット実行されるもの
macOS、Windows、Linux自分のクレートを含むリリースランナーがウィンドウで動作します。nsh run はデバッグビルドを実行し、アプリのログをターミナルに表示します
iOS、iOS シミュレーター、Mac Catalyst自分のクレートを含む iOS ランナーがアプリとして動作します
Android自分のクレートを含むアプリの libnsh.so を、ランタイムのアクティビティが起動します
Webエンジンと自分のクレートを含む nsh.wasm(Rust ギャラリー全体で 3.3 MB、brotli で 0.75 MB)。ページがルートをマウントします

nsh は、自分のクレートに必要なものだけをビルドします。つまり、自分のクレートと NSH のコアからなる小さなプログラム(コンポーネントとモジュールを CLI に記述するもの。以降の処理は CLI 自身が行います)と、実行またはパッケージ化する各ターゲットのランナーです。新しい Rust アプリの最初のビルドでは、自分のクレートと NSH のコア、約 60 クレートをコンパイルします。nsh test は、同じ方法でビルドした自分のクレートのランナーで、アプリのテストをヘッドレスで実行します。

Linux では、クレートがオプションのプラットフォーム機能を選択します。Web ビューを使うには、nsh-platform-linux 依存関係で webview を有効にします。これは WebKitGTK 6.0 をリンクするため、アプリのビルドと実行にはそれがインストールされている必要があります。生成されたランナーは、Cargo のフィーチャー統合によってこの選択を引き継ぎます。

nsh run の間は、クレートの Rust コードを変更するとランナーが再ビルドされ、アプリが再起動します。ブラウザでは、開いているページが新しいランタイムを自動的に読み込みます。cargo run は同じルートをデスクトップバックエンド(src/main.rs)で直接起動します。NSH を組み込むネイティブアプリは、macOS、Windows、Linux、GPUI、iOS の AppView に対して AppSource::Rust で Rust のルートをマウントします。

Web を含むすべてのプラットフォーム向けのコードは、NSH のサービスを使います。HTTP には fetch(Networking)、待機には timer、set_timeout、set_interval、時刻には nsh_core::time::Instant と nsh_core::time::unix_ms() を使います。background と blocking は、ネイティブターゲットのスレッド上で処理を実行します。

#デスクトップのタイトルバー

Rust アプリは nsh_core::element::desktop で独自のタイトルバーを描画します。set_title_bar(TitleBarStyle::Hidden, Some(height)) を呼び出し、.window_drag(true) でバーの背景を指定し、title_bar() でネイティブのウィンドウコントロールの領域を確保します。その top はバーの高さで、left と right はシステムコントロールが占める幅です。これを読み取ると、フルスクリーンを含む変更が購読されます。ウィンドウのドラッグとユーザーのタイトルバーのダブルクリック動作はプラットフォームが処理します。.window_drag(false) は、子要素を親のドラッグ領域から除外します。

use nsh_core::{
    desktop::TitleBarStyle,
    element::{desktop::{close_window, set_title_bar, title_bar}}, prelude::*,
};

fn window_bar() -> impl IntoElement {
    set_title_bar(TitleBarStyle::Hidden, Some(px(42.0)));
    view()
        .h(px(42.0))
        .flex_row()
        .items_center()
        .window_drag(true)
        .pl(move || title_bar().left + px(12.0))
        .pr(move || title_bar().right + px(12.0))
        .child(text("My app").flex_1())
        .child(text("Close").window_drag(false).on_press(close_window))
}

set_title_bar(TitleBarStyle::Default, None) はシステムのタイトルバーを復元します。close_window() は、組み込み先ホストの通常のウィンドウクローズ動作に従います。このモジュールには、ウィンドウに関するその他の関数(set_title、minimize、toggle_full_screen など)やメニュー(set_application_menu、popup_menu)もあります。詳しくは nsh/desktop を参照してください。

#ファイルとフォルダー

デスクトップでは、Rust コードからシステムのファイルダイアログを表示したり、ローカルパスをシステムに渡したりできます。各応答はタスク内で待機します。OpenDialog(OpenDialog::files()、::folders()、::files_and_folders())を伴う pick_paths、SaveDialog を伴う pick_save_path、open_path(ファイルのデフォルトアプリ)、reveal_path(Finder、ファイルエクスプローラー、またはファイルマネージャーで選択されたアイテム)、move_to_trash(macOS が返す場合、アイテムの移動先)があります。ダイアログは、ユーザーがキャンセルすると Ok(None) を返し、表示できない場合(ファイルチューザーポータルのない Linux、および iOS、Android、Web。Web では open_path、reveal_path、move_to_trash もエラーを返します)はエラーを返します。失敗したリクエストは必ずそのエラーを返し、false を返すことはありません。

use nsh_core::{
    file_dialog::{FileFilter, OpenDialog},
    prelude::*,
};

fn open_notes() {
    let picked = pick_paths(
        OpenDialog::files()
            .multiple(true)
            .filter(FileFilter::new("Markdown", ["md", "markdown"]))
            .button_label("Open"),
    );
    spawn(async move {
        match picked.await {
            Ok(Some(paths)) => {
                for path in paths {
                    let _ = reveal_path(path).await;
                }
            }
            Ok(None) => {} // Cancelled.
            Err(error) => log::warn!("no file dialog: {error}"),
        }
    });
}

#TSX で Rust アプリを拡張する

クレートの #[native_module("app/native")] 関数は、TSX がインポートできるものを指定します。コンポーネント(registry.register::<Chart>())、関数のモジュール(registry.module::<Portfolio>()、ユニット構造体とその impl に対する #[module])、クラス(registry.class::<Ledger>()、構造体とその impl に対する #[class])、アセットです。この属性はクレートのルートに pub fn registry() も生成します。これはテストやその他の Rust コードでも同じ集合です(nsh_base::registry() の隣の my_app_native::registry())。#[module] または #[class] の impl では、pub fn がエクスポートされます(pub(crate) も同様)。可視性のない関数は Rust に留まるヘルパーです。それらの TypeScript 宣言は、ドキュメントコメントを含め、Rust の型から .nsh/types に生成されるため、TSX は nsh check とエディターによって検査されます。

/// A price chart.
#[derive(Clone, PartialEq, Props)]
pub struct ChartProps {
    /// The symbol to chart.
    pub symbol: Prop<SharedString>,
    /// The range shown.
    #[prop(default = SharedString::from("1D"))]
    pub range: Prop<SharedString>,
}

#[derive(Event)]
pub enum ChartEvent {
    /// A price was selected.
    #[args(price)]
    Select(f64),
}

/// A price chart; a press selects the last price.
#[component]
pub fn Chart(props: ChartProps, cx: Scope<ChartEvent>) -> impl IntoElement {
    view()
        .h(px(160.0))
        .bg(move || theme().muted)
        .on_press(move || cx.emit(ChartEvent::Select(101.5)))
        .child(text(move || format!("{} · {}", props.symbol.get(), props.range.get())))
}

/// A ledger kept in Rust.
#[class]
pub struct Ledger {
    entries: Vec<f64>,
}

#[class]
impl Ledger {
    /// An empty ledger.
    pub fn new() -> Self {
        Ledger { entries: Vec::new() }
    }

    /// Adds `amount`; the new balance.
    pub fn add(&mut self, amount: f64) -> f64 {
        self.entries.push(amount);
        self.balance()
    }

    /// The balance.
    #[getter]
    pub fn balance(&self) -> f64 {
        self.entries.iter().sum()
    }

    /// Uploads the entries; resolves when the server has them.
    pub async fn upload(this: Instance<Ledger>, url: String) -> Result<(), String> {
        let body = this.with(|l| format!("{:?}", l.entries));
        fetch(HttpRequest::post(url, body)).await.map(|_| ())
    }
}

/// The app's holdings.
#[module]
pub struct Portfolio;

#[module]
impl Portfolio {
    /// The value of the holdings.
    pub fn total() -> f64 {
        1250.0
    }
}
import { mount, signal, View, Text } from "nsh";
import { Chart, Ledger, Portfolio } from "app/native";   // Rust

const ledger = new Ledger();   // its state lives in Rust

function Overview() {
  const balance = signal(ledger.add(Portfolio.total()));   // Rust, called at once
  return (
    <View class="gap-2 p-4">
      <Text>Balance: {balance()}</Text>
      <Chart symbol="NSH" range="1M" onSelect={(e) => balance.set(ledger.add(e.price))} />
      <View onPress={() => ledger.upload("https://example.com/ledger")} role="button" class="rounded-lg bg-blue-600 px-4 py-2 active:bg-blue-700">
        <Text class="text-white">Upload</Text>
      </View>
    </View>
  );
}

mount(() => <Overview />);

各エクスポートは、それを指定する Rust アイテムのドキュメントコメントによって TypeScript で文書化されます。コンポーネントは #[component] fn(Chart)、モジュールとクラスはその構造体(Portfolio、Ledger)、props、イベント、関数、メンバーはそれぞれ自身のコメントです(セッターにはゲッターの隣に別のコメントは不要です)。Props 構造体とイベント enum 自体のドキュメントコメント、および impl ブロックのものは Rust に残ります。#[module] または #[class] の impl ブロックにドキュメントコメントを付けるとコンパイルエラーになります。TypeScript では表示されないためです。.nsh/types を書き出す各コマンドは、ドキュメントコメントのない、TSX から見えるエクスポートについて、その名前を挙げて警告します(Ledger.add、Chart.range)。

TypeScript はドキュメントコメントを最初の見出しまで表示します。Rust の呼び出し側向けの注記、たとえば &self が何を借用するか、関数がなぜ cx を取るかは、見出しの下に書きます。# Rust(または rustdoc の # Panics、# Examples)で、Rust のドキュメントにだけ残ります。

/// Puts `text` on the clipboard; resolves with the number of characters copied.
///
/// # Rust
///
/// Taking `cx` makes it an op, run in order with the UI changes TS made before it.
pub fn copy(text: String, cx: &mut ModuleCx) -> u32 {
    let count = text.chars().count() as u32;
    cx.set_clipboard(text);
    count
}

props はコンポーネントの Props 構造体のフィールド、イベントはその Event enum のバリアント(onSelect)で、どちらもキャメルケースです。Rust コードでは、同じコンポーネントを、コンストラクター、prop ごとのセッター、イベントごとのリスナーメソッドで配置します(chart().symbol("NSH").range("1M").on_select(move |price: &f64| last.set(*price)))。Rust コンポーネント自身のコンテンツ、イベント、状態は Rust に留まり、TSX は props を渡してイベントを受け取ります。誤った型の prop(u32 の prop に対する level={1.5})は、組み込み要素と同様に警告となり、その prop は値を保持し、コンポーネントの残りは描画されます。

関数やメンバーは、その Rust シグネチャが示すとおりに実行されます。async でないものは TSX の呼び出し内で実行され、その値を返します(Portfolio.total() は数値、ledger.balance はプロパティ)。計算を行い、状態を保持します。async fn は Promise を返し、NSH のサービス(fetch、timer、ダイアログ)を使うタスクとして実行されます。cx: &mut ModuleCx を取るモジュール関数は、UI と順序を保ってプラットフォーム(トースト、ステータスバー)にアクセスします。コンポーネントの ref メソッドも同様です。戻り値の型によって TSX が受け取るものが決まります。() を返すものは void になり、返信はありません(TSX は呼び出して先へ進みます)。Result<(), E> を返すものは Promise<void> になり、Err のメッセージで reject されます(この呼び出しは TSX の呼び出しが戻った後、UI の変更とともに実行されるため、Promise がそのエラーを運びます。誰も await も catch もしない Promise は未処理の拒否としてログに記録されます)。値を返すものは Promise<T> になります。引数と結果はシグネチャから型付けされます。数値、文字列、Option、Vec、自分の #[derive(JsObject)] 構造体と #[derive(PropEnum)] enum、自分のクラスのインスタンス、TSX 関数(Callback<A, R>。Rust は呼び出し内で即座に、または post と call_async で後から呼び出します。TS では Callback<f64> に対して (value: number) => void と見え、#[args(balance)] listener: Callback<f64> が引数 (balance: number) => void を指定します)です。リスニングを開始する関数は on(event, listener) という名前で Stop(Stop::new(|| …))を返し、TSX はこれをリスニングを止める関数として受け取ります(() => void)。これは、TSX がそれを呼び出したとき、または on を呼び出したもの(コンポーネント、またはホットリロード下でのモジュールの実行)が終了したときに、一度だけ実行されます。イベントのハンドラーは1つのオブジェクトを受け取ります。バリアントの値はそのフィールド value で、バリアント上の #[args(price)] がその名前を指定し(onSelect({ price }))、複数のフィールドを持つバリアントのフィールドは #[args(open, reason)] で名前を指定します。誤った型の引数は、その呼び出しで TypeError をスローするか、Promise を返す関数の場合はその Promise を reject します。Err はそのメッセージとともにスロー(または reject)されます。クラスのオブジェクトは自身の Rust の値を所有し、オブジェクトがガベージコレクションされるとき、または ledger.dispose()(using ledger = new Ledger() も同様)で即座に解放されます。TSX のエントリ nsh run App.tsx または nsh package App.tsx --target … を指定して実行します。TSX の編集はホットアップデートで、Rust の編集はアプリを再ビルドして再起動します。CLI は、クレートの説明からそのエクスポートを把握したうえで、TSX 自身をバンドルしてコンパイルします。開発ホストと nsh test のヘッドレスランナーは、自分のクレートと QuickJS とともにビルドされます。

リポジトリ内の examples/rust-exports はエクスポートのリファレンスです。これは小さな TSX アプリで、そのクレートは1つのコンポーネント(必須、デフォルト付き、オプションの props、1つのイベント、子要素)、1つのモジュール(各種類の関数を1つずつ)、1つのクラス(コンストラクター、メソッド、ゲッターとセッター、static、async メンバー、引数としての別のインスタンス、Rust が投稿するコールバック、dispose() と using)をエクスポートし、それぞれに短いドキュメントコメントが付いています。また、アプリを操作して各要素の表示内容を確認する Rust と TypeScript のテスト(App.test.tsx)も含まれます。

リポジトリ内の examples/gallery-rs は完全な Rust アプリです。すべてのデモが Rust で書かれており、nsh run examples/gallery-rs --target web (or ios, android, …) runs its root, nsh test examples/gallery-rs でそのテストをヘッドレスで実行でき、cargo run -p gallery-native でデスクトップバックエンド上で起動します。

#レンダー関数とアプリのクラス

コンポーネントは、持っている値(データの行、ドキュメントのコードブロックなど)に対するアプリ独自のコンテンツを、レンダー関数で描画します。レンダー関数は prop の Option<Renderer<T>>(TypeScript では (value: T) => JSX.Element)です。コンポーネントはコンテンツを置く位置で render(key, value) を呼び出し、他のビューと同じようにスタイルを設定して配置できるビュー(text(..) の内側、インラインビュー)を受け取ります。コンテンツは値が等しい間はキーごとに保持され、値が変わると再レンダーされます。Rust の親は prop のセッター(quote().render_badge(|symbol| ..))にクロージャを渡し、セッターがそこから Renderer を作ります。

コンポーネント内のスタイルは型付けされています。アプリの Tailwind クラスは、クラス用の prop と、その部分の要素の .app_class(..) を通じて、コンポーネントの各部分に届きます。クラスは要素自身のスタイルの後に適用され、要素上の class と同じように、アプリのテーマ、dark:、各状態が反映されます。

/// A quote: its symbol, then content of the app's.
#[derive(Clone, Copy, PartialEq, Props)]
pub struct QuoteProps {
    /// The symbol.
    pub symbol: Prop<SharedString>,
    /// Tailwind classes of the symbol's text.
    pub symbol_class: Prop<Option<SharedString>>,
    /// Content of the app's after the symbol (a change, a badge).
    pub render_badge: Prop<Option<Renderer<SharedString>>>,
}

/// A quote of a symbol.
#[component]
pub fn Quote(props: QuoteProps) -> impl IntoElement {
    view()
        .flex_row()
        .items_center()
        .gap_2()
        .child(text(props.symbol).app_class(props.symbol_class))
        .child(dynamic(move || {
            props.render_badge.get().map(|r| r.render("badge", props.symbol.get()).flex_1())
        }))
}
<Quote
  symbol="NSH"
  symbolClass="text-lg font-semibold"
  renderBadge={(symbol) => <Text class="text-green-600">{`${symbol} ▲ 2.4%`}</Text>}
/>

nsh/base の TextView はこの方法で作られています。renderCodeBlock、renderMath、… はレンダー関数で、classes は部分ごとのアプリのクラスです(TextView)。

#テスト

Rust アプリのテストは、cargo test または nsh test で、同じドライバーを使ってヘッドレスで実行されます。実際のエンジン上で、ウィンドウなしに、60 Hz のディスプレイフレームで動くため、タイマー、タスク、アニメーションは画面上と同じように実行されます。ドライバーはアプリを起動し、表示されているビューのモデルを保持し、セレクター(テキスト、アクセシビリティラベル、テスト ID。text:、label:、id:)で指定したビューに対して操作(press、type_text、submit、scroll、wait_for)を行います。実行できない操作は、画面上のテキストとともに失敗します。

cargo test では、nsh_testing::Headless(nsh-testing のデフォルト機能、QuickJS なし)がルート(app.screen():画面上のテキスト、送信したコマンド)を実行します。EngineConfig::new は、他に何も設定されていない、スマートフォンサイズのエンジンです。nsh test は、あなたのクレートでビルドされたヘッドレスランナーで TypeScript のテスト(Testing)を実行し、その中で open(".") が Rust アプリのルートを起動します。

[dev-dependencies]
nsh-engine = "0.1"
nsh-testing = "0.1"
// tests/app.rs: cargo test
use nsh_engine::{EngineConfig, Root};
use nsh_testing::Headless;

#[test]
fn a_tap_counts() {
    let mut app = Headless::start(EngineConfig::new(Root::of::<my_app::App>())).unwrap();
    app.wait_for("Count: 0").unwrap();
    app.press("Tap").unwrap();
    app.wait_for("Count: 1").unwrap();
}

TSX エントリは、nsh run がビルドするのと同様に、nsh-testing の機能 bundle を有効にして、そのホストが提供する UI セット(NSH のものと、各クレートの registry()。#[native_module] が作成します)に対してビルドされます。

[dev-dependencies]
nsh-testing = { version = "0.1", features = ["bundle"] }
nsh-base = "0.1"
#[test]
fn the_module_formats_money() {
    let sets = vec![nsh_base::registry(), my_app_native::registry()];
    let mut app = nsh_testing::tsx("../App.tsx", sets).unwrap();
    app.wait_for("Formatted at once: $1,234.50").unwrap();
}

nsh_testing::compile_tsx は複数のテストのためにエントリを一度だけビルドし、nsh_testing::start は画面の独自モデルを保持するテストにバッチを渡します。examples/rust-exports はこの方法で TSX エントリをテストし、examples/gallery-rs は Rust のルートを TypeScript でテストします(デモごとにおよそ 1 つのテストファイルがあります)。

#テーマ

アプリの見た目はアプリ自身のものです。NSH は nsh_base のプリミティブとスタイルなしのベースコンポーネントを提供し、あなたのクレートはその上のスタイル付きレイヤー、つまりアプリ独自のコンポーネントライブラリになります(Three layers)。Rust コンポーネントは、色、角丸、フォントをテーマから取得します。テーマは nsh_base から theme() されるため、1 つのファイルでアプリのすべてのコンポーネントのスタイルを変更できます。各色にはライト用とダーク用の値があり、システムが外観を切り替えると、.bg(move || theme().primary) のようなバインディングは、レンダーなしでネイティブに色を変更します。

テーマには shadcn/ui のデザイントークンが含まれ、デフォルトは shadcn の neutral テーマ(角丸 10 pt の白黒パレット)です。独自のテーマを使うには、nsh.json の "theme" に、nsh.json からの相対パスでテーマファイルのパスを指定するか、テーマの内容をインラインで指定します。

{
  "name": "My App",
  "rust": ".",
  "theme": "brand-theme.json"
}

テーマファイルは shadcn の cssVars、つまり shadcn のテーマとレジストリアイテムの形式なので、shadcn’s themes や tweakcn からエクスポートしたテーマをそのまま使えます。light はライト外観の値、dark はダーク外観の値、theme は両方の値を設定します。

{
  "cssVars": {
    "theme": {
      "radius": "0.5rem",
      "font-sans": "Inter, sans-serif"
    },
    "light": {
      "primary": "oklch(0.541 0.281 293.009)",
      "primary-foreground": "oklch(0.969 0.016 293.756)",
      "ring": "var(--primary)",
      "up": "#16a34a"
    },
    "dark": {
      "primary": "oklch(0.702 0.183 293.541)",
      "primary-foreground": "oklch(0.283 0.141 291.089)",
      "up": "#4ade80"
    }
  }
}
  • テーマはデフォルトテーマの上に適用されるため、変更するものだけを記述します。light で設定され、dark に含まれない値は、両方の外観で使われます。
  • 値は CSS と同じように記述します。色は任意の CSS カラー(#7c3aed、rgb(…)、hsl(…)、oklch(…)、white)、color-mix(in oklab, var(--primary) 80%, white)、または同じ外観の別のトークン var(--primary)(フォールバック付き:var(--brand, #7c3aed))です。長さは rem(16 ポイント)または px(ポイント)です。フォントはフォントファミリーのリストで、ジェネリックでない最初のファミリーが使われ、最初のファミリーがジェネリック(system-ui、sans-serif)の場合はシステムフォントになります。
  • 独自のトークン。 それ以外の名前はアプリのトークンで、Tailwind の @theme の変数と同じように扱われます。上の "up" は、Rust では theme().color("up")、クラスでは bg-up / text-up/70、TSX では token("up") です(Theme tokens)。"radius-card": "14px" のような数値は theme().number("radius-card") および rounded-(--radius-card) です。
  • バンドラーはビルド時にテーマを検査します。ファイルが見つからない、JSON が不正、または cssVars 以外のテーマである場合、nsh.json: theme: … エラーでビルドが失敗します。

デフォルトのライト値とダーク値を含むトークン一覧(shadcn の neutral):

トークンtheme()ライトダーク役割
background / foregroundbackground / foregroundoklch(1 0 0) / oklch(0.145 0 0)oklch(0.145 0 0) / oklch(0.985 0 0)アプリの背景と、その上のテキスト
card / card-foregroundcard / card_foregroundoklch(1 0 0) / oklch(0.145 0 0)oklch(0.205 0 0) / oklch(0.985 0 0)カードやグループ化された面と、その上のテキスト
popover / popover-foregroundpopover / popover_foregroundoklch(1 0 0) / oklch(0.145 0 0)oklch(0.205 0 0) / oklch(0.985 0 0)ポップオーバー、メニュー、ツールチップと、その上のテキスト
primary / primary-foregroundprimary / primary_foregroundoklch(0.205 0 0) / oklch(0.985 0 0)oklch(0.922 0 0) / oklch(0.205 0 0)メインのアクション(プライマリボタン)と、その上のテキスト
secondary / secondary-foregroundsecondary / secondary_foregroundoklch(0.97 0 0) / oklch(0.205 0 0)oklch(0.269 0 0) / oklch(0.985 0 0)セカンダリのアクションと、その上のテキスト
muted / muted-foregroundmuted / muted_foregroundoklch(0.97 0 0) / oklch(0.556 0 0)oklch(0.269 0 0) / oklch(0.708 0 0)控えめな面、補助テキスト(キャプション、ラベル、プレースホルダー)
accent / accent-foregroundaccent / accent_foregroundoklch(0.97 0 0) / oklch(0.205 0 0)oklch(0.269 0 0) / oklch(0.985 0 0)ホバー中またはハイライトされた項目(メニュー項目やリスト行など)と、そのテキスト
destructivedestructiveoklch(0.577 0.245 27.325)oklch(0.704 0.191 22.216)破壊的なアクションとエラー
borderborderoklch(0.922 0 0)oklch(1 0 0 / 10%)区切り線と枠線
inputinputoklch(0.922 0 0)oklch(1 0 0 / 15%)入力欄の枠線
ringringoklch(0.708 0 0)oklch(0.556 0 0)フォーカスリング
chart-1 … chart-5chart_1 … chart_5oklch(0.87 0 0) … oklch(0.269 0 0)同じチャートの系列
sidebar, sidebar-foreground, sidebar-primary, sidebar-primary-foreground, sidebar-accent, sidebar-accent-foreground, sidebar-border, sidebar-ringsidebar, sidebar_foreground, …sidebar oklch(0.985 0 0), sidebar-primary oklch(0.205 0 0)sidebar oklch(0.205 0 0), sidebar-primary oklch(0.488 0.243 264.376)サイドバーの面、メインのアクション、ハイライトされた項目、枠線、フォーカスリング
radiusradius0.625rem(10 pt)角丸
font-sansfont_sansシステムフォントアプリのフォント:独自のファミリーを設定しないすべてのテキスト
font-monofont_monoシステムの等幅フォント等幅フォント:font-mono、TextView 内のコード

#複数のクレート

アプリの Rust コードは複数のクレートに分割でき、それぞれ独自のスペecifierでインポートされます。"rust" はクレートパスのリストを受け取り、リスト内の各クレートは独自の #[native_module] 関数を持ちます。

{
  "rust": ["native/charts", "native/storage"],
  "root": "App"
}
import { Chart } from "app/charts";        // native/charts: #[native_module("app/charts")]
import { Store } from "app/storage";       // native/storage: #[native_module("app/storage")]

すべてのランナーとホストがこれらをすべてリンクし、.nsh/types が各スペecifierを宣言します。クレート間のコードは Cargo のパス依存関係(shared = { path = "../shared" })で共有します。会社共通のコンポーネントのクレートを複数のクレートで使用し、複数のアプリに載せることができ、"rust" に列挙されたクレートだけが TSX にエクスポートされます。列挙された各クレートは固有のパッケージ名とスペecifierを持ち、重複がある場合、nsh はそれを共有する 2 つのパスとともに報告します。Rust アプリの "root" は、列挙された最初のクレートのコンポーネントです。nsh run と nsh dev の間、これらのいずれか、またはパスで依存しているクレートに変更があると、アプリが再ビルドされて再起動されます。

#Web でのブラウザ API

Web では、クレートから wasm-bindgen と web-sys を通じて、ブラウザの API(WebSocket、IndexedDB など)を呼び出せます。nsh は、それらに必要なグルーコードをランタイムとともにビルドします。Web を参照してください。

#関連項目

  • Project structure:nsh.json のすべてのキー。
  • Web:ブラウザ上の Rust アプリ。
  • Networking:Rust からの fetch。
  • CLI:nsh init --template rust と --template mixed。