Skip to content

Instantly share code, notes, and snippets.

@egonelbre
Last active January 7, 2023 02:33
Show Gist options
  • Select an option

  • Save egonelbre/f458f857d95d8330cbc1fb05d638ef5c to your computer and use it in GitHub Desktop.

Select an option

Save egonelbre/f458f857d95d8330cbc1fb05d638ef5c to your computer and use it in GitHub Desktop.
Gio architecture brain-dump
title Architecture

Immediate Mode UI

Gio is based on the concept of Immediate Mode User Interface. This approach can be implemented in multiple ways, however the overarching similarity is that the program:

  1. listens for events such as mouse or keyboard input,
  2. updates its internal state based on the event (e.g. sets Checked = true for a checkbox),
  3. runs code that re-renders and layouts the whole state.

In pseudo-code a minimal immediate mode UI can look like this:

// state of the program
var showlist bool
var items    []string

for {
    // waiting for new events
    select {
    case ev := <-eventQueue:
       clearScreen()

        // handle the checkbox
        if DoCheckbox(ev, &showlist) {
            Listbox{
                Items: items
            }.Do(ev)
        }
    }
}

func DoCheckbox(ev Event, checked *bool) bool {
    // see whether we need to handle the event
    if e, ok := ev.(KeyboardInput); ok {
        if e.Key == Space {
            *checked = !*checked
        }
    }

    // draw the checkbox
    if *checked {
        fmt.Println("[x]")
    } else {
        fmt.Println("[ ]")
    }

    // return whether we are checked for convenience
    return *checked
}

type Listbox struct {
    Items []string
}

func (list *Listbox) Do(ev Event) {
    for i, item := range list.Items {
        fmt.Printf("#%d: %q\n",i, item)
    }
}

This of course is not a very useful library, however it demonstrates the core loop of a immediate mode UI:

  1. get an event
  2. handle the widgets while updating the state and drawing the widgets

The main differentiation from non-immediate user interfaces is that the widgets and layout are determined based on the code, not by a separate configuration or setup “before rendering”.

This becomes less simple when other aspects of the GUI are taken into account:

  1. how do you get the events?
  2. when do you re-render the state?
  3. what do the widget structures look like?
  4. how do you track the focus?
  5. how do you structure the events?
  6. how do you communicate with the graphics card?
  7. how do you handle input?
  8. how do you render text?
  9. where does the widget state belong?
  10. ... and many more.

The rest of this document tries to answer how Gio does it. If you wish to know more about immediate mode UI, these references are a good start:

Window

Since a GUI library needs to talk to some sort of display system to display information:

window := app.NewWindow(app.Size(unit.Dp(800), unit.Dp(650)))
for {
    select {
    case e := <-window.Events():
        switch e := e.(type) {
        case system.DestroyEvent:
            // The window was closed.
            return e.Err
        case system.FrameEvent:
            // A request to draw the window state.
            // Update the visible state based on events in e.Queue.
        }
    }
}

app.NewWindow chooses the appropriate "handling driver" depending on the environment and build context. It might choose Wayland, WinAPI,Cocoa or many others.

It then sends events from the display system to the windows.Events() channel.

Operations

There is a need to communicate information about window events, the GPU, input and about the general structure of the screen. Gio uses op.Ops.

In abstract terms an Ops value contains a sequence of operations that tell the window driver what to display and how to handle user input.

By convention, graphical primitives are represented by data types that have an Add method which adds the operations necessary to draw itself to its argument Ops value. Like any Go struct literal, zero-valued fields can be useful to represent optional values.

var ops op.Ops

red := color.RGBA{R:0xFF, A:0xFF}
paint.ColorOp{Color: red}.Add(&ops) 

You might be thinking that it would be more usual to have an ops.Add(ColorOp{Color: red}) method instead of using op.ColorOp{Color: red}.Add(ops). It's like this so that the Add method doesn't have to take an interface-typed argument, which would often require an allocation to call. This is a key aspect of Gio's "zero allocation" design.

Rendering

To tell the graphics API what to draw, Gio uses op.Ops to serialize drawing commands.

Coordinates are based on the top-left corner by default, although it’s possible to transform the coordinate system. This means f32.Point{X:0, Y:0} is the top left corner of the window.

Gio encodes operations as Go structs which know how to encode data into op.Ops. data into op.Ops. For example, the following code will draw a 10x10 pixel colored rectangle at the top level corner of the window: encoding a colored rectangle looks like:

func drawRedRect(ops *op.Ops) {
    paint.ColorOp{Color: color.RGBA{R: 0x80, G: 0x00, B: 0x00, A: 0xFF}}.Add(ops)
    paint.PaintOp{Rect: f32.Rectangle{Max: f32.Point{X:100, Y:100}}}.Add(ops)
}

Transformation

Operation op.TransformOp allows us to translate the position of the rendering operations that come after it.

For example, the following would render 10 units to the right compared to the previous example:

func drawRedRect10PixelsRight(ops *op.Ops) {
       op.TransformOp{}.Offset(f32.Point{X: 100, Y: 0}).Add(ops)
       drawRedRect(ops)
}

Clipping

In some cases we want the rendering to be clipped to some smaller rectangle to avoid accidentally drawing over other things.

Package gioui.org/op/clip, provides exactly that.

clip.Rect clips all subsequent rendering to a particular rounded rectangle.

Note: that we first need to get the actual operation for the clipping with Op before calling Add. This level of indirection is useful if we want to use the same clipping operation multiple times - under the hood, Op records a macro that encodes the clipping path.

This could be used as a basis for a button background:

func redButtonBackground(ops *op.Ops) {
       r := 3 // roundness
       bounds :=  f32.Rectangle{Max: f32.Point{X:30, Y:100}}
       clip.Rect{Rect: bounds, SE: r, SW: r, NW: r, NE: r}.Op(ops).Add(ops)
       drawRedRectangle(ops)
}

Push and Pop

Some of the gio operations affect all operations that follow them. For example, ColorOp sets the “brush” color that is used in subsequent PaintOp operations. This drawing context also includes coordinate transformation (set by TransformOp) and clipping (set by ClipOp).

We often need to set up some drawing context and then restore it to its previous state, leaving later operations unaffected. We can use op.StackOp to do this. A Push operation saves the current drawing context; a Pop operation restores it.

For example, the clipButtonOutline function in the previous section has the unfortunate side-effect of clipping all later operations to the outline of the button background! Let’s make a version of it that doesn’t affect any callers:

func redButtonBackground(ops *op.Ops) {
       var stack op.StackOp
       stack.Push(ops)
       defer stack.Pop()
       r := 3 // roundness
       bounds :=  f32.Rectangle{Max: f32.Point{X:30, Y:100}}
       clip.Rect{Rect: bounds, SE: r, SW: r, NW: r, NE: r}.Op(ops).Add(ops)
       drawRedRectangle(ops)
}

Drawing Order and Macros

Drawing happens from back to front. In this example the green rectangle is drawn on top of red rectangle:

func drawOverlappingRectangles(ops *op.Ops) {
      // red rectangle
      paint.ColorOp{Color: color.RGBA{R: 0xFF, G: 0x00, B: 0x00, A: 0xFF}}.Add(ops)
      paint.PaintOp{Rect: f32.Rectangle{Max: f32.Point{X:100, Y:10}}}.Add(ops)

      // green rectangle
      paint.ColorOp{Color: color.RGBA{R: 0x00, G: 0xFF, B: 0x00, A: 0xFF}}.Add(ops)
      paint.PaintOp{Rect: f32.Rectangle{Max: f32.Point{X:10, Y:100}}}.Add(ops)
}

Sometimes you may want to change this order. For example, you may want to delay rendering to apply a transform that is calculated after the rendering, or you may want to do the same set of operations several times. For this purpose there is op.MacroOp.

func drawFiveRectangles(ops *op.Ops) {
      // Record all the operations performed by drawRedRect
      // into the macro.
      var macro op.MacroOp
      macro.Record(ops)
      drawRedRect(ops)
      macro.Stop()
      // “play back” the macro 5 times, each time vertically offset
      // 40 pixels more down the screen.
      for i := 0; i < 5; i++ {
          macro.Add(ops)
          op.TransformOp{}.Offset(f32.Point{X: 0, Y: 40}).Add(ops)
      }
}

Animating and requesting a redraw

When you are animating something you may need to retrigger rendering immediately rather than wait for events. For that there is op.InvalidateOp

The following code will animate a green “progress bar” that fills up from left to right over 5 seconds from when the program starts:

var startTime = time.Now()
var duration = 5*time.Second

func drawProgressBar(ops *op.Ops, now time.Time) {
      // Calculate how much of the progress bar to draw, based
      // on the current time.
      elapsed := now.Sub(startTime)
      progress := elapsed.Seconds() / duration.Seconds())
      if progress < 1 {
            // The progress bar hasn’t yet finished animating.
            op.InvalidateOp{}.Add(ops)
      } else {
            progress = 1
      }

      paint.ColorOp{Color: color.RGBA{R: 0x00, G: 0xFF, B: 0x00, A: 0xFF}}.Add(ops)
      width := 100*float32(progress)
      paint.PaintOp{Rect: f32.Rectangle{Max: f32.Point{X:width, Y:10}}}.Add(ops)
}

Caching with CallOp

If you wish to cache a rendering to redraw a frame later, you can use op.Ops and then use op.CallOp to render it. This can be useful to animate away a widget that has been removed:

func drawWithCache(ops *op.Ops) {
      // Save the operations in an independent ops value (the cache).
      cache := new(op.Ops)
      paint.ColorOp{Color: color.RGBA{R: 0x00, G: 0xFF, B: 0x00, A: 0xFF}}.Add(cache)
      paint.PaintOp{Rect: f32.Rectangle{Max: f32.Point{X:10, Y:100}}}.Add(cache)

      // Render the operations from the cache.
      op.CallOp{Ops: cache}.Add(ops)
}

Images

paint.ImageOp can be used to draw images. Like ColorOp, it sets the drawing context that’s used for any subsequent PaintOp. It is used similarly to ColorOp. Note that RGBA and image.Uniform(https://golang.org/pkg/image#Uniform) images are efficient and treated specially. Other Image implementations will undergo a potentially expensive conversion to convert them to the underlying image model.

func drawImage(ops *op.Ops, img image.Image) {
      imageOp := paint.NewImageOp(img)
      imageOp.Add(ops)
      paint.PaintOp{Rect: f32.Rectangle{Max: f32.Point{X:100, Y:100}}}.Add(ops)
}

Note, the image must not be mutated until another FrameEvent happens, because the image is read asynchronously while the frame is being rendered.

Input

Input is delivered to the widgets via a system.FrameEvent which contains a Queue.

Some of the most common events are: key.Event, key.Focus - for keyboard input. key.EditEvent - for text editing. pointer.Event - for mouse and touch input.

The program can do arbitrary things when these events arrive, such as updating its local state. The Frame event is special - when the program receives a Frame event, it is responsible for rendering the graphics by calling the e.Frame function with an Ops value holding all the graphics to render.

There are also event-processors, such as gioui.org/gesture, that detect higher-level actions such as a double-click from individual click events.

To handle input for multiple different widgets, Gio needs to have widgets register themselves to receive input. However, since the Gio framework doesn't provide a persistent data structure as state, there's no obvious place where widgets can register themselves to receive input.

Gio achieves this by associating input with an arbitrary tag (an interface{} value) provided by the program. When the next frame is being rendered, the input can be retrieved by using the same tag.

For example to handle input and register for listening for it, the code would look something like:

var tag = new(bool)		// We could use &pressed for this instead.
var pressed = false

func doButton(ops *op.Ops, q event.Queue) {
     // Make sure we don’t pollute the graphics context.
     var stack op.StackOp
     stack.Push(ops)
     defer stack.Pop()

     for _, ev := range q.Events(tag) {
          if x, ok := ev.(pointer.Event); ok {
               switch x.Type {
               case pointer.Press:
                    pressed = true
               case pointer.Release:
                    pressed = false
               }
          }
     }

     pointer.Rect(image.Rect(0, 0, 100, 100)).Add(ops)
     pointer.InputOp{Tag: tag}.Add(ops)
     
     var c color.RGBA
     if pressed {
          c = color.RGBA{R: 0xFF, A: 0xFF}
     } else {
          c = color.RGBA{G: 0xFF, A: 0xFF}
     }
     paint.ColorOp{Color: c}.Add(ops)
     paint.PaintOp{Rect: f32.Rectangle{Max: f32.Point{X:100, Y:100}}}.Add(ops)
}

It's convenient to use a pointer value for the input tag, as it's cheap to convert a pointer to an interface{}, and it's easy to make the value specific to a local data structure, which avoids the risk of tag conflict. However, using other kinds of tag can work, bearing in mind that all the handlers using the same tag will see the events.

For more details take a look at https://godoc.org/gioui.org/io/pointer (pointer/mouse events) and https://godoc.org/gioui.org/io/key (keyboard events).

Putting the low-level pieces together

A single frame consists of getting input, registering for input and rendering the new state:

func main() {
	go func() {
		w := app.NewWindow()
		if err := loop(w); err != nil {
			log.Fatal(err)
		}
	}()
	app.Main()
}

func loop(w *app.Window) error {
	ops := new(op.Ops)
	for e := range w.Events() {
		switch e := e.(type) {
		case system.DestroyEvent:
			return e.Err
		case system.FrameEvent:
			ops.Reset()

			// handle button input and render
			doButton(ops, e.Queue)

			// render the frame
			e.Frame(ops)
		}
	}
}

Writing a program using these concepts could get really verbose, however these low-level pieces are intended for writing Widgets themselves. Most programs end up using widgets rather than the low-level operations.

Widget

We’ve been mentioning widgets quite a while now. In principle widgets are composable and renderable UI elements that react to input. Or to put more concretely.

They get input from e.Queue They might hold some state They calculate their size They render themselves to op.Ops

By convention they have: (TODO: explain the difference between Layout and Update) method called Layout(gtx *layout.Context, ...) to render themselves, method called Update(gtx *layout.Context, ...) to update themselves.

This gives a separation between “widget state” and “widget style and rendering”. The state is very often reusable. The common widget states are in gioui.org/widget. Code that combines state with style is in gioui.org/widget/material.

Context

To build out more complex UI from these primitives we need more structure and describe the layout in a composable way.

Static layouts are a thing of the past and we need to calculate how big things can be, i.e. constrain their size and then figure out the rest of the layout. We also need a comfortable way of passing events through the composed structure and similarly we need a way to pass op.Ops through the system. It would be really inconvenient to pass them separately.

layout.Context is what carries the state that is needed by almost all layouts and widgets. Summarizing:

Constraints - an “incoming” parameter to a widget: give a widget’s maximum (and minimum) size.. Dimensions - an “outgoing” return value from a widget, used for tracking or returning the most recent layout size. Ops - for communicating with the windowing system. Events - to get events associated with some handle Now - to get the current time

It contains the constraints on how much screen is available -- both how much and how little screen real-estate the caller wants the widget to consume.

A Button From Scratch

As an example, here is how to write a very simple button.

First let’s write draw our button:

type Button struct {
	pressed bool
}

func (b *Button) Layout(gtx *layout.Context) {
col := color.RGBA{A: 0xff, R: 0xff}
	if b.pressed {
		col = color.RGBA{A: 0xff, G: 0xff}
	}
	drawSquare(gtx.Ops, col)
}

func drawSquare(ops *op.Ops, color color.RGBA) {
	square := f32.Rectangle{
		Max: f32.Point{X: 500, Y: 500},
	}
	paint.ColorOp{Color: color}.Add(ops)
	paint.PaintOp{Rect: square}.Add(ops)
}

We now also need to handle the input:

type Button struct {
	pressed bool
}

func (b *Button) Layout(gtx *layout.Context) {
	// here we loop through all the events associated with this button.
	for _, e := range gtx.Events(b) {
		if e, ok := e.(pointer.Event); ok {
			switch e.Type {
			case pointer.Press:
				b.pressed = true
			case pointer.Release:
				b.pressed = false
			}
		}
	}

	// register rectangle that receives 
	pointer.Rect(
		image.Rectangle{Max: image.Point{X: 500, Y: 500}},
	).Add(gtx.Ops)
	pointer.InputOp{Tag: b}.Add(gtx.Ops)

	// draw the button
	col := color.RGBA{A: 0xff, R: 0xff}
	if b.pressed {
		col = color.RGBA{A: 0xff, G: 0xff}
	}
	drawSquare(gtx.Ops, col)
}

Layouting

To create a layout for widgets there are special functions and structures to manipulate layout.Context. The common variants are conveniently in layout.

Layouting in general happens as the following:

push state set layout.Context.Constraints set op.TransformOp call widget.Layout(gtx, ...) pop state

For more complicated layout approaches it needs to be combined with op.MacroOp, such as in flex:

record widget rendering using MacroOp calculate sizes for non-rigid widgets draw widgets based on the calculated sizes using the macros

Example: Split View Widget

(full code currently here https://github.com/egonelbre/expgio/tree/master/split)

As an example, to split the screen into two you could write a widget that looks like:

type Split struct {
}

func (s *Split) Layout(gtx *layout.Context, left, right layout.Widget) {
	savedConstraints := gtx.Constraints
	defer func() {
		gtx.Constraints = savedConstraints
		gtx.Dimensions.Size = image.Point{
			X: savedConstraints.Width.Max,
			Y: savedConstraints.Height.Max,
		}
	}()
	gtx.Constraints.Height.Min = gtx.Constraints.Height.Max

	leftsize := gtx.Constraints.Width.Max / 2
	rightsize := gtx.Constraints.Width.Max - leftsize

	{
		var stack op.StackOp
		stack.Push(gtx.Ops)

		gtx.Constraints.Width.Min = leftsize
		gtx.Constraints.Width.Max = leftsize
		left()

		stack.Pop()
	}

	{
		var stack op.StackOp
		stack.Push(gtx.Ops)

		gtx.Constraints.Width.Min = rightsize
		gtx.Constraints.Width.Max = rightsize

		op.TransformOp{}.Offset(f32.Point{
			X: float32(leftsize),
		}).Add(gtx.Ops)
		right()

		stack.Pop()
	}
}

The usage code would look like:

split.Layout(gtx, func() {
	// render the left side
}, func() {
	// render the right side
})

Of course, you do not need to implement such layouting yourself, there are plenty of them available in layout.

Example: Split View Widget Input

(full code currently here https://github.com/egonelbre/expgio/tree/master/split-interactive)

To make it more useful we could make the split draggable.

First let’s make the ratio adjustable. We should try to make zero values useful, in this case 0 could mean that it’s split in the center.

type Split struct {
	// Ratio keeps the current layout.
	// 0 is center, -1 completely to the left, 1 completely to the right.
	Ratio float32
}

func (s *Split) Layout(gtx *layout.Context, left, right layout.Widget) {
	savedConstraints := gtx.Constraints
	defer func() {
		gtx.Constraints = savedConstraints
		gtx.Dimensions.Size = image.Point{
			X: savedConstraints.Width.Max,
			Y: savedConstraints.Height.Max,
		}
	}()
	gtx.Constraints.Height.Min = gtx.Constraints.Height.Max

	proportion := (s.Ratio + 1) / 2
	leftsize := int(proportion*float32(gtx.Constraints.Width.Max))

	rightoffset := leftsize
	rightsize := gtx.Constraints.Width.Max - rightoffset

	{
		var stack op.StackOp
		stack.Push(gtx.Ops)

		gtx.Constraints.Width.Min = leftsize
		gtx.Constraints.Width.Max = leftsize
		left()

		stack.Pop()
	}
	{
		var stack op.StackOp
		stack.Push(gtx.Ops)

		gtx.Constraints.Width.Min = rightsize
		gtx.Constraints.Width.Max = rightsize

		op.TransformOp{}.Offset(f32.Point{
			X: float32(rightoffset),
		}).Add(gtx.Ops)
		right()

		stack.Pop()
	}
}

Because we also need to have an area designated for moving the split, let’s add a bar into the center:

type Split struct {
	// Ratio keeps the current layout.
	// 0 is center, -1 completely to the left, 1 completely to the right.
	Ratio float32
	// Bar is the width for resizing the layout
	Bar int
}

const defaultBarWidth = 4

func (s *Split) Layout(gtx *layout.Context, left, right layout.Widget) {
	savedConstraints := gtx.Constraints
	defer func() {
		gtx.Constraints = savedConstraints
		gtx.Dimensions.Size = image.Point{
			X: savedConstraints.Width.Max,
			Y: savedConstraints.Height.Max,
		}
	}()
	gtx.Constraints.Height.Min = gtx.Constraints.Height.Max

	bar := s.Bar
	if bar <= 0 {
		bar = defaultBarWidth
	}

	proportion := (s.Ratio + 1) / 2
	leftsize := int(proportion*float32(gtx.Constraints.Width.Max) - float32(bar))

	rightoffset := leftsize + bar
	rightsize := gtx.Constraints.Width.Max - rightoffset

	{
		var stack op.StackOp
		stack.Push(gtx.Ops)

		gtx.Constraints.Width.Min = leftsize
		gtx.Constraints.Width.Max = leftsize
		left()

		stack.Pop()
	}
	{
		var stack op.StackOp
		stack.Push(gtx.Ops)

		gtx.Constraints.Width.Min = rightsize
		gtx.Constraints.Width.Max = rightsize

		op.TransformOp{}.Offset(f32.Point{
			X: float32(rightoffset),
		}).Add(gtx.Ops)
		right()

		stack.Pop()
	}
}

Now we need to handle input events:

type Split struct {
	// Ratio keeps the current layout.
	// 0 is center, -1 completely to the left, 1 completely to the right.
	Ratio float32
	// Bar is the width for resizing the layout
	Bar int

	// drag says that some pointer is dragging things
	drag   bool
	// dragID specifies which pointer (e.g. mouse, or which finger) is dragging
	dragID pointer.ID
	// dragX is the last dragging position
	dragX  float32
}

... snip ...

// handle events
for _, ev := range gtx.Events(s) {
	e, ok := ev.(pointer.Event)
	if !ok {
		continue
	}

	switch e.Type {
	case pointer.Press:
		// ensure that we don’t start grabbing twice
		if s.drag {
			break
		}

		// setup our initial state for dragging
		s.drag = true
		s.dragID = e.PointerID
		s.dragX = e.Position.X

	case pointer.Move:
		// ensure that the correct pointer handles things
		if !s.drag || s.dragID != e.PointerID {
			break
		}

		// calculate how much we need to adjust ratio
		deltaX := e.Position.X - s.dragX
		s.dragX = e.Position.X

		deltaRatio := deltaX * 2 / float32(gtx.Constraints.Width.Max)
		s.Ratio += deltaRatio

	case pointer.Release:
		fallthrough
	case pointer.Cancel:
		// finish dragging
		if !s.drag || s.dragID != e.PointerID {
			break
		}
		s.drag = false
	}
}


// Register input
barRect := image.Rect(leftsize, 0, rightoffset, gtx.Constraints.Width.Max)
// Register bar rectangle for input.
pointer.Rect(barRect).Add(gtx.Ops)
// Grab tells the input system to ensure this widget gets priority.
pointer.InputOp{Tag: s, Grab: s.drag}.Add(gtx.Ops)

Putting the whole Layout function together, it will look like:

func (s *Split) Layout(gtx *layout.Context, left, right layout.Widget) {
	savedConstraints := gtx.Constraints
	defer func() {
		gtx.Constraints = savedConstraints
		gtx.Dimensions.Size = image.Point{
			X: savedConstraints.Width.Max,
			Y: savedConstraints.Height.Max,
		}
	}()
	gtx.Constraints.Height.Min = gtx.Constraints.Height.Max

	bar := s.Bar
	if bar <= 0 {
		bar = defaultBarWidth
	}

	proportion := (s.Ratio + 1) / 2
	leftsize := int(proportion*float32(gtx.Constraints.Width.Max) - float32(bar))

	rightoffset := leftsize + bar
	rightsize := gtx.Constraints.Width.Max - rightoffset

	{ // handle input
		for _, ev := range gtx.Events(s) {
			e, ok := ev.(pointer.Event)
			if !ok {
				continue
			}

			switch e.Type {
			case pointer.Press:
				if s.drag {
					break
				}

				s.drag = true
				s.dragID = e.PointerID
				s.dragX = e.Position.X

			case pointer.Move:
				if !s.drag || s.dragID != e.PointerID {
					break
				}

				deltaX := e.Position.X - s.dragX
				s.dragX = e.Position.X

				deltaRatio := deltaX * 2 / float32(gtx.Constraints.Width.Max)
				s.Ratio += deltaRatio

			case pointer.Release:
				fallthrough
			case pointer.Cancel:
				if !s.drag || s.dragID != e.PointerID {
					break
				}
				s.drag = false
			}
		}

		// register for input
		barRect := image.Rect(leftsize, 0, rightoffset, gtx.Constraints.Width.Max)
		pointer.Rect(barRect).Add(gtx.Ops)
		pointer.InputOp{Tag: s, Grab: s.drag}.Add(gtx.Ops)
	}

	{
		var stack op.StackOp
		stack.Push(gtx.Ops)

		gtx.Constraints.Width.Min = leftsize
		gtx.Constraints.Width.Max = leftsize
		left()

		stack.Pop()
	}
	{
		var stack op.StackOp
		stack.Push(gtx.Ops)

		gtx.Constraints.Width.Min = rightsize
		gtx.Constraints.Width.Max = rightsize

		op.TransformOp{}.Offset(f32.Point{X: float32(rightoffset)}).Add(gtx.Ops)
		right()

		stack.Pop()
	}
}

Of course, we might need some additional checks to avoid Ratio values like -5, but that’s tiny improvements.

Themes

Since many widgets need different colors, it's useful to place all the relevant colors into a single struct Theme. It contains the relevant settings for a Material design based UI.

Since also there needs to be custom code per style, it also contains widgets based on the Material Design Components.

Text

TODO: describe how shaper works

Units

TODO: describe how units are handled

Common widget developer errors

The system is drawing on top of my custom widget, or otherwise ignoring its size.

The problem: You’ve created a nice new widget. You lay it out, say, in a Flex Rigid. The next Rigid draws on top of it.

The explanation: Gio communicates the size of widgets dynamically via layout.Context.Dimensions (commonly “gtx.Dimensions”). High level widgets (such as Labels) “return” or pass on their dimensions in gtx.Dimensions, but lower-level operations, such as paint.PaintOp, do not set Dimensions.

The solution: Update gtx.Dimensions in your widget’s Layout function before you return.

TODO: Example code & screenshots illustrating the problem and solution.

My list.List won’t scroll

The problem: You lay out a list and then it just sits there and doesn’t scroll.

The explanation: A lot of widgets in Gio are context free -- you can and should allocate new ones every time through your Layout function. Lists are not like that. They record their scroll position and state internally, and that state needs to persist between calls to Layout.

The solution: Put your List object as a field inside your object struct to make it persistent across calls to Layout.

The system is ignoring updates to a widget

The problem: You define a field in your widget struct with the widget. You update the child widget state, either implicitly or explicitly. The child widget stubbornly refuses to reflect your updates.

This is related to the problem with Lists that won’t scroll, above.

The (possible) explanation: You might be seeing a common “gotcha” in Go code, where you’ve defined a method that doesn’t take a pointer receiver, so all the updates you’re making to your widget are only visible inside that function,and thrown away when it returns. (Or of course you might have some other bug.)

The solution: Make sure you’re using pointer receivers when appropriate. Usually Layout and Update methods should have pointer receivers.

OFFTOPIC

Convenience Wishlist

// convenience constructor for f32.Rectangle f32.Rect(minx, miny, maxx, maxy)

// convenience conversion from and to image.Rectangle f32.FromImageRect() // or similar f32.Rectangle.ToImage() or image.Rect(f32.Rectangle.Ints())

Same for points.

// clip rect also have Add, since often you want to immediately use it // rather than preserve. clip.Rect.Add

// confusion rename widget.Bool -> widget.Toggle

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment