Drawing and Layout #

Everything a Budo app shows is drawn on one canvas, sys.canvas, rendered by Skia on every platform. The model is the one of creative-coding and native canvas libraries: set some state (a color, a stroke width, a transform), draw, change the state, draw again. Three helpers sit next to it: sys.path for vector shapes, sys.svg for SVG files, and sys.font for typefaces.

This page starts with shapes, colors, and text, which cover most apps, then moves to transforms, clipping, paths, gradients, layers, density, and pixel readback.

Canvas basics #

Each frame, clear the canvas, draw, and ask for the next frame.

main.js
// Draw one frame of the scene.
function frame() {
  // Clear the previous frame with a dark background.
  sys.canvas.clear('#111318');

  // Select a fill color for the rectangle.
  sys.canvas.setFillColor('#f4efe5');
  // Draw a filled rectangle at x=40, y=40.
  sys.canvas.drawRect(40, 40, 180, 100);

  // Select a stroke color for the circle.
  sys.canvas.setStrokeColor('#7c6ff7');
  // Choose the stroke width in canvas pixels.
  sys.canvas.setStrokeWidth(4);
  // Draw a stroked circle over the rectangle.
  sys.canvas.drawCircle(130, 90, 54);

  // Request the next frame so the app keeps rendering.
  sys.animation.requestFrame(frame);
}

// Start the frame loop.
sys.animation.requestFrame(frame);

Coordinates are physical canvas pixels with the origin at the top-left corner. Rectangle-like calls (drawRect, drawRoundRect, drawOval) take x, y, width, and height; circles take a center and a radius; drawLine takes two points and drawPoint one. drawArc(x, y, width, height, startAngle, sweepAngle, useCenter) takes angles in degrees, and useCenter: true draws a pie wedge. clear() with no color clears to white.

Colors and paint #

The canvas keeps one current paint: a color, a style (fill or stroke), a stroke width, cap and join, an alpha, and anti-aliasing. setFillColor sets the color and switches the following shapes to fill; setStrokeColor sets the color and switches them to stroke.

Colors are hex strings, #RRGGBB or #RRGGBBAA (the last pair is the opacity), or numbers in ARGB order such as 0xffff0000 for opaque red. A string in any other form, including CSS names such as 'transparent', reads as opaque black; use '#00000000' for transparent.

setAlpha(alpha) fades the following draws, from 0 (invisible) to 255 (opaque). Setting a color replaces the alpha with the color's own, so call setAlpha after choosing the color.

main.js
// Smooth the edges of curves and diagonals.
sys.canvas.setAntiAlias(true);
// Fill with a warm yellow, then fade it slightly.
sys.canvas.setFillColor('#ffcc66');
sys.canvas.setAlpha(220);
// Draw a rounded rectangle with 10-pixel corner radii.
sys.canvas.drawRoundRect(24, 24, 160, 64, 10, 10);

// A new color is opaque again: no need to reset the alpha.
sys.canvas.setStrokeColor('#101116');
// Use rounded line ends and a visible width.
sys.canvas.setStrokeCap('round');
sys.canvas.setStrokeWidth(3);
// Draw a line across the rounded rectangle.
sys.canvas.drawLine(32, 92, 176, 32);
// A translucent color carries its own opacity.
sys.canvas.setFillColor('#7c6ff780');
sys.canvas.drawCircle(200, 60, 36);

Stroke caps are 'butt', 'round', and 'square'; joins are 'miter', 'round', and 'bevel'. For effects, setBlendMode selects how shapes composite ('multiply', 'screen', 'plus', and the other Skia modes), setImageFilter adds a blur or a drop shadow, and setColorFilter applies a color matrix; the API reference lists their arguments.

Text and fonts #

drawText(text, x, y, fontSize) draws one line whose baseline sits at y, and returns the width it drew. The default size is 32. To lay text out before drawing it, measureText(text, fontSize) returns its width and measureTextRect(text, fontSize) returns { width, height }.

main.js
// Measure the label at 18 pixels.
const box = sys.canvas.measureTextRect('Ready', 18);
// Draw a pill 24 pixels wider and 18 pixels taller than the text.
sys.canvas.setFillColor('#7c6ff7');
sys.canvas.drawRoundRect(24, 24, box.width + 24, box.height + 18, 8, 8);
// Draw the label inside the pill; y is the baseline.
sys.canvas.setFillColor('#ffffff');
sys.canvas.drawText('Ready', 36, 24 + box.height + 4, 18);

// Center a title horizontally in the window.
const title = 'Centered';
const width = sys.canvas.measureText(title, 24);
sys.canvas.drawText(title, (sys.window.getWidth() - width) / 2, 120, 24);

Text takes the current paint, so setFillColor picks its color. The runtime ships a default font. To use your own, load a font file from the project and select it by the name you gave it:

main.js
// Load a font file from the project directory and name it Inter.
sys.font.load('assets/Inter-Regular.ttf', 'Inter');
// Make Inter the active font for the following text.
sys.canvas.setFont('Inter');
// Draw text with the loaded font.
sys.canvas.setFillColor('#f8fafc');
sys.canvas.drawText('Loaded font', 32, 64, 26);
// Return to the runtime's default font.
sys.canvas.setFont(null);

sys.font.loadFromBuffer(buffer, name) registers a font from bytes you already hold, such as a download. In Lua, setFont(nil) returns to the default font.

Paragraphs #

drawParagraph(text, x, y, width, fontSize, options) wraps text to width. Unlike drawText, y is the top of the paragraph. Lines break at spaces and at \n, and a word wider than a line breaks between characters. options takes align ('left', 'center', or 'right'), lineHeight (a multiple of the font size, 1.25 by default), and maxLines (the last line then ends with an ellipsis).

drawParagraph returns { width, height, lines }, and measureParagraph(text, width, fontSize, options) returns the same metrics without drawing, so you can size a container first.

main.js
const text = 'Paragraphs wrap to their width, break words that are too long, ...';
// Measure first to size the card around the text.
const m = sys.canvas.measureParagraph(text, 260, 17, { maxLines: 3 });
sys.canvas.setFillColor('#e2e8f0');
sys.canvas.drawRoundRect(16, 16, 292, m.height + 32, 12, 12);
// Draw the text with a 16-pixel margin inside the card; y is the top.
sys.canvas.setFillColor('#101116');
sys.canvas.drawParagraph(text, 32, 32, 260, 17, { maxLines: 3 });

Rich text #

drawRichText(spans, x, y, width, options) lays out one paragraph from up to 64 spans. A span is a plain string or a { text, size, color, font } object, where font names a font registered with sys.font.load. Each line is as tall as its largest span, and baselines line up. options takes the paragraph options plus size, the size of spans that set none (32 by default). Spans without a color use the current paint. measureRichText(spans, width, options) measures without drawing.

main.js
// The color of spans that do not set one.
sys.canvas.setFillColor('#334155');
// One paragraph, 420 pixels wide, mixing sizes and colors.
sys.canvas.drawRichText([
  { text: 'Rich text ', size: 26, color: '#7c6ff7' },
  'mixes sizes and colors: ',
  { text: 'warnings stand out', color: '#dc2626' },
  ', and lines grow to fit their ',
  { text: 'largest', size: 30 },
  ' span.',
], 24, 24, 420, { size: 17 });

Lua uses the same calls, with tables for spans and options. WebAssembly passes text and spans through linear memory; see the WebAssembly section of the API reference.

Transforms #

translate, rotate (in degrees), scale, and skew change the coordinate system of every draw that follows. Wrap them in save() and restore() to keep the change local; reset() returns to the identity transform.

main.js
// Save the current transform so the following changes stay local.
sys.canvas.save();
// Move the origin to the center of the shape, then rotate and scale.
sys.canvas.translate(200, 140);
sys.canvas.rotate(30);
sys.canvas.scale(1.2, 1.2);
// Draw around the new origin.
sys.canvas.setFillColor('#7c6ff7');
sys.canvas.drawRect(-40, -30, 80, 60);
// Return to the transform saved above.
sys.canvas.restore();

To rotate around a point without translating first, pass the point:

main.js
// Rotate the following draws by 15 degrees around (centerX, centerY).
sys.canvas.rotate(15, centerX, centerY);

save() and restore() cover the transform and the clip, not the paint: colors and stroke settings stay as you last set them.

Clipping #

A clip restricts drawing to a region until the restore() that matches the save() before it. clipRect keeps drawing inside a rectangle, clipRoundRect(x, y, width, height, radiusX, radiusY) inside a rounded rectangle (radiusY defaults to radiusX), and clipPath inside any path from sys.path. Rounded and path clips have anti-aliased edges.

main.js
sys.canvas.save();
// Everything drawn until restore() stays inside a rounded rectangle.
sys.canvas.clipRoundRect(24, 24, 220, 120, 28);
for (let i = 0; i < 12; i++) {
  // Alternate two colors.
  sys.canvas.setFillColor(i % 2 ? '#7c6ff7' : '#2dd4bf');
  // These stripes are taller than the clip: their ends are cut off.
  sys.canvas.drawRect(i * 24, 0, 24, 200);
}
// Remove the clip.
sys.canvas.restore();

Paths #

A path is an integer handle to a shape you build from segments: moveTo, lineTo, quadTo, cubicTo, close, and the addRect and addCircle shortcuts. Build it once and draw it with sys.canvas.drawPath as often as you need; it takes the current paint and transform.

main.js
// Create a path handle once and keep it for reuse.
const badge = sys.path.create();
// Start the triangle at its top point.
sys.path.moveTo(badge, 0, -42);
// Add the lower-right and lower-left corners.
sys.path.lineTo(badge, 38, 24);
sys.path.lineTo(badge, -38, 24);
// Close the shape back to the top point.
sys.path.close(badge);

// Draw the badge path at a chosen position.
function drawBadge(x, y, color) {
  // Keep the translation local to this helper.
  sys.canvas.save();
  // Move the path origin to the requested position.
  sys.canvas.translate(x, y);
  // Draw the path with the current paint.
  sys.canvas.setFillColor(color);
  sys.canvas.drawPath(badge);
  // Restore the previous transform.
  sys.canvas.restore();
}

Paths are not destroyed one by one, so create them once rather than every frame. When a shape changes, sys.path.reset(path) empties the handle and you rebuild its segments into it.

SVG path data #

sys.path.addSvg(path, d) appends SVG path data, the d attribute of an SVG <path> element, with every command (M L H V C S Q T A Z, absolute or relative). It returns false and leaves the path unchanged when the data does not parse.

A path takes the current paint, unlike an SVG file drawn with sys.svg, which keeps its own colors. That makes path data the right format for icons that follow a theme. Draw them at any size with translate and scale:

main.js
// A heart drawn on a 24-unit grid, like most icon sets.
const heart = sys.path.create();
sys.path.addSvg(heart, 'M12 20s-7.5-4.6-7.5-10.2A4.3 4.3 0 0 1 12 7a4.3 4.3 0 0 1 7.5 2.8C19.5 15.4 12 20 12 20z');

sys.canvas.save();
// Place the icon, then scale 24 units to 144 pixels; the stroke scales too.
sys.canvas.translate(72, 24);
sys.canvas.scale(6, 6);
// Stroke it with rounded corners.
sys.canvas.setStrokeColor('#f472b6');
sys.canvas.setStrokeWidth(2);
sys.canvas.setStrokeJoin('round');
sys.canvas.drawPath(heart);
sys.canvas.restore();

SVG files #

sys.svg.load(path) loads an SVG file from the project and returns a handle, or -1 when the file cannot be loaded. sys.canvas.drawSvg(svg, x, y, width, height) draws it scaled into a rectangle, in the colors the file defines.

main.js
// Load an SVG file from the project directory.
const logo = sys.svg.load('assets/logo.svg');

// Draw the SVG every frame after the canvas is cleared.
function frame() {
  // Clear to white so transparent SVG content is visible.
  sys.canvas.clear('#ffffff');
  // A non-negative handle means the SVG loaded.
  if (logo >= 0) {
    // Draw the SVG scaled into a 96 by 96 rectangle.
    sys.canvas.drawSvg(logo, 24, 24, 96, 96);
  }
  // Keep rendering.
  sys.animation.requestFrame(frame);
}

// Start the frame loop.
sys.animation.requestFrame(frame);

sys.svg.getWidth(svg) and getHeight(svg) return the file's intrinsic size, useful to keep its aspect ratio. sys.svg.loadFromBuffer(bytes) loads SVG data you already hold, and sys.svg.destroy(svg) frees a handle you no longer need.

Gradients #

setGradient fills and strokes with a gradient instead of the paint color. It has three forms: 'linear' between two points, 'radial' around a center with a radius, and 'sweep' (conic) around a center, starting at the positive x axis. Colors blend in OKLab, so transitions stay even and fading to a transparent stop does not turn gray.

main.js
// A fill color selects fill style; the gradient then replaces the color.
sys.canvas.setFillColor('#000000');
// Linear: from (24, 24) to (264, 104), through three colors.
sys.canvas.setGradient('linear', 24, 24, 264, 104, ['#2dd4bf', '#7c6ff7', '#f472b6']);
sys.canvas.drawRoundRect(24, 24, 240, 80, 16, 16);
// Radial: a soft glow fading to transparent.
sys.canvas.setGradient('radial', 340, 64, 48, ['#fde68a', '#fde68a00']);
sys.canvas.drawCircle(340, 64, 48);
// Sweep: colors around the center.
sys.canvas.setGradient('sweep', 460, 64, ['#f87171', '#facc15', '#4ade80', '#60a5fa', '#f87171']);
sys.canvas.drawCircle(460, 64, 40);
// Back to plain colors.
sys.canvas.setGradient(null);

colors holds 2 to 16 colors. An optional last argument, stops, holds one position from 0 to 1 per color; without it, colors are evenly spaced. The gradient applies to shapes and text, makes the paint opaque (call setAlpha afterwards to fade it), and lasts until setGradient(null) or the next setFillColor or setStrokeColor.

Layers and frosted glass #

saveLayer(alpha) draws what follows into an offscreen layer, and the matching restore() composites it back with one opacity from 0 to 255. setAlpha fades each shape on its own, so overlapping shapes show through each other; a layer fades the group as one image. Use a layer to fade a card, a menu, or a page.

main.js
// Fading each shape: the circle shows the card through it.
sys.canvas.setFillColor('#7c6ff7');
sys.canvas.setAlpha(128);
sys.canvas.drawRoundRect(24, 30, 140, 90, 14, 14);
sys.canvas.setFillColor('#2dd4bf');
sys.canvas.setAlpha(128);
sys.canvas.drawCircle(134, 75, 40);

// Fading the group: the same drawing, faded as one image.
sys.canvas.saveLayer(128);
// card(x) draws the same rounded rectangle and circle at x.
card(220);
sys.canvas.restore();

The full form is saveLayer(alpha, x, y, width, height, backdropBlur). The rectangle bounds and clips the layer, and backdropBlur starts the layer with a blurred copy of what is already drawn below it. Clip to a rounded rectangle first to get a frosted panel:

main.js
// Clip to the panel's rounded shape.
sys.canvas.save();
sys.canvas.clipRoundRect(x, y, width, height, 20);
// Start with a blurred copy of the background, then tint it.
sys.canvas.saveLayer(255, x, y, width, height, 14);
sys.canvas.setFillColor('#ffffff40');
sys.canvas.drawRect(x, y, width, height);
// Composite the layer, then remove the clip.
sys.canvas.restore();
sys.canvas.restore();

Each layer costs an offscreen pass. Use layers for panels, menus, and transitions, not for every row of a list.

Density-aware layout #

The canvas, the pointer, and sys.window.getWidth() and getHeight() all count physical pixels, so a 16-pixel margin looks half as large on a high-density screen. sys.window.getDisplayDensity() returns how many physical pixels make one logical pixel: 1 on a standard desktop monitor, 2 on a Retina display, the device density on Android, and devicePixelRatio in the browser.

Multiply the sizes you choose (margins, radii, font sizes, hit targets) by the density so the interface keeps its physical size across devices. Do not multiply values the runtime gives you, such as the window size or pointer position: they are already in physical pixels.

main.js
// Read the display density once per frame.
const density = sys.window.getDisplayDensity();
// Scale the authored sizes by density.
const margin = 16 * density;
const radius = 10 * density;
const fontSize = 18 * density;
// Draw a button background.
sys.canvas.setFillColor('#7c6ff7');
sys.canvas.drawRoundRect(margin, margin, 220 * density, 56 * density, radius, radius);
// Draw the button label.
sys.canvas.setFillColor('#ffffff');
sys.canvas.drawText('Start', margin + 18 * density, margin + 36 * density, fontSize);

Games and simulations often do better with their own logical coordinate system, scaled once with sys.canvas.scale to fit the window.

Reading pixels #

sys.canvas.readPixels() copies the canvas into memory and returns { width, height, pixels }, where pixels is an ArrayBuffer of premultiplied RGBA bytes, row by row from the top.

main.js
// Clear the canvas to a known color before the readback.
sys.canvas.clear('#ffffff');
// Draw a small red square into the top-left corner.
sys.canvas.setFillColor('#ff0000');
sys.canvas.drawRect(0, 0, 20, 20);

// Read the current canvas pixels back into memory.
const shot = sys.canvas.readPixels();
// Wrap the ArrayBuffer to read individual bytes.
const bytes = new Uint8Array(shot.pixels);
// Log the image size and the first pixel: 255 0 0 255 (opaque red).
console.log(shot.width, shot.height, bytes[0], bytes[1], bytes[2], bytes[3]);

Readback waits for the GPU to finish all pending drawing. It suits screenshots, tests, and one-off analysis; avoid it in every frame of an animation.

Building interfaces #

The calls on this page are enough for games, visualizations, and simple tools. For application interfaces, the budo-ui toolkit builds on them: buttons, fields, lists, menus, dialogs, and navigation that animate, follow the system settings, and work with the keyboard and screen readers. See Building interfaces.