Elements

Text

Text draws a paragraph. It is the element that shows strings: a string child of a View draws nothing. The paragraph is typeset natively (CoreText on iOS and Mac Catalyst, StaticLayout on Android) and measured on the engine thread, so the main thread only draws it. Rust makes one with text(content), TSX with <Text> from nsh.

el-text.tsx
el-text.tsxLive
import { mount, signal, Text, View } from "nsh";
import { button, buttonText, focusRing, ui } from "./palette";

const LONG =
  "Text is typeset natively (CoreText on iOS, StaticLayout on Android) and measured on the engine thread, so the main thread only draws it.";

function App() {
  const tapped = signal("nothing yet");
  const lines = signal(2);
  const link = () => ({ color: ui().accent, textDecorationLine: "underline" }) as const;
  return (
    <View style={{ flex: 1, backgroundColor: ui().page }}>
      <View style={{ flex: 1, width: "100%", maxWidth: 560, alignSelf: "center", padding: 20, paddingTop: 24, gap: 14 }}>
        <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>text</Text>

        <Text style={{ fontSize: 16, lineHeight: 24, color: ui().text }}>
          {"Nested text elements are spans: "}
          <Text style={{ fontWeight: "700" }}>bold</Text>
          {", "}
          <Text style={{ fontStyle: "italic", color: ui().violet }}>italic</Text>
          {", and "}
          <Text style={link()} onPress={() => tapped.set("the first link")} hoverStyle={{ opacity: 0.88 }} focusStyle={focusRing()} activeStyle={{ opacity: 0.7 }}>
            links
          </Text>
          {" that take presses of "}
          <Text style={link()} onPress={() => tapped.set("the second link")} hoverStyle={{ opacity: 0.88 }} focusStyle={focusRing()} activeStyle={{ opacity: 0.7 }}>
            their own
          </Text>
          .
        </Text>
        <Text style={{ fontSize: 13, color: ui().muted }}>Pressed: {tapped()}</Text>

        <View style={{ height: 1, backgroundColor: ui().line }} />

        <Text style={{ numberOfLines: lines(), fontSize: 15, color: ui().text }}>
          {LONG}
        </Text>
        <View style={{ flexDirection: "row", gap: 8 }}>
          <View
            onPress={() => lines.set(lines() === 2 ? 0 : 2)}
            {...button()}
          >
            <Text style={buttonText()}>{lines() === 2 ? "Show all" : "numberOfLines 2"}</Text>
          </View>
        </View>

        <Text style={{ numberOfLines: 1, textOverflow: "ellipsis-middle", fontSize: 15, color: ui().muted }}>
          {LONG}
        </Text>

        <Text style={{ fontSize: 17, color: ui().text }}>
          <Text style={{ textDecorationLine: "line-through", textDecorationColor: ui().down }}>struck</Text>{" "}
          <Text style={{ textTransform: "uppercase", letterSpacing: 2 }}>spaced</Text>{" "}
          <Text style={{ fontVariant: ["tabular-nums"] }}>0123456789</Text>
        </Text>

        <Text
          style={{
            fontSize: 28,
            fontWeight: "800",
            color: ui().onAccent,
            backgroundColor: "#c8f26d",
            paddingHorizontal: 10,
            borderRadius: 8,
            textShadow: "2px 2px 4px rgba(255,255,255,0.55)",
          }}
        >
          Text shadow
        </Text>
      </View>
    </View>
  );
}

mount(App);
use nsh_core::prelude::*;

text("Read the ")
    .text_base()
    .line_height(px(24.0))
    .text_color(rgb(0x1f2328))
    .child(text("terms").font_weight(FontWeight::BOLD))
    .child(" before you continue.")

text(..) takes a string, a SharedString, a signal or a closure (text(move || format!("{} items", count.get()))): a closure or a signal updates the text in place when what it reads changes.

#Spans

A text inside a text is a span of the same paragraph, not a separate view. Spans inherit the text styles of the paragraph (color, fontSize, fontWeight, …) and override what they set. The whole block is one native view. In Rust, text(..) shows its content and then its children: .child(text(..)) adds a span, .child("…") a plain run.

text("")
    .text_size(px(17.0))
    .child(text("Light ").font_weight(FontWeight::LIGHT))
    .child(text("Bold ").font_weight(FontWeight::BOLD))
    .child(text("Italic").italic().text_color(rgb(0x8250df)))

#Span backgrounds

A span with a background fills the box of its characters, behind them: as tall as its font (the ascent above the baseline and the descent below it, whatever the line height), and on every line it wraps to, each line’s part a box of its own with the span’s corner radius. Spans inside it share its background unless they set their own. It is drawing only: the text lays out as without it, and a selectable text copies the span’s characters with the rest. Inline code is a span:

text("Run ")
    .text_size(px(15.0))
    .line_height(px(22.0))
    .select_text()
    .child(
        text(" cargo test ")
            .font_family("ui-monospace")
            .text_size(px(13.0))
            .bg(rgb(0xeff1f3))
            .rounded(px(4.0)),
    )
    .child(" before you commit.")

The Tailwind classes work the same way: <Text class="bg-gray-100 rounded">. Padding and borders are box styles and leave spans as they are; spaces inside the span give the box room on its sides.

A span with a press handler is a link: a press on its characters goes to the span, a press elsewhere to the paragraph. In a terminal each link is also a stop of the keyboard focus: Tab reaches the links of a paragraph in turn, and Return presses the focused one.

text("By continuing you agree to the ")
    .text_size(px(15.0))
    .on_press(move || log("the sentence"))
    .child(
        text("Terms of service")
            .text_color(rgb(0x0969da))
            .underline()
            .on_press(move || open("terms")),
    )
    .child(".")

#Inline views and images

Any other element inside a text (a view, an image, even inside spans) is an inline attachment: it is laid out at its own size, sits on the baseline (or where its verticalAlign style puts it: "text-top", "text-bottom", "middle", as in CSS; the line grows to hold it) and wraps with the words. It takes presses of its own.

text("Made with ")
    .text_base()
    .line_height(px(26.0))
    .child(image(LOGO).size(px(18.0)).rounded(px(4.0)))
    .child(" NSH. Status ")
    .child(
        view()
            .bg(rgb(0x1a7f37))
            .rounded(px(9.0))
            .px(px(7.0))
            .h(px(18.0))
            .justify_center()
            .child(text("LIVE").text_color(white()).text_size(px(11.0)).font_weight(FontWeight::BOLD)),
    )

Attachments cut off by numberOfLines or an ellipsis are hidden. Copying a selectable text leaves them out.

#Whitespace

JSX text follows React’s rules: lines are trimmed except at the text’s ends, whitespace-only lines are dropped and the remaining lines are joined with one space. Spaces on one line are kept ({a} {b} keeps its space). Write {" "} or a string expression when a span needs a leading or trailing space. Rust strings are drawn as written.

#Props

Besides the common props (with style typed as TextStyle):

PropRustTypeDefaultDescription
numberOfLines (style).number_of_lines(n), .truncate()number0The most lines shown; 0 shows them all.
textOverflow (style).text_overflow(EllipsizeMode::Middle)"ellipsis" | "clip" | "ellipsis-start" | "ellipsis-middle""ellipsis"How text that does not fit numberOfLines is cut: an ellipsis at its end, at its start or in its middle, or clipped.
allowFontScaling.allow_font_scaling(..)booleantrueFollow the system text size setting.
maxFontSizeMultiplier.max_font_size_multiplier(..)number0The upper bound of the system scale; 0 is none.
adjustsFontSizeToFit.shrink_to_fit(min_scale)booleanfalseShrink the font until the text fits its lines (numberOfLines) and its box.
minimumFontScale.shrink_to_fit(min_scale)numberHow far adjustsFontSizeToFit shrinks at most (0..1; at least 0.1).
userSelect (style).select_text(), .select_none()"auto" | "none" | "text""auto""text": the user can select ranges of the text: a long press selects a word, handles extend it, the edit menu copies it. "auto" takes the setting of the views around the text; with none set, text is not selectable, except in desktop browsers, where it is unless it is inside an interactive element; "none" keeps it unselectable there too.
selectionColor (style).selection_color(..)ColorValuethe platform’s accentThe selection’s highlight and handles.
onTextLayout(e: { lines: TextLayoutLine[] }) => voidThe laid-out lines after each layout.

The Tailwind classes select-text, select-none and select-auto set userSelect, line-clamp-* sets numberOfLines, text-ellipsis and text-clip set textOverflow, and truncate is one line cut with an ellipsis. The selection’s highlight and handles take the style key selectionColor (selection:text-*), the platform’s accent by default.

#Text styles

TextStyle adds to the view styles: color, fontSize, fontWeight ("normal", "bold" or 100–900), fontStyle, fontFamily, lineHeight, letterSpacing, textAlign, textAlignVertical, textTransform, textDecorationLine, textDecorationColor, textDecorationStyle, textDecorationThickness, textUnderlineOffset, textShadow (CSS’s text-shadow), fontVariant, writingDirection, numberOfLines and textOverflow. In Rust they are Styled methods: .text_color(..), .text_size(..) (or .text_sm(), .text_lg(), …), .font_weight(FontWeight::SEMIBOLD), .italic(), .font_family(..), .line_height(..), .letter_spacing(..), .text_center(), .uppercase(), .underline(), .line_through(), .tabular_nums(), .number_of_lines(..) and the rest. See the style reference and text.

The font family is a system family or a font file of the app:

// assets/fonts/Inter-Regular.ttf, shipped by the registry (`registry.assets(assets!())`).
const INTER: FontAsset = asset!("fonts/Inter-Regular.ttf");

text("Inter").font_family(INTER.family).text_size(px(17.0))

The font is registered before the first frame. See fonts.

#Events

EventRustPayloadWhen
onTextLayout{ lines: TextLayoutLine[] }After each layout of the paragraph.
onPress, onLongPress, ….on_press, .on_long_press, …PressEvent (none in Rust)As on view; on a span, presses on its characters.
onLayout.on_layout{ bounds }The block’s frame changed.

TextLayoutLine is { text, x, y, width, height, ascender, descender, capHeight, xHeight }, in the text’s content box:

const lines = signal(0);
<Text onTextLayout={(e) => lines.set(e.lines.length)}>{article}</Text>;

Rust measures a string before it lays out with measure_text(..).

#Ref methods

None of its own.

#Platform notes

  • Selection stays inside one text block (one text and its spans). iOS shows its edit menu (Copy, Select All, Look Up, Translate, Share), Android a floating toolbar (Copy, Select all, Share).
  • Text decorations other than a solid single line (double, dotted, dashed, wavy) are drawn by NSH on iOS.
  • iOS texts draw solid backgrounds and outer shadows.
  • fontScale() from nsh (font_scale() in Rust) reports the current system text scale.