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:
- Embed
widget.BaseWidget. It provides theCanvasObjectmethods: size, position, visibility,MinSizeandRefresh. The widget only has to addCreateRenderer. - Call
ExtendBaseWidget(r)in the constructor, passing the widget itself. This tellsBaseWidgetwhich 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. - Call
Refresh()after changing state.SetValuechangesValueand then callsRefresh, which asks the renderer to update. Give your widget setter methods like this, so callers do not have to remember. - Implement event interfaces for the input you want.
Tappedmakes the widgetTappable. The event’sPositionis relative to the widget, so the code works out which dot was tapped from the x coordinate. CreateRenderercreates 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() {}| Method | Called | Job |
|---|---|---|
MinSize() | When a layout measures the widget | Return the smallest size the widget can be drawn at. BaseWidget.MinSize returns this. |
Layout(size) | When the widget is resized | Resize and move the objects to fit size. |
Refresh() | When the widget’s Refresh is called, and when the theme changes | Copy state from the widget onto the objects, then refresh them. |
Objects() | When drawing and when finding what is under the pointer | Return the objects to draw, back to front. |
Destroy() | When the renderer is no longer needed | Stop 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. BecauseRefreshruns on theme changes, the widget then follows a switch between light and dark. - Do not create objects in
RefreshorLayout. Create them inCreateRendererand change their fields afterwards. If the set of objects changes, update the slice thatObjects()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.Refreshcalls itsSyncState()before the renderer’sRefresh. - Disabling: embed
widget.DisableableWidgetinstead ofBaseWidgetto getEnable,DisableandDisabled. CheckDisabled()in your renderer’sRefreshand in your event methods. - Keyboard: implement
event.Focusableto receive keys when focused. - Tooltips:
BaseWidget.SetToolTipTextgives the widget a hover tooltip. - Debugging: set
widget.DebugLayout = trueto log everyMinSizeandResize, orwidget.DebugRefresh = trueto log everyRefresh.