Native Shell apps are written in Rust, in TSX, or in both. All three run on the same engine: the reactive graph, the renderer, Taffy layout and the native modules (navigation, dialogs, the keyboard, the status bar) are Rust in every case, and every platform renders with its own UI. What differs is the language of your own code, and with it whether the app carries a JavaScript VM.
| Rust | TSX | Rust with TSX | |
|---|---|---|---|
| Your UI, state and handlers | Rust | TSX on QuickJS | Rust components and modules, TSX screens that use them |
| The app runs | the engine and your crate only, on every platform and in the browser | QuickJS and the engine | QuickJS and the engine, with your crate |
| Suits | Medium and large apps with demanding business logic | Apps and modules delivered at run time, quick iteration | A Rust core with parts that change often |
| Edits | Rebuild and restart | Hot updates | Hot updates for TSX, rebuild and restart for Rust |
| Start | nsh init my-app --template rust | nsh init my-app --template ts | nsh init my-app --template mixed |
Rust suits apps whose business logic is large and demanding: trading, editors, data-heavy tools. You get Rust‘s type system and compile-time checks across the whole app, the crates.io ecosystem, native performance for your own code (not only for NSH’s), and headless tests of the real UI with cargo test. The app is Rust and native code from start to end: no JavaScript VM ships with it or runs in it, on iOS, Android, macOS, Windows, Linux, GPUI and the web. Components are functions that run once and bind signals to what they show, as in TSX (Reactivity), written with typed styles instead of class strings.
TSX suits UI that arrives after the app ships: modules a native app loads at run time, generated by a model or written by another team, delivered as signed packages and run in a sandbox the host configures, with hot updates while you develop.
Rust with TSX keeps the Rust core and adds TSX where it pays off: screens, settings, onboarding, experiments, anything you iterate on with hot updates or deliver at run time as a signed package. TSX imports your Rust components and modules like any NSH module, so the two halves meet at typed interfaces. The TSX runs on QuickJS, which the app then carries.
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 holds the app: components, state, handlers, tasks.
use nsh_base::{button, theme}; use nsh_core::ui::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)), ) }
Elements and components are made the same way: a function makes one (view(), text(..), nsh_base's button(), your #[component] fn Chart's chart()), and its methods set its props, one setter per prop (a value, a signal or a closure), and its listeners, one method per event. Listeners are closures of their event (move |text: &str| ..), and move || .. for an event without data; .child(..) takes an element, or an Option, a Vec, an array or an iterator of elements (.child(rows.iter().map(|r| row(r)))).
nsh.json names the root, and nsh mounts it:
{ "name": "My App", "rust": ".", "root": "App", "theme": "theme.json", "appearance": "system" }
"root" is the component‘s path in the crate (App, shell::Gallery; in the first one with several crates); "theme" is the theme its components draw with, and "appearance" applies to it as it does to a TSX app. With it, nsh run and nsh package of the app’s directory build the app's own runner for the target, with the root mounted and no QuickJS linked in:
| Target | What runs |
|---|---|
| macOS, Windows, Linux | The release runner with your crate, in a window; nsh run runs a debug build and shows the app's logs in the terminal |
| iOS, the iOS simulator, Mac Catalyst | The iOS runner with your crate, in an app |
| Android | The app‘s libnsh.so with your crate, started by the runtime’s activity |
| Web | nsh.wasm with the engine and your crate (3.3 MB for the whole Rust gallery, 0.75 MB with brotli); the page mounts the root |
nsh builds what your crate needs and nothing more: a small program of your crate and NSH‘s core that describes its components and modules to the CLI, which then does the rest itself, and the runner of each target you run or package. The first build of a new Rust app compiles your crate and NSH’s core, about 60 crates. nsh test runs the app's tests headless in a runner of your crate built the same way.
On Linux, your crate selects optional platform features. Enable webview on your nsh-platform-linux dependency to use web views; this links WebKitGTK 6.0, which must be installed to build and run the app. The generated runner inherits that choice through Cargo feature unification.
During nsh run, a change to the crate's Rust code builds the runner again and restarts the app; in the browser, the open page loads the new runtime by itself. cargo run starts the same root directly on the desktop backend (src/main.rs), and native apps that embed NSH mount a Rust root with AppSource::Rust on the AppView of macOS, Windows, Linux, GPUI and iOS.
Code for every platform, the web included, uses NSH‘s services: fetch for HTTP (Networking), timer, set_timeout and set_interval for waiting, nsh_core::time::Instant and nsh_core::time::unix_ms() for the clock. background and blocking run work on the native targets’ threads.
Rust apps draw their own title bars with nsh_core::ui::desktop. Call set_title_bar(TitleBarStyle::Hidden, Some(height)), mark the bar‘s background with .window_drag(true), and reserve the native window controls’ space with title_bar(). Its top is the bar‘s height, and left and right are the widths occupied by the system controls. Reading it subscribes to changes, including full screen. The platform handles window dragging and the user’s title-bar double-click behavior; .window_drag(false) keeps a child out of its parent's drag region.
use nsh_core::{ desktop::TitleBarStyle, ui::{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) restores the system title bar. close_window() follows the embedding host's normal window-close behavior.
The crate‘s #[native_module("app/native")] function names what TSX may import: components (registry.register::<Chart>()), modules of functions (registry.module::<Portfolio>(), #[module] on a unit struct and its impl), classes (registry.class::<Ledger>(), #[class] on a struct and its impl) and assets; the attribute also makes pub fn registry() at the crate’s root, the same set for your tests and other Rust code (my_app_native::registry(), next to nsh_base::registry()). In a #[module] or #[class] impl, the pub fns are exported (pub(crate) too); functions without a visibility are helpers that stay in Rust. Their TypeScript declarations, doc comments included, are generated from the Rust types into .nsh/types, so TSX is checked against them by nsh check and your editor:
/// 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={(price) => balance.set(ledger.add(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 />);
Each export is documented in TypeScript by the doc comment of the Rust item that names it: a component by its #[component] fn (Chart), a module and a class by their struct (Portfolio, Ledger), and props, events, functions and members by their own (a setter needs none beside its getter). The Props struct‘s and the event enum’s own doc comments, and those of impl blocks, stay in Rust; a doc comment on a #[module] or #[class] impl block is a compile error, since TypeScript would not show it. Each command that writes .nsh/types warns about an export TSX sees without a doc comment, naming it (Ledger.add, Chart.range).
TypeScript shows a doc comment up to its first heading. Notes for Rust callers, such as what &self borrows or why a function takes cx, go under a heading, # Rust (or rustdoc's # Panics, # Examples), and stay in the Rust docs:
/// 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 are the fields of the component‘s Props struct and events the variants of its Event enum (onSelect), both in camel case. Rust code places the same component with its constructor, a setter per prop and a listener method per event: chart().symbol("NSH").range("1M").on_select(move |price: &f64| last.set(*price)). A Rust component’s own content, events and state stay in Rust; TSX passes props in and hears events. A prop of the wrong type (level={1.5} for a u32 prop) is a warning, as on a built-in element: the prop keeps its value and the rest of the component renders.
A function or a member runs the way its Rust signature says. One that is not async runs inside the TSX call and returns its value (Portfolio.total() is a number, ledger.balance a property): it computes and keeps state. An async fn returns a Promise and runs as a task with NSH‘s services (fetch, timer, dialogs). A module function taking cx: &mut ModuleCx reaches the platform in order with the UI (a toast, the status bar), and so does a component’s ref method; their return type decides what TSX gets: one returning () gives void, with no reply (TSX calls it and moves on); one returning Result<(), E> gives Promise<void>, which rejects with the Err‘s message (the call runs with the UI’s changes, after the TSX call returned, so the Promise carries its error; one nobody awaits or catches is logged as an unhandled rejection); one returning a value gives Promise<T>. Arguments and results are typed from the signature: numbers, strings, Option, Vec, your #[derive(JsObject)] structs and #[derive(PropEnum)] enums, instances of your classes, and TSX functions (Callback<A, R>, which Rust calls at once inside a call, or later with post and call_async; TS sees (value: number) => void for a Callback<f64>, and #[args(balance)] listener: Callback<f64> names the argument, (balance: number) => void). An event‘s handler argument is value too, and #[args(index)] on the event’s variant names it. An argument of the wrong type throws a TypeError in the call, or rejects the Promise of one that returns a Promise; an Err throws (or rejects) with its message. A class‘s object owns its Rust value: it is freed when the object is collected, or at once with ledger.dispose() (also using ledger = new Ledger()). Name the TSX entry to run it, nsh run App.tsx or nsh package App.tsx --target …: TSX edits are hot updates, Rust edits rebuild and restart the app. The CLI bundles and compiles the TSX itself, knowing your crate’s exports from its description; the development hosts and nsh test's headless runner are built with your crate and QuickJS.
examples/rust-exports in the repository is the reference for exporting: a small TSX app whose crate exports one component (props required, defaulted and optional, an event, children), one module (a function of each kind) and one class (a constructor, methods, a getter and a setter, a static, an async member, another instance as an argument, a callback Rust posts to, dispose() and using), each with a short doc comment, and tests, in Rust and in TypeScript (App.test.tsx), that press through the app and check what each one shows.
examples/gallery-rs in the repository is a complete Rust app: every demo is Rust, nsh run examples/gallery-rs --target web (or ios, android, …) runs its root, nsh test examples/gallery-rs runs its tests headless, and cargo run -p gallery-native starts it on the desktop backend.
A component draws content of the app‘s for values it has (a row of its data, a code block of a document) with a render function: a prop Option<Renderer<T>>, in TypeScript (value: T) => JSX.Element. The component calls render(key, value) where the content goes and gets a view it styles and places as any other (inside a text(..), an inline view). The content is kept per key while the value stays equal, and rendered again when it changes. A Rust parent passes a closure to the prop’s setter (quote().render_badge(|symbol| ..)), which makes the Renderer of it.
Styles in a component are typed; the app‘s Tailwind classes reach one of its parts through a prop of classes and .app_class(..) on that part’s element. They apply after the element‘s own styles, with the app’s theme, dark: and the states, as class on an element does.
/// 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>} />
TextView in nsh/base is built this way: its renderCodeBlock, renderMath, ... are render functions, and its classes are the app's classes by part (TextView).
A Rust app's tests run it headless, in cargo test or in nsh test, with the same driver: on the real engine, without a window, with display frames at 60 Hz, so timers, tasks and animations run as on screen. The driver starts the app, keeps a model of the views it shows and takes steps (press, type_text, submit, scroll, wait_for) on views named by selectors (a text, an accessibility label or a test id; text:, label:, id:); a step that cannot be taken fails with the texts on screen.
In cargo test, nsh_testing::Headless (nsh-testing‘s default features, without QuickJS) runs the root (app.screen(): the texts on screen, the commands it sent); EngineConfig::new is a phone-sized engine with nothing else set. nsh test runs TypeScript tests (Testing) in a headless runner built with your crates, where open(".") starts a Rust app’s root:
[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(); }
// App.test.tsx, next to nsh.json: nsh test import { expect, open, test } from "nsh/test"; test("a tap counts", async () => { const app = await open("."); await app.press("Tap"); expect(app.texts()).toContain("Count: 1"); });
A TSX entry is built with nsh-testing‘s feature bundle as nsh run builds it, against the UI sets its hosts serve: NSH’s, and each crate's registry(), which #[native_module] makes:
[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 builds the entry once for several tests, and nsh_testing::start hands the batches to a test that keeps its own model of the screen. examples/rust-exports tests its TSX entry this way; examples/gallery-rs tests its Rust root in TypeScript, a test file for about each demo.
The look of an app is the app‘s own: NSH provides the primitives and the unstyled base components of nsh_base, and your crate is the styled layer on top, the app’s own component library (Three layers). Rust components take their colors, corner radius and fonts from a theme, theme() from nsh_base, so one file restyles every component of the app. Each color has a light and a dark value; when the system switches appearance, a binding such as .bg(move || theme().primary) changes color natively, with no render.
The theme has the design tokens of shadcn/ui, and its default is shadcn‘s neutral theme, a black-and-white palette with a 10 pt corner radius. To use your own, set "theme" in nsh.json to the path of a theme file, relative to nsh.json, or to the theme’s content inline:
{ "name": "My App", "rust": ".", "theme": "brand-theme.json" }
A theme file is shadcn‘s cssVars, the format of shadcn’s themes and registry items, so a theme exported from shadcn's themes or tweakcn works as it is. light sets the values of the light appearance, dark those of the dark one, and theme those of both:
{ "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 sets and dark leaves out is used in both appearances.#7c3aed, rgb(…), hsl(…), oklch(…), white), color-mix(in oklab, var(--primary) 80%, white), or another token of the same appearance, var(--primary) (with a fallback: var(--brand, #7c3aed)). Lengths are rem (16 points) or px (points). Fonts are font family lists: the first family that is not a generic one is used, and a generic first family (system-ui, sans-serif) is the system font.@theme is: "up" above is theme().color("up") in Rust, bg-up / text-up/70 in classes and token("up") in TSX (Theme tokens). A number such as "radius-card": "14px" is theme().number("radius-card") and rounded-(--radius-card).cssVars fails the build with an nsh.json: theme: … error.The tokens, with their default light and dark values (shadcn's neutral):
| Token | theme() | Light | Dark | Role |
|---|---|---|---|---|
background / foreground | background / foreground | oklch(1 0 0) / oklch(0.145 0 0) | oklch(0.145 0 0) / oklch(0.985 0 0) | The app's background, and the text on it |
card / card-foreground | card / card_foreground | oklch(1 0 0) / oklch(0.145 0 0) | oklch(0.205 0 0) / oklch(0.985 0 0) | Cards and grouped surfaces, and the text on them |
popover / popover-foreground | popover / popover_foreground | oklch(1 0 0) / oklch(0.145 0 0) | oklch(0.205 0 0) / oklch(0.985 0 0) | Popovers, menus and tooltips, and the text on them |
primary / primary-foreground | primary / primary_foreground | oklch(0.205 0 0) / oklch(0.985 0 0) | oklch(0.922 0 0) / oklch(0.205 0 0) | The main action (a primary button), and the text on it |
secondary / secondary-foreground | secondary / secondary_foreground | oklch(0.97 0 0) / oklch(0.205 0 0) | oklch(0.269 0 0) / oklch(0.985 0 0) | Secondary actions, and the text on them |
muted / muted-foreground | muted / muted_foreground | oklch(0.97 0 0) / oklch(0.556 0 0) | oklch(0.269 0 0) / oklch(0.708 0 0) | Quiet surfaces; secondary text: captions, labels, placeholders |
accent / accent-foreground | accent / accent_foreground | oklch(0.97 0 0) / oklch(0.205 0 0) | oklch(0.269 0 0) / oklch(0.985 0 0) | A hovered or highlighted item, such as a menu item or list row, and its text |
destructive | destructive | oklch(0.577 0.245 27.325) | oklch(0.704 0.191 22.216) | Destructive actions and errors |
border | border | oklch(0.922 0 0) | oklch(1 0 0 / 10%) | Dividers and borders |
input | input | oklch(0.922 0 0) | oklch(1 0 0 / 15%) | The border of inputs |
ring | ring | oklch(0.708 0 0) | oklch(0.556 0 0) | Focus rings |
chart-1 … chart-5 | chart_1 … chart_5 | oklch(0.87 0 0) … oklch(0.269 0 0) | the same | The series of a chart |
sidebar, sidebar-foreground, sidebar-primary, sidebar-primary-foreground, sidebar-accent, sidebar-accent-foreground, sidebar-border, sidebar-ring | sidebar, 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) | A sidebar's surface, its main action, its highlighted item, borders and focus ring |
radius | radius | 0.625rem (10 pt) | The corner radius | |
font-sans | font_sans | the system font | The app's font: every text that sets no family of its own | |
font-mono | font_mono | the system monospace font | The monospaced font: font-mono, code in TextView |
An app's Rust code can be split into crates, each imported by its own specifier: "rust" takes a list of crate paths, and each crate in it has its own #[native_module] function.
{ "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")]
Every runner and host links all of them, and .nsh/types declares each specifier. Crates share code through Cargo path dependencies (shared = { path = "../shared" }): a crate of company components can be used by several crates and listed by several apps, and only the crates listed in "rust" export to TSX. Each listed crate has a package name and a specifier of its own; nsh reports a repeated one with the two paths that share it. A Rust app's "root" is a component of the first crate listed. During nsh run and nsh dev, a change to any of them, or to a crate they depend on by path, rebuilds the app and restarts it.
On the web, a crate can call the browser's APIs (a WebSocket, IndexedDB, …) through wasm-bindgen and web-sys; nsh builds the glue they need with the runtime. See Web.
nsh.json.fetch from Rust.nsh init --template rust and --template mixed.