Elements
Canvas
A canvas is a view you draw yourself with a 2D drawing API, the HTML Canvas 2D API’s: charts, gauges, rings,
signatures, custom shapes. The drawing code records operations; the platform replays them natively (Core Graphics on
iOS and Mac Catalyst, android.graphics.Canvas on Android) at the screen’s resolution. Rust makes one with
canvas(painter), a Painter recording the drawing; TSX with <Canvas> from nsh and its draw function.
import { Canvas, mount, signal, Text, View } from "nsh";
import { createAnimatedValue, Easing } from "nsh/animated";
import { platform, windowSize } from "nsh/device";
import { tint, ui } from "./palette";
const TAU = Math.PI * 2;
// Twelve months of made-up values, 0–100.
const values = [32, 41, 38, 52, 47, 63, 58, 71, 66, 80, 74, 88];
const months = "JFMAMJJASOND";
function Button(props: { label: string; onPress: () => void }) {
return (
<View
onPress={props.onPress}
activeStyle={{ opacity: 0.6 }}
style={{ ...ui().raised, paddingHorizontal: 14, paddingVertical: 9, borderRadius: 10, alignItems: "center" }}
>
<Text style={{ color: ui().text, fontWeight: "600" }}>{props.label}</Text>
</View>
);
}
function App() {
// The month under the finger or pointer: `draw` reads it, so moving redraws.
const picked = signal(values.length - 1);
const pick = (x: number, width: number) =>
picked.set(Math.max(0, Math.min(values.length - 1, Math.round(((x - 20) / (width - 40)) * (values.length - 1)))));
let chartWidth = 0;
// The dial's angle is a native animated value: it turns without running `draw`.
const angle = createAnimatedValue(0);
const turns = signal(0);
const spin = () => {
turns.update((n) => n + 1);
angle.timing(turns() * TAU, { duration: 900, easing: Easing.out(Easing.cubic) });
};
const wide = () => windowSize().width >= 700;
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>canvas</Text>
<Text style={{ color: ui().muted }}>
{platform.idiom === "desktop" ? "Drag across the chart with the mouse" : "Drag your finger across the chart"} · {months[picked()]}{" "}
= {values[picked()]}
</Text>
<View style={{ flex: 1, flexDirection: wide() ? "row" : "column", gap: 14 }}>
<Canvas
style={{ ...ui().inset, flex: 1, minHeight: 200, borderRadius: 14 }}
onLayout={(e) => (chartWidth = e.size.width)}
onStartShouldSetResponder={() => true}
onResponderGrant={(e) => {
pick(e.locationX, chartWidth);
return true;
}}
onResponderMove={(e) => pick(e.locationX, chartWidth)}
draw={(cx, { width, height }) => {
const pad = 20;
const x = (i: number) => pad + (i / (values.length - 1)) * (width - 2 * pad);
const y = (v: number) => height - pad - 16 - (v / 100) * (height - 2 * pad - 32);
const line = () => {
cx.beginPath();
values.forEach((v, i) => (i === 0 ? cx.moveTo(x(i), y(v)) : cx.lineTo(x(i), y(v))));
};
// Grid lines, dashed.
cx.strokeStyle = ui().border;
cx.lineWidth = 1;
cx.setLineDash([4, 4]);
for (const v of [25, 50, 75]) {
cx.beginPath();
cx.moveTo(pad, y(v));
cx.lineTo(width - pad, y(v));
cx.stroke();
}
cx.setLineDash([]);
// The area under the line: a path filled with a linear gradient.
const area = cx.createLinearGradient(0, pad, 0, height - pad);
area.addColorStop(0, tint(ui().accent, 0.32));
area.addColorStop(1, tint(ui().accent, 0));
line();
cx.lineTo(x(values.length - 1), y(0));
cx.lineTo(x(0), y(0));
cx.closePath();
cx.fillStyle = area;
cx.fill();
line();
cx.strokeStyle = ui().accent;
cx.lineWidth = 2.5;
cx.lineJoin = "round";
cx.stroke();
// Month labels: text.
cx.font = "11px system-ui";
cx.textAlign = "center";
cx.textBaseline = "alphabetic";
values.forEach((_, i) => {
cx.fillStyle = i === picked() ? ui().text : ui().faint;
cx.fillText(months[i], x(i), height - 10);
});
// The picked point, its guide and a bubble with its value.
const i = picked();
const px = x(i);
const py = y(values[i]);
cx.strokeStyle = tint(ui().text, 0.25);
cx.beginPath();
cx.moveTo(px, pad);
cx.lineTo(px, y(0));
cx.stroke();
cx.shadowColor = tint(ui().accent, 0.6);
cx.shadowBlur = 10;
cx.fillStyle = ui().accent;
cx.beginPath();
cx.arc(px, py, 6, 0, TAU);
cx.fill();
cx.shadowBlur = 0;
const label = String(values[i]);
cx.font = "600 13px system-ui";
const w = cx.measureText(label).width + 16;
const bx = Math.max(pad, Math.min(width - pad - w, px - w / 2));
cx.fillStyle = ui().text;
cx.beginPath();
cx.roundRect(bx, py - 36, w, 22, 6);
cx.fill();
cx.fillStyle = ui().page;
cx.textBaseline = "middle";
cx.fillText(label, bx + w / 2, py - 25);
}}
/>
<View style={{ ...ui().card, borderRadius: 14, padding: 16, gap: 12, alignItems: "center", width: wide() ? 240 : undefined, alignSelf: wide() ? "flex-start" : undefined }}>
<Canvas
style={{ width: 120, height: 120 }}
draw={(cx, { width, height }) => {
const r = width / 2 - 6;
const c = cx.createConicGradient(0, width / 2, height / 2);
c.addColorStop(0, ui().info);
c.addColorStop(0.5, ui().violet);
c.addColorStop(1, ui().info);
cx.fillStyle = c;
cx.beginPath();
cx.arc(width / 2, height / 2, r, 0, TAU);
cx.fill();
cx.translate(width / 2, height / 2);
cx.rotate(angle); // animates natively
cx.fillStyle = ui().page;
cx.beginPath();
cx.moveTo(0, -r + 10);
cx.lineTo(8, 0);
cx.lineTo(-8, 0);
cx.closePath();
cx.fill();
cx.beginPath();
cx.arc(0, 0, 10, 0, TAU);
cx.fill();
}}
/>
<Text style={{ color: ui().muted, textAlign: "center" }}>Turns: {turns()} (a native animated value)</Text>
<Button label="Spin" onPress={spin} />
</View>
</View>
</View>
);
}
mount(App);use std::f32::consts::{FRAC_PI_2, TAU};
use nsh_core::{canvas::LineCap, ui::prelude::*};
/// A progress ring, 120 points square.
fn ring(progress: f32) -> impl IntoElement {
let (center, radius) = (60.0, 50.0);
canvas(
Painter::new()
.line_width(12.0)
.line_cap(LineCap::Round)
.stroke_color(rgb(0xe5e7eb))
.begin_path()
.arc(center, center, radius, 0.0, TAU)
.stroke()
.stroke_color(rgb(0x6366f1))
.begin_path()
.arc(center, center, radius, -FRAC_PI_2, -FRAC_PI_2 + progress * TAU)
.stroke(),
)
.size(px(120.0))
}import { Canvas, mount } from "nsh";
function Ring(props: { progress: number }) {
return (
<Canvas
style={{ width: 120, height: 120 }}
draw={(cx, { width, height }) => {
const centerX = width / 2, cy = height / 2, r = width / 2 - 10;
cx.lineWidth = 12;
cx.lineCap = "round";
cx.strokeStyle = "#e5e7eb";
cx.beginPath();
cx.arc(centerX, cy, r, 0, Math.PI * 2);
cx.stroke();
cx.strokeStyle = "#6366f1";
cx.beginPath();
cx.arc(centerX, cy, r, -Math.PI / 2, -Math.PI / 2 + props.progress * Math.PI * 2);
cx.stroke();
}}
/>
);
}
mount(() => <Ring progress={0.7} />);In TSX, Canvas creates the native canvas view, measures it, and runs your draw function with a context and the
canvas’s size (see the Canvas API). In Rust, canvas(..) takes a Painter, or a closure
returning one: the closure draws again when what it reads changes, and the size comes from on_layout when the
drawing depends on it.
#Props
A canvas takes the props of a view (styles, press and gesture props, onLayout, accessibility, ref, …)
plus:
| Prop | Rust | Type | Description |
|---|---|---|---|
draw | canvas(painter) | (cx: CanvasRenderingContext2D, size: { width: number; height: number }) => void | Draws the canvas. Runs once the size is known, again when the canvas resizes, and again when signals it reads change. |
Give the canvas a size through its styles (width / height, flex, an aspect ratio): its size is the one its styles
give it.
#When it redraws
The drawing is tracked like an effect: reading a signal inside it subscribes the drawing to that signal.
let values = signal(vec![12.0_f32, 40.0, 28.0, 64.0]);
let width = signal(0.0_f32);
let height = 160.0;
canvas(move || {
let data = values.get(); // a change of values redraws
let step = width.get() / data.len() as f32;
data.iter().enumerate().fold(Painter::new().fill_color(rgb(0x0969da)), |p, (i, v)| {
p.fill_rect(i as f32 * step + 4.0, height - v, step - 8.0, *v)
})
})
.h(px(height))
.on_layout(move |frame: &Bounds<Points>| width.set(frame.size.width.0))const values = signal([12, 40, 28, 64]);
<Canvas
style={{ height: 160 }}
draw={(cx, { width, height }) => {
const data = values(); // a change of values() redraws
const step = width / data.length;
cx.fillStyle = "#0969da";
data.forEach((v, i) => cx.fillRect(i * step + 4, height - v, step - 8, v));
}}
/>;Numbers and colors may also be native animated values (CanvasNumber, CanvasPaint;
in Rust a &AnimatedValue wherever a number goes): the platform then redraws on its own while they animate, with no
work in the app’s code per frame.
use std::f32::consts::TAU;
let angle = animated_value(0.0);
angle.timing(TAU, 1000.0, Easing::Linear);
canvas(
Painter::new()
.translate(40.0, 40.0)
.rotate(&angle) // animates natively
.fill_color(rgb(0xcf222e))
.fill_rect(-20.0, -4.0, 40.0, 8.0),
)
.size(px(80.0))import { Canvas } from "nsh";
import { Animated, createAnimatedValue } from "nsh/animated";
const angle = createAnimatedValue(0);
Animated.timing(angle, { toValue: Math.PI * 2, duration: 1000 }).start();
<Canvas
style={{ width: 80, height: 80 }}
draw={(cx) => {
cx.translate(40, 40);
cx.rotate(angle); // animates natively
cx.fillStyle = "#cf222e";
cx.fillRect(-20, -4, 40, 8);
}}
/>;#The context
CanvasRenderingContext2D is a subset of the browser’s, and Painter has the same operations as methods in snake case
(move_to, line_to, fill_rect, fill_text, stroke_color, …): the state stack (save, restore), transforms,
paths (moveTo, lineTo, arc, arcTo, ellipse, rect, roundRect, Bézier curves), fill / stroke / clip
(also of a Path2D, which accepts SVG path data; fill_svg / stroke_svg in Rust), rectangles, text (fillText,
strokeText, measureText), linear / radial / conic gradients, line dashes, shadows, globalAlpha,
globalCompositeOperation and drawImage (images by URL or asset). Every member is listed in the
Canvas API reference; the canvas guide walks through drawing
charts and animated graphics.
Coordinates are in points with the origin at the top left. measureText is synchronous and returns width. Rust’s
Painter answers hit tests too (is_point_in_path, is_point_in_stroke).
The context draws vector operations and images: pixel access (getImageData / putImageData), filter and SVG
images are outside it.
#Events
The view events: onPress, onLayout, gestures and touches. To handle drawing-relative taps, read
locationX / locationY in onTouchEnd, or track a drag with onResponderMove (.on_responder_move(..) in Rust,
with e.location).
#Ref methods
None of its own.