Docs

Device and environment

The environment an app runs in changes while it runs: the window resizes (rotation, split view, a Mac window), the user switches to dark mode or a larger text size, the keyboard comes and goes. NSH exposes each of these as a reactive accessor: call it like a signal, and whatever read it (a style, a text, a computed value) updates when it changes. There are no listeners to add or remove.

guide-device.tsx
guide-device.tsxLive
import { mount, signal, Text, View } from "nsh";
import { colorScheme, fontScale, pixelRatio, platform, windowSize } from "nsh/device";

function Row(props: { label: string; value: string }) {
  return (
    <View class="flex-row py-2 border-b border-[#e9e9e4] dark:border-[#1f2124]">
      <Text class="flex-1 text-[#565a61] dark:text-[#8d9198]">{props.label}</Text>
      <Text class="text-[#15161a] dark:text-[#eceef1]">{props.value}</Text>
    </View>
  );
}

function App() {
  const hairline = () => 1 / pixelRatio();
  const rtl = signal(false);
  return (
    <View class="flex-1 bg-[#f3f3ef] dark:bg-[#101113] px-4 pt-6 gap-4">
      <Text class="text-2xl font-bold text-[#15161a] dark:text-[#eceef1]">Environment</Text>
      <View class="rounded-2xl bg-white border border-[#e1e1db] dark:bg-[#18191c] dark:border-[#2c2e33] px-4 py-1">
        <Row label="windowSize()" value={`${Math.round(windowSize().width)} × ${Math.round(windowSize().height)}`} />
        <Row label="colorScheme()" value={colorScheme()} />
        <Row label="fontScale()" value={fontScale().toFixed(2)} />
        <Row label="platform.os" value={platform.os} />
        <Row label="platform.version" value={platform.version} />
        <Row label="platform.idiom" value={platform.idiom} />
        <Row label="Locale" value={new Intl.DateTimeFormat().resolvedOptions().locale} />
        <Row label="pixelRatio()" value={String(pixelRatio())} />
      </View>

      <Text class="text-[#565a61] dark:text-[#8d9198]">
        {platform.select({ ios: "Running on iOS.", android: "Running on Android.", web: "Running in a browser.", default: "Running elsewhere." })}
      </Text>

      <View class="gap-1">
        <View class="bg-[#8c9097] dark:bg-[#5a5e65]" style={{ height: hairline() }} />
        <Text class="text-xs text-[#8c9097] dark:text-[#5a5e65]">The line above is one device pixel tall.</Text>
      </View>

      <View class={windowSize().width >= 600 ? "flex-row gap-3" : "gap-3"}>
        <View class="flex-1 rounded-xl bg-[#4a6a0a] dark:bg-[#c8f26d] p-4">
          <Text class="text-white dark:text-[#13160c] font-semibold">dark: classes follow the system</Text>
        </View>
        <View class="flex-1 rounded-xl bg-white border border-[#e1e1db] dark:bg-[#24262a] dark:border-[#2c2e33] p-4">
          <Text class="text-[#15161a] dark:text-[#eceef1] font-semibold">A row only when the window is 600 pt or wider</Text>
        </View>
      </View>

      <View
        onPress={() => rtl.set(!rtl())}
        activeStyle={{ opacity: 0.6 }}
        style={{ direction: rtl() ? "rtl" : "ltr" }}
        class="flex-row items-center gap-3 rounded-2xl bg-white border border-[#e1e1db] dark:bg-[#18191c] dark:border-[#2c2e33] p-4"
      >
        <Text class="text-lg text-[#4a6a0a] dark:text-[#c8f26d]">⇄</Text>
        <Text class="flex-1 text-[#15161a] dark:text-[#eceef1]">Lay out right to left: {rtl() ? "on" : "off"}</Text>
      </View>
    </View>
  );
}

mount(App);

In Rust the accessors are functions of nsh_core::ui::prelude; in TSX they come from the modules listed.

RustTSXValue
window()windowSize() (nsh/device)The surface’s width and height, in points.
dark()colorScheme() (nsh/device)Dark or light: the system’s, or the app’s own (set_color_scheme, setColorScheme).
font_scale()fontScale() (nsh/device)The user’s preferred text size relative to the default (1 is the default).
safe_area()safeArea() (nsh/base)Insets of the surface not covered by system UI: top, right, bottom, left.
cx.insets()safeAreaInsets() (nsh/base)The insets where the calling component renders (a page, a dialog).
keyboard_height(), keyboard_duration()keyboardFrame() (nsh/base)The software keyboard’s height and animation duration.
locale()locale() (nsh/device)The user’s locale as a BCP 47 tag ("en-US", "zh-Hans-CN"): what Intl formats for by default.
reduce_motion(), screen_reader()reduceMotion(), screenReaderEnabled() (nsh/accessibility)See Accessibility.
use nsh_core::ui::prelude::*;

#[component]
pub fn App() -> impl IntoElement {
    view()
        .flex_1()
        .pt(move || safe_area().top + px(16.0))
        .px(px(16.0))
        // A light and a dark value: the color follows the appearance natively.
        .bg(AdaptiveColor::rgb(0xffffff, 0x0d1117))
        .child(
            text(move || {
                let size = window();
                format!(
                    "{} × {} pt, text at {:.2}×",
                    size.width.0.round(),
                    size.height.0.round(),
                    font_scale()
                )
            })
            .text_color(AdaptiveColor::rgb(0x1f2328, 0xe6edf3)),
        )
}

#Window size and responsive layout

window() (TSX: windowSize()) is the size of the surface NSH renders into: the screen on a phone, the window on an iPad in split view or on a Mac, the host view when NSH is embedded. Use it to change the layout, not only sizes:

use nsh_core::ui::prelude::*;

fn gallery() -> impl IntoElement {
    let columns = || {
        let width = window().width.0;
        if width >= 700.0 { 4.0 } else if width >= 500.0 { 3.0 } else { 2.0 }
    };
    let side = move || px((window().width.0 - 32.0 - (columns() - 1.0) * 8.0) / columns());
    view().flex_row().flex_wrap().gap_2().child(each(
        photos,
        |p| p.id,
        move |p, _| image(move || p.with(|p| p.url.clone())).w(side).aspect_ratio(1.0),
    ))
}

Often flexbox, percentages and aspect_ratio (aspectRatio) do the job without reading the size at all; see Layout. Rust’s style_when(|| window().width.0 >= 640.0, |s| s.flex_row()) switches styles with the width; in TSX with Tailwind, the breakpoint variants (sm:, md:, lg:, xl:, 2xl:, max-*:, min-[…]:) and portrait: / landscape: follow the window too: class="grid grid-cols-2 md:grid-cols-4".

#Dark mode

The appearance follows the system unless the app sets its own. In Rust, the simplest way to support dark mode is a color with a light and a dark value, AdaptiveColor::rgb(light, dark): it switches natively when the appearance does, with nothing to run. In TSX it is the dark: Tailwind variant, which applies when colorScheme() is "dark":

view()
    .flex_1()
    .p_4()
    .bg(AdaptiveColor::rgb(0xffffff, 0x18181b))
    .child(
        text("Follows the system appearance")
            .text_color(AdaptiveColor::rgb(0x18181b, 0xf4f4f5)),
    )

For anything else, read dark() (TSX: colorScheme()) in a closure or a computed value; in TSX, for more than a few colors, derive a palette once:

use nsh_core::ui::prelude::*;

// `dark()` is reactive: the closure runs again when the appearance changes.
view()
    .flex_1()
    .bg(move || if dark() { rgb(0x0d1117) } else { rgb(0xf6f8fa) })
    .child(text("Hello"))

Rust components also take their colors from the theme, which has light and dark values (nsh.json "theme", see Building with Rust), so they switch with the system as well. To match the status bar to your background, give your pages and dialogs a statusBar, or the app one in nsh.json.

#An app with its own appearance

An app that is always dark (or always light) says so in nsh.json, and dark() and colorScheme(), adaptive colors, dark: classes, the theme, the status bar’s default style and alerts follow it from the first frame (packaged apps also launch in it):

{ "appearance": "dark" }

set_color_scheme(Appearance::Dark) (TSX: setColorScheme from nsh/device) changes it at run time, for an in-app appearance setting; Appearance::System ("system") follows the system again:

use nsh_core::ui::prelude::*;

view()
    .on_press(move || {
        set_color_scheme(if dark() { Appearance::Light } else { Appearance::Dark })
    })
    .role(AccessibilityRole::Button)
    .rounded_lg()
    .px_4()
    .py_2()
    .bg(AdaptiveColor::rgb(0xe5e5e5, 0x262626))
    .child(
        text(|| if dark() { "Light" } else { "Dark" })
            .text_color(AdaptiveColor::rgb(0x171717, 0xfafafa)),
    )

#Text size

font_scale() (TSX: fontScale()) is the user’s text size setting (Dynamic Type on iOS, font size on Android) relative to the default. Text and inputs follow it by themselves: fontSize: 16 renders larger when the user asked for larger text. Per element you can opt out or cap it:

Rust (on text and input)TSX (on <Text> and <Input>)DefaultDescription
.allow_font_scaling(..)allowFontScalingtrueFollow the system text size.
.max_font_size_multiplier(..)maxFontSizeMultiplier0 (none)The largest scale applied to this text.
text("Today")
    .text_size(px(34.0))
    .font_weight(FontWeight::BOLD)
    .max_font_size_multiplier(1.4)

Read font_scale() to adapt layout that does not follow text automatically, such as switching a row to a column when the text gets large: .style_when(|| font_scale() > 1.3, |s| s.flex_col()) in Rust, flexDirection: fontScale() > 1.3 ? "column" : "row" in TSX.

The text size scales text: every text and input that allows font scaling, and the platform’s alerts and toasts. Lengths keep their size: spacing, widths and heights, corner radii, borders and images, in points or in classes (p-4, rounded-lg, w-[2rem]). Boxes sized by their content grow with their text.

#Language and region

locale() is the user’s locale, as a BCP 47 tag (locale().as_str() in Rust). In TSX, Intl formats numbers and dates for it by default, so new Intl.NumberFormat().format(1234.5) gives 1,234.5 in en-US and 1.234,5 in de-DE. Read locale() to pick the strings of your own translations: the texts that read it update when it changes.

use nsh_core::ui::prelude::*;

fn greeting() -> &'static str {
    match locale().as_str().split('-').next() {
        Some("de") => "Willkommen",
        Some("zh") => "欢迎",
        _ => "Welcome",
    }
}

text(greeting)

#In an embedding app

An app shown inside a native app follows that app’s own settings when it sets them: the embedding APIs’ locale and text size (AppView::set_locale and set_font_scale in Rust on GPUI, macOS, Linux and Windows, locale and fontScale in Swift on iOS, setLocale and setFontScale in Java on Android) stand in for the system’s language and text size. locale(), Intl, fontScale() and text follow them at once, as they follow a system change, and the system’s come back when the embedding app clears them. Use them when the native app lets its users choose a language or a text size of its own.

#Safe areas

Notches, the home indicator, the status bar, Android’s navigation bar and the Mac Catalyst title bar cover parts of the surface. NSH apps draw edge to edge on every platform, and get the covered parts as insets.

  • safe_area() (TSX: safeArea()) gives the surface’s insets.
  • A component’s cx.insets() (TSX: safeAreaInsets()) gives the insets where the component renders: inside a navigation page they are measured below its header, inside a dialog they are the dialog’s. Prefer it in components that may be shown in several places.
  • safe_area_view() from nsh_base (TSX: SafeAreaView from nsh/base) is a view padded by those insets (all edges by default, or .edges(vec![Edge::Top, Edge::Bottom]), edges={["top", "bottom"]}), on top of its own padding.
use nsh_base::{Edge, safe_area_view};
use nsh_core::ui::prelude::*;

fn screen() -> impl IntoElement {
    safe_area_view()
        .edges(vec![Edge::Top, Edge::Left, Edge::Right])
        .flex_1()
        .bg(white())
        .child(content())
        .child(bottom_bar())
}

// A component, for its `Scope`: the insets where it renders.
#[component]
fn BottomBar(cx: Scope<NoEvent>) -> impl IntoElement {
    // `cx.insets()`: the insets where the component renders.
    view()
        .flex_row()
        .pt(px(8.0))
        .pb(move || cx.insets().bottom + px(8.0))
}

On Android, a view embedded below a toolbar gets only the parts of the system bars that overlap it, like UIKit’s safe area: no top inset if it does not reach the status bar.

#The platform

os(), os_version(), idiom() and is_desktop() in Rust, and the platform constant from nsh/device in TSX, describe where the app runs. They stay the same while the app runs.

RustTSXDescription
os()platform.osOs::Ios, Os::Android, Os::MacOs, Os::Windows, Os::Linux or Os::Web (os().name() is TSX’s string). Mac Catalyst is iOS.
os_version()platform.versionMajor and minor; on Android the API level and 0.
idiom()platform.idiom, platform.catalystIdiom::Phone, Pad, Mac, Tv or Desktop.
is_desktop()platform.idiom === "desktop"macOS, Windows or Linux, or a desktop browser.

TSX’s platform in full:

MemberDescription
platform.os"ios", "android", "macos", "windows", "linux" or "web". Mac Catalyst reports "ios".
platform.versionA string: iOS and desktops: the OS version ("17.5"); Android: the API level ("35"); the web: "0.0".
platform.idiom"phone", "pad", "mac", "tv" or "desktop". "desktop" is desktop behavior: macOS, Windows, Linux and desktop browsers.
platform.catalystMac Catalyst in the Mac idiom.
platform.select(spec)The value for this platform; see Platform-specific code.
use nsh_core::ui::{Os, prelude::*};

let haptics_supported = os() == Os::Ios || (os() == Os::Android && os_version().0 >= 30);

nsh test runs headless as iOS.

On Linux, platform.os is "linux", the idiom "desktop" and platform.version the kernel’s major and minor version ("6.12"): distributions number their releases in ways that cannot be compared. There, the safe area and the keyboard height are 0, colorScheme() follows the desktop’s setting (the XDG settings portal) live, fontScale() the desktop’s text scaling and pixelRatio() the display’s scale, fractional ones included.

On macOS (the AppKit backend), platform.os is "macos", the idiom "desktop" and platform.version the macOS version. The safe area and the keyboard height are 0, colorScheme() follows the system’s appearance live, fontScale() is 1 and pixelRatio() the display’s backing scale.

On Windows, platform.os is "windows", the idiom "desktop" and platform.version "10.0", on Windows 11 too (Windows kept the version number). The safe area and the keyboard height are 0, colorScheme() follows Settings > Personalization > Colors live, fontScale() the Text size setting (1 to 2.25), reduceMotion() the animation effects setting, pixelRatio() the monitor’s scale, and Intl formats in the user’s regional format.

In a browser (the web runtime), platform.os is "web" and platform.version "0.0". The idiom is "desktop" with a mouse and a window at least 600 points wide, "pad" on a tablet and "phone" on a phone, decided when the app starts. The locale, reduceMotion(), fontScale() (the browser’s text size), pixelRatio(), the safe area and the keyboard height come from the browser and follow it live.

#Pixel density

pixel_ratio() and round_to_nearest_pixel(points) in Rust, and pixelRatio() from nsh/device in TSX, convert between points and device pixels.

RustTSXDescription
pixel_ratio()pixelRatio()Device pixels per point (2 or 3 on most phones), reactive.
round_to_nearest_pixel(points)Math.round(x * pixelRatio()) / pixelRatio()Points rounded to the nearest device pixel.

A hairline, one device pixel tall:

view()
    .h(move || px(1.0 / pixel_ratio()))
    .bg(rgb(0xd0d7de))

pixel_ratio() and pixelRatio() are reactive, so a hairline follows a window moved to a screen with another density.

#Right to left

When the device language is written right to left (Arabic, Hebrew, Persian, …), NSH lays the app out right to left: rows run from the right, left and right style properties swap, text aligns to the right, and native widgets mirror. The layout direction follows the user’s language by itself. A view’s direction style ("ltr", "rtl" or "inherit"; .direction(LayoutDirection::..) in Rust) sets the direction of its subtree; on the root view it sets the app’s.

Changes apply at once, without restarting the app; the demo on this page toggles the direction style.

Write layouts that work both ways with the logical properties: in Rust ms / me and ps / pe (Tailwind’s names), in TSX marginStart / marginEnd, paddingStart / paddingEnd, start / end, borderTopStartRadius and so on, which mean left and right in left-to-right layouts and the reverse right to left. left and right themselves also swap. The direction style ("inherit", "ltr", "rtl") overrides the direction for a subtree, for example to keep a phone number or a media player’s controls left to right. The rtl: and ltr: Tailwind variants apply per direction.

Horizontal scroll views and lists start at the right end in right-to-left layouts. On Android, the host app must declare android:supportsRtl="true" in its manifest for native widgets to mirror.