Skip to content

Building a widget

A widget has two parts:

  • The widget holds state and behaviour: its fields, its methods, and the event methods it implements. It is what your app code creates and talks to.
  • The renderer draws it. It owns a set of canvas objects and keeps them in step with the widget’s state.

This page builds a rating widget: five dots that fill up to the current value, which the user sets by tapping.

The widget

const maxRating = 5

// Rating shows a value from 0 to 5 as a row of dots.
// The widget holds the state; its renderer draws it.
type Rating struct {
	widget.BaseWidget

	Value     int
	OnChanged func(int)
}

// NewRating creates the widget. ExtendBaseWidget must be called before
// the widget is used, so BaseWidget knows which object it belongs to.
func NewRating(value int) *Rating {
	r := &Rating{Value: value}
	r.ExtendBaseWidget(r)
	return r
}

// SetValue changes the value and redraws the widget.
func (r *Rating) SetValue(v int) {
	if v < 0 || v > maxRating || v == r.Value {
		return
	}
	r.Value = v
	r.Refresh()
	if r.OnChanged != nil {
		r.OnChanged(v)
	}
}

// Tapped makes the widget respond to clicks and taps (event.Tappable).
func (r *Rating) Tapped(e *event.PointEvent) {
	dot := int(e.Position.X/(r.Size().Width/maxRating)) + 1
	if dot == r.Value {
		dot = 0 // tapping the current value clears it
	}
	r.SetValue(dot)
}

// CreateRenderer is called when the widget is first needed, and again if
// its cached renderer was destroyed.
func (r *Rating) CreateRenderer() core.WidgetRenderer {
	rr := &ratingRenderer{rating: r}
	for i := range rr.dots {
		rr.dots[i] = canvas.NewCircle(nil)
		rr.dots[i].StrokeWidth = 2
	}
	rr.Refresh()
	return rr
}

Step by step:

  1. Embed widget.BaseWidget. It provides the CanvasObject methods: size, position, visibility, MinSize and Refresh. The widget only has to add CreateRenderer.
  2. Call ExtendBaseWidget(r) in the constructor, passing the widget itself. This tells BaseWidget which object it is part of, so that it can find the renderer and call your methods. Without it the widget does not render, and Bellui logs a warning that names the call site.
  3. Call Refresh() after changing state. SetValue changes Value and then calls Refresh, which asks the renderer to update. Give your widget setter methods like this, so callers do not have to remember.
  4. Implement event interfaces for the input you want. Tapped makes the widget Tappable. The event’s Position is relative to the widget, so the code works out which dot was tapped from the x coordinate.
  5. CreateRenderer creates the canvas objects and returns the renderer. Bellui calls it when the widget is first needed and caches the result. A cached renderer can be destroyed later, for example after its window closes or once the widget has been out of use for a while, and a new one created when it is needed again.

The renderer

A renderer implements core.WidgetRenderer:

// ratingRenderer owns the canvas objects that draw a Rating.
type ratingRenderer struct {
	rating *Rating
	dots   [maxRating]*canvas.Circle
}

// MinSize is the smallest size the widget can be drawn at.
func (rr *ratingRenderer) MinSize() geo.Size {
	d := theme.IconInlineSize()
	p := theme.Padding()
	return geo.NewSize(maxRating*d+(maxRating-1)*p, d)
}

// Layout places the objects inside the widget's current size.
func (rr *ratingRenderer) Layout(size geo.Size) {
	slot := size.Width / maxRating
	d := min(slot-theme.Padding(), size.Height)
	for i, dot := range rr.dots {
		dot.Resize(geo.NewSquareSize(d))
		dot.Move(geo.NewPos(float32(i)*slot+(slot-d)/2, (size.Height-d)/2))
	}
}

// Refresh updates the objects from the widget's state and theme.
func (rr *ratingRenderer) Refresh() {
	th := rr.rating.Theme()
	v := bellui.CurrentApp().Settings().ThemeVariant()
	on := th.Color(theme.ColorNamePrimary, v)
	off := th.Color(theme.ColorNameDisabled, v)
	for i, dot := range rr.dots {
		dot.StrokeColor = off
		dot.FillColor = nil
		if i < rr.rating.Value {
			dot.StrokeColor, dot.FillColor = on, on
		}
		dot.Refresh()
	}
}

// Objects lists everything the renderer draws.
func (rr *ratingRenderer) Objects() []core.CanvasObject {
	objs := make([]core.CanvasObject, len(rr.dots))
	for i, dot := range rr.dots {
		objs[i] = dot
	}
	return objs
}

// Destroy releases resources when the renderer is no longer needed.
func (rr *ratingRenderer) Destroy() {}
MethodCalledJob
MinSize()When a layout measures the widgetReturn the smallest size the widget can be drawn at. BaseWidget.MinSize returns this.
Layout(size)When the widget is resizedResize and move the objects to fit size.
Refresh()When the widget’s Refresh is called, and when the theme changesCopy state from the widget onto the objects, then refresh them.
Objects()When drawing and when finding what is under the pointerReturn the objects to draw, back to front.
Destroy()When the renderer is no longer neededStop timers or release resources. Often empty.

Some rules that keep a renderer correct:

  • Read colours and sizes from the theme in Refresh, not once at creation. rr.rating.Theme() returns the theme that applies to this widget, which can differ inside a theme override. Because Refresh runs on theme changes, the widget then follows a switch between light and dark.
  • Do not create objects in Refresh or Layout. Create them in CreateRenderer and change their fields afterwards. If the set of objects changes, update the slice that Objects() returns.
  • Keep state in the widget, not the renderer. The renderer reads rr.rating.Value; it never stores a copy that could go stale. A renderer can be thrown away and recreated, and anything stored only there is lost.

For a widget drawn by a single object, you do not need your own renderer type. widget.NewSimpleRenderer(obj) returns one that draws obj at the widget’s full size. The pad on the Input events page uses it.

Use it

The widget behaves like any other. It goes in containers and reports its minimum size to their layouts:

label := widget.NewLabel("Rating: 3")
rating := NewRating(3)
rating.OnChanged = func(v int) {
	label.SetText(fmt.Sprintf("Rating: %d", v))
}
reset := widget.NewButton("Reset", func() { rating.SetValue(0) })

The whole program

// A star-rating widget built from scratch: five dots that fill up to the
// value, set by tapping.
package main

import (
	"fmt"

	"github.com/belluikit/bellui"
	"github.com/belluikit/bellui/app"
	"github.com/belluikit/bellui/canvas"
	"github.com/belluikit/bellui/container"
	"github.com/belluikit/bellui/core"
	"github.com/belluikit/bellui/event"
	"github.com/belluikit/bellui/geo"
	"github.com/belluikit/bellui/theme"
	"github.com/belluikit/bellui/widget"
)

const maxRating = 5

// Rating shows a value from 0 to 5 as a row of dots.
// The widget holds the state; its renderer draws it.
type Rating struct {
	widget.BaseWidget

	Value     int
	OnChanged func(int)
}

// NewRating creates the widget. ExtendBaseWidget must be called before
// the widget is used, so BaseWidget knows which object it belongs to.
func NewRating(value int) *Rating {
	r := &Rating{Value: value}
	r.ExtendBaseWidget(r)
	return r
}

// SetValue changes the value and redraws the widget.
func (r *Rating) SetValue(v int) {
	if v < 0 || v > maxRating || v == r.Value {
		return
	}
	r.Value = v
	r.Refresh()
	if r.OnChanged != nil {
		r.OnChanged(v)
	}
}

// Tapped makes the widget respond to clicks and taps (event.Tappable).
func (r *Rating) Tapped(e *event.PointEvent) {
	dot := int(e.Position.X/(r.Size().Width/maxRating)) + 1
	if dot == r.Value {
		dot = 0 // tapping the current value clears it
	}
	r.SetValue(dot)
}

// CreateRenderer is called when the widget is first needed, and again if
// its cached renderer was destroyed.
func (r *Rating) CreateRenderer() core.WidgetRenderer {
	rr := &ratingRenderer{rating: r}
	for i := range rr.dots {
		rr.dots[i] = canvas.NewCircle(nil)
		rr.dots[i].StrokeWidth = 2
	}
	rr.Refresh()
	return rr
}


// ratingRenderer owns the canvas objects that draw a Rating.
type ratingRenderer struct {
	rating *Rating
	dots   [maxRating]*canvas.Circle
}

// MinSize is the smallest size the widget can be drawn at.
func (rr *ratingRenderer) MinSize() geo.Size {
	d := theme.IconInlineSize()
	p := theme.Padding()
	return geo.NewSize(maxRating*d+(maxRating-1)*p, d)
}

// Layout places the objects inside the widget's current size.
func (rr *ratingRenderer) Layout(size geo.Size) {
	slot := size.Width / maxRating
	d := min(slot-theme.Padding(), size.Height)
	for i, dot := range rr.dots {
		dot.Resize(geo.NewSquareSize(d))
		dot.Move(geo.NewPos(float32(i)*slot+(slot-d)/2, (size.Height-d)/2))
	}
}

// Refresh updates the objects from the widget's state and theme.
func (rr *ratingRenderer) Refresh() {
	th := rr.rating.Theme()
	v := bellui.CurrentApp().Settings().ThemeVariant()
	on := th.Color(theme.ColorNamePrimary, v)
	off := th.Color(theme.ColorNameDisabled, v)
	for i, dot := range rr.dots {
		dot.StrokeColor = off
		dot.FillColor = nil
		if i < rr.rating.Value {
			dot.StrokeColor, dot.FillColor = on, on
		}
		dot.Refresh()
	}
}

// Objects lists everything the renderer draws.
func (rr *ratingRenderer) Objects() []core.CanvasObject {
	objs := make([]core.CanvasObject, len(rr.dots))
	for i, dot := range rr.dots {
		objs[i] = dot
	}
	return objs
}

// Destroy releases resources when the renderer is no longer needed.
func (rr *ratingRenderer) Destroy() {}


func main() {
	a := app.NewWithID("com.example.rating")
	w := a.NewWindow("Custom widget")

	label := widget.NewLabel("Rating: 3")
	rating := NewRating(3)
	rating.OnChanged = func(v int) {
		label.SetText(fmt.Sprintf("Rating: %d", v))
	}
	reset := widget.NewButton("Reset", func() { rating.SetValue(0) })

	w.SetContent(container.NewCenter(container.NewVBox(rating, label, reset)))
	bellui.ShowAndRun(w)
}

More you can add

  • State sync: if a widget has internal state derived from its public fields, implement widget.StateSyncer. BaseWidget.Refresh calls its SyncState() before the renderer’s Refresh.
  • Disabling: embed widget.DisableableWidget instead of BaseWidget to get Enable, Disable and Disabled. Check Disabled() in your renderer’s Refresh and in your event methods.
  • Keyboard: implement event.Focusable to receive keys when focused.
  • Tooltips: BaseWidget.SetToolTipText gives the widget a hover tooltip.
  • Debugging: set widget.DebugLayout = true to log every MinSize and Resize, or widget.DebugRefresh = true to log every Refresh.