MyGo
Drawing and animation

Guides

Drawing and animation

Painting on elements, shaping text, and values that ease to their targets.

Draw paints on an element after its background, and DrawOver after its children, with a *ui.Painter in DIPs of the window: rectangles with Fill, FillGradient, FillConicGradient and FillRadialGradient, Stroke and StrokeDashed, Shadow, Line (horizontal or vertical), Image, Clip and ClipPath, paths of lines and curves with FillPath and StrokePath, or in a gradient with FillPathGradient and StrokePathGradient, and text with Text, or RichText in spans of any style, which MeasureText measures first, to center or align it:

ui.Box(c).Height(120).Draw(func(p *ui.Painter, r ui.Rect) {
	var wave ui.Path
	for i := 0; i <= 100; i++ {
		x := r.X + r.W*float32(i)/100
		y := r.Y + r.H/2 + 40*float32(math.Sin(float64(i)/8))
		if i == 0 {
			wave.MoveTo(x, y)
		} else {
			wave.LineTo(x, y)
		}
	}
	p.StrokePath(&wave, 2, c.Theme().Accent)
})
ui.Box(c).Size(200, 40).Draw(func(p *ui.Painter, r ui.Rect) {
	label := ui.Span{Text: "42%", Weight: 600, Color: c.Theme().TextMuted}
	w, h := p.MeasureText(0, label)
	p.RichText(r.X+(r.W-w)/2, r.Y+(r.H-h)/2, 0, label)
})

Paragraphs a view lays out itself, as the pages of a book reader, are shaped once with ui.ShapeRichTextWith, justified and indented if need be, and drawn with Painter.TextLayout; their Lines tell where each line is, to break pages between them (see retained text geometry).

Draw functions only paint: MyGo may call them more than once a frame. Painter.Icon and Painter.Image draw SVGs and bitmaps, Painter.ImageRegion parts of bitmaps (see Parts of bitmaps), and Painter.FocusRing the ring of the keyboard focus, as the theme draws it (see the focus ring), for widgets that draw their own.

Path.Trim(from, to) keeps the part of a path's length from from to to, as fractions of it, which draws a path in as to animates from 0 to 1, as SwiftUI's trim and Motion's pathLength:

drawn := float32(0)
if app.done {
	drawn = 1
}
drawn = box.AnimateTo("check", drawn, ui.Smooth)
box.Draw(func(p *ui.Painter, r ui.Rect) {
	check := (&ui.Path{}).MoveTo(r.X+4, r.Y+12).LineTo(r.X+10, r.Y+18).LineTo(r.X+20, r.Y+6)
	p.StrokePath(check.Trim(0, drawn), 2, c.Theme().Accent)
})

A stroke has round ends and corners unless its path sets them, as SVG's stroke-linecap, stroke-linejoin and stroke-miterlimit do: Path.Cap ends it flat (ui.ButtCap) or square (ui.SquareCap), and Path.Join turns its corners in points (ui.MiterJoin, bevelled past Path.MiterLimit) or cuts them (ui.BevelJoin). Path.Dash strokes it in dashes, as stroke-dasharray and stroke-dashoffset do, and they stay where they are as Trim draws the path in:

box.Draw(func(p *ui.Painter, r ui.Rect) {
	edge := (&ui.Path{}).MoveTo(r.X, r.Y+r.H/2).LineTo(r.X+r.W, r.Y+r.H/2)
	p.StrokePath(edge.Cap(ui.ButtCap).Dash(0, 6, 4), 1.5, c.Theme().TextMuted)
})

Fill rules and hits

A path fills, and clips, the points its subpaths wind around (SVG's nonzero), so a subpath inside another one leaves a hole only when it goes the other way round. Path.FillRule(ui.EvenOdd) fills the points its subpaths go around an odd number of times instead, as SVG's fill-rule and clip-rule: evenodd do, which makes a ring or a donut of two circles, and a shape with a cut-out, whichever way their path data draws them:

box.Draw(func(p *ui.Painter, r ui.Rect) {
	cx, cy := r.X+r.W/2, r.Y+r.H/2
	donut := (&ui.Path{}).Circle(cx, cy, r.W/2).Circle(cx, cy, r.W/4).FillRule(ui.EvenOdd)
	p.FillPath(donut, c.Theme().Accent)
})

Path.Contains(x, y) reports whether a point is inside a path as it fills, by its rule, as the web's isPointInPath: a view's handler can tell which segment of a pie a press is in. HitTest makes an element take the pointer only where a function says, given the pointer in the coordinates Draw paints in, so a pie's segments, a map's regions or a ring take presses, hover and the cursor only where they paint, as SVG's pointer-events: visiblePainted, and the pointer reaches what is under them elsewhere:

seg := func(r ui.Rect) *ui.Path { return slice(r, from, to) } // a segment's outline
ui.Box(c).Absolute().Fill().
	Draw(func(p *ui.Painter, r ui.Rect) { p.FillPath(seg(r), color) }).
	HitTest(func(x, y float32, r ui.Rect) bool { return seg(r).Contains(x, y) }).
	OnClick(func() { app.pick(i) })

Paths from SVG data

ui.ParsePath(d) reads the path data of an SVG <path d="…"> (all of its commands, absolute and relative, arcs too) into a *ui.Path, so an icon's outline copied from an SVG can be filled, stroked, trimmed or clipped to like any path. It returns an error that gives the offset of the first thing it can't read; ui.MustParsePath panics instead, for data written into the program:

var heart = ui.MustParsePath("M12 21 L10.6 19.7 C5.4 15 2 11.9 2 8.1 C2 5 4.4 2.6 7.5 2.6 " +
	"C9.2 2.6 10.9 3.4 12 4.7 C13.1 3.4 14.8 2.6 16.5 2.6 C19.6 2.6 22 5 22 8.1 " +
	"C22 11.9 18.6 15 13.4 19.7 Z")

box.Draw(func(p *ui.Painter, r ui.Rect) {
	p.Transform(ui.Scaling(r.W/24, r.H/24).Then(ui.Translation(r.X, r.Y)), 0, 0, func() {
		p.StrokePath(heart.Trim(0, drawn), 1.5, c.Theme().Accent)
	})
})

The path is in the data's own units; a transform scales it to the box.

Clipping to a path

Painter.Clip(r, radius, fn) clips what fn draws to a rounded rectangle. Painter.ClipPath(path, fn) clips it to the inside of any path (by its FillRule), with smooth edges, as SVG's and CSS's clip-path: a chart drawn into a polygon, or a picture cut to a star.

star := ui.MustParsePath("M50 0 L61 35 L98 35 L68 57 L79 91 L50 70 L21 91 L32 57 L2 35 L39 35 Z")
ui.Box(c).Size(100, 100).Draw(func(p *ui.Painter, r ui.Rect) {
	p.Transform(ui.Translation(r.X, r.Y), 0, 0, func() {
		p.ClipPath(star, func() {
			p.FillGradient(ui.Rect{W: 100, H: 100}, ui.LinearGradient{From: gold, To: orange, Angle: 135}, 0)
		})
	})
})

Path clips nest with each other and with Clip, and turn with a transform. The clip is a mask of the path's coverage that every renderer samples, made once for each shape and size it is drawn at and kept while frames draw it, so a still path costs nothing to clip to again. A moving or changing one makes a new mask every frame, sized to the path in device pixels (at most 8192 each way).

To clip an element and its children rather than a drawing, see ClipInset and the gradient masks in styling.

Parts of bitmaps

A bitmap can hold many pictures: the icons of a sheet, the frames of an animation, the cells of a contact sheet. Painter.ImageRegion draws one part of it, src in the bitmap's pixels, into a rectangle; nothing of the bitmap around the part shows, smooth or pixelated. Its ImageOptions mirror the part (FlipX, FlipY) and tint it (Tint multiplies its colors and alpha):

icons, _ := ui.DecodeBitmap(iconsPNG) // 16×16 icons in a row
icon := func(i int) ui.Rect { return ui.Rect{X: float32(i * 16), W: 16, H: 16} }

box.Draw(func(p *ui.Painter, r ui.Rect) {
	p.ImageRegion(icons, icon(3), ui.Rect{X: r.X, Y: r.Y, W: 24, H: 24},
		ui.ImageOptions{Tint: c.Theme().TextMuted})
})

A part outside what the painter shows draws nothing, so a view of many can draw them all without culling them itself, as games draw their sprites and tiles.

Canvases

A ui.Canvas is a bitmap a painter draws, on the GPU where the window draws there: render to texture, as Skia's SkSurface and LÖVE's Canvas. Draw draws it anew during a frame, with a painter in its pixels, and it keeps what it holds, showing anywhere a bitmap does through Bitmap:

grid := ui.NewCanvas(800, 400)
drawn := false

box.Draw(func(p *ui.Painter, r ui.Rect) {
	if !drawn { // once, then every frame for the price of an image
		drawn = true
		grid.Draw(p, func(cp *ui.Painter) { drawGrid(cp, 800, 400) })
	}
	p.Image(grid.Bitmap(), r, ui.FillBox)
	drawSeries(p, r)
})

A canvas drawn with a tint or an opacity fades or colors a whole layer at once. Canvases show other canvases, and text. A canvas's pixels are its units: draw it at the window's scale (Painter.Scale) for one as sharp as the window, or smaller and make its bitmap Pixelated to scale pixel art up crisp (the game plugin shows one at whole multiples). A shader reads canvases as images, and draws into them.

Transforms

Painter.Transform(t, ox, oy, fn) transforms what fn draws by t, a ui.Transform such as Translation, Scaling or Rotation, around the point (ox, oy), which stays where it is. Transforms nest, after the painter's own, so a view that pans and zooms, as a map or a diagram, draws its content through one, and turns a part of it inside:

ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
	view := ui.Translation(-doc.panX, -doc.panY).Then(ui.Scaling(doc.zoom, doc.zoom))
	p.Transform(view, r.X, r.Y, func() {
		for _, n := range doc.nodes {
			p.Fill(n.box, n.color, 6) // culled where the view does not look
		}
		cx, cy := doc.dial.X, doc.dial.Y
		p.Transform(ui.Rotation(doc.dial.angle), cx, cy, func() {
			p.Line(cx, cy, cx, cy-40, 2, c.Theme().Accent)
		})
	})
})

Clips made inside turn with what they clip. Input is not transformed: the pointer comes in the element's coordinates, which the app maps to its content with the inverse of its view. Under a transform, images and their parts are placed where they are rather than on whole pixels, so that one moving through a zoomed view glides.

Shaping text

Text that a widget lays out itself, as in the cells of a grid, is shaped once with ui.Shape, which returns glyphs placed along a line by the system's text engine, with ligatures, kerning and fallback fonts, and the runes each comes from. The glyphs come in visual order on every platform, from the left: through Hebrew or Arabic their Cluster descends, and RTL marks them, so find a range of runes' glyphs by Cluster and Runes. Move their X and draw them with Painter.Glyphs; Font.Metrics returns the font's ascent, descent and line gap, and Painter.Scale the device pixels of a DIP, to line things up with the display's pixels. Shape caches nothing, unlike the text of elements: keep the glyphs of text drawn in many frames. Font.Features turns OpenType features on and off, and Font.Thicken draws text with a thicker stroke on macOS, as Ghostty's font-thicken, and Font.Antialiased a thinner one, without font smoothing, as browsers draw text styled -webkit-font-smoothing: antialiased.

font := ui.Font{Family: "monospace", Size: 13}
glyphs := ui.Shape("grid", font)
for i := range glyphs {
	glyphs[i].X = float32(glyphs[i].Cluster) * cellWidth // one per cell
}
ui.Box(c).Height(20).Draw(func(p *ui.Painter, r ui.Rect) {
	p.Glyphs(glyphs, r.X, r.Y+font.Metrics().Ascent, c.Theme().Text)
})

Animation

For motion, Element.Animate returns a value that eases to a target and draws frames until it gets there:

panel := ui.Column(c).Clip()
width := float32(0)
if app.sidebar {
	width = 280
}
panel.Width(panel.Animate("width", width, 200*time.Millisecond))

AnimateWith takes the easing: ui.Linear, ui.EaseIn, ui.EaseOut (as Animate), ui.EaseInOut, ui.CubicBezier(x1, y1, x2, y2), any func(t float32) float32, or ui.Bounce(e), which goes along e and comes back. AnimateTo takes springs as well (see Motion). Loop returns the progress of an animation that starts over every period, for spinners and pulses; Rotate turns an element, as an icon:

spin := ui.Icon(c, loader)
spin.Rotate(spin.Loop("spin", time.Second, ui.Linear) * 360)

skeleton := ui.Box(c).Height(14).Radius(7).Background(t.Border)
skeleton.Opacity(0.4 + 0.6*skeleton.Loop("pulse", 1600*time.Millisecond, ui.Bounce(ui.EaseInOut)))

To animate in other ways, compute from c.Now() and call c.AnimationFrame() in every frame that moves: MyGo draws the next frame when the display can show it, and draws nothing while nothing changes.

A drawing that moves while the layout stays, as a spinner or a chart scrolling by, animates from its Draw instead: compute it from p.Now() and call p.AnimationFrame() while it moves, or p.After(d) when it changes in steps. Unless something else changed, those frames paint the elements of the last frame again without running the view, so they cost only the painting; and an element out of view is not painted, so it asks for none. While the window is out of sight (hidden, minimized, or covered by other windows), MyGo draws no frames at all, from Draw or the view, until some of it shows again: compute what moves from the time, as here, and it shows where the time puts it (see Motion):

ui.Box(c).Height(4).Draw(func(p *ui.Painter, r ui.Rect) {
	p.AnimationFrame()
	x := r.X + (r.W-20)*float32(p.Now().UnixMilli()%1000)/1000
	p.Fill(ui.Rect{X: x, Y: r.Y, W: 20, H: r.H}, c.Theme().Accent, 2)
})

A drawing that looks as smooth at fewer frames, as a record turning, a slow wave or a level meter, can ask for them with p.AnimationFrameRate(fps) in place of p.AnimationFrame(): the window paints it again that often, rather than at each of the display's refreshes (120 a second on ProMotion displays), and on macOS has the display refresh as often, which saves power. The rate rounds up to one the display shows evenly, a whole number of refreshes a frame: 60, 40 or 30 frames a second on a 120 Hz display, 60 or 30 on a 60 Hz one. The window draws at the fastest rate anything in it asks for, so a drawing asking for AnimationFrame or a view that animates still draws at the display's. Pacing with p.After(time.Second / 60) instead draws unevenly: a frame asked for later than a refresh waits for the next.

Such a Draw sees the state as the view last ran: what it reads that changes otherwise is the view's to build anew, after Window.Update.

When the desktop asks for less motion (c.Preferences().ReduceMotion), Animate and AnimateWith go to their target at once. Loop goes on, as the system's spinners do: it shows that something is going on. Motion you compute yourself should read the preference too.

Motion has springs that keep their velocity, delays, values following the pointer, keyframes, and how to tell when an animation is done; Transforms move, scale and rotate any element.