Fyne GUIs

Filed under golang on December 21, 2019

Something that took my interest at GopherConAU this year was Steve O’Connor’s talk The Fyne GUI Toolkit, where he showcased what Fyne was capable of.

Anyone that’s worked with me knows I have WinForms induced PTSD when it comes to GUI toolkits, long ago coming to the conclusion that anything I wrote will just be a console app or web API. While in the past, I’ve found WPF quite comfortable and had minimal amounts of success using the Angular framework with Material, it’s just too fiddly and annoying for a colourblind person like me to care that much about GUI programming.

Enter Fyne, a Material based, cross-platform UI toolkit written with OpenGL. Something that attracted me to it was that colour palettes are part of the kit itself, so I can either use the default dark mode scheme or switch it to light mode with a command line flag. Another aspect is that layouts are sensible, no need to center align a div anywhere.

Getting Started on Windows

It was somewhat of an adventure getting this started on Windows, and I chose to give this a go because whenever something claims to be cross-platform, the steps to get going on Windows are always as long as my arm. Thanks the creator of Fyne on Slack for answering my dumb questions, I’ll do what I can here to explain it

Step 1: Install msys2

Go here and install it. This is a round about way to get to where we’re going, but just trust me

Step 2: Use pacman to install gcc and go

Open msys and run

pacman -S base-devel git mingw-w64-x86_64-toolchain mingw-w64-x86_64-go

Step 3: Get the example program going

Create a folder somewhere, navigate to it in msys, and run

go mod init

Next, create your main.go file and dump the following into it

package main

import (
	"fyne.io/fyne/app"
	"fyne.io/fyne/widget"
)

func main() {
	a := app.New()

	w := a.NewWindow("Hello")
	w.SetContent(widget.NewVBox(
		widget.NewLabel("Hello Fyne!"),
		widget.NewButton("Quit", func() {
			a.Quit()
		}),
	))
	w.ShowAndRun()
}

Head back to your msys terminal and run

go run main.go

and you should get something that looks like this after go is done downloading everything we need and compiling the application Fyne hello world

There’s probably an easier way to do this by setting up my library paths and stuff so this will work from the Windows command line and goland, but for time’s sake I’ll leave that alone for now.

Update

I reopened goland after reinstalling msys to try and confirm the steps, and I think it’s set environment variables or something correctly and I can suddenly build and run without the msys terminal. I’m just going to run with this for now, but I know for sure the method above works having done it twice now.

Something a little more interesting

Let’s have a look at the example applications, particularly the game of life one Game of Life

Let’s have a go at modifying this to be Langton’s Ant instead, since we have our grid and two states already. And that’s what I always do whenever I learn a new GUI toolkit

The core logic

The nextGen method is the part that iterates the world state here, so let’s have a look at it

func (b *board) nextGen() [][]bool {
	state := make([][]bool, b.height)

	for y := 0; y < b.height; y++ {
		state[y] = make([]bool, b.width)

		for x := 0; x < b.width; x++ {
			n := b.countNeighbours(x, y)

			if b.cells[y][x] {
				state[y][x] = n == 2 || n == 3
			} else {
				state[y][x] = n == 3
			}
		}
	}

	return state
}

We can see here this evaluates our game of life rules and sets the state for each cell. Let’s modify this a little to reflect our ant and add a new struct to hold that information

const (
	NORTH int = iota
	EAST  int = iota
	SOUTH int = iota
	WEST  int = iota
)

type ant struct {
	x         int
	y         int
	direction int
}

func (a *ant) turn(cell bool) bool {
	if cell {
		a.direction = (a.direction + 1) % 4
	} else {
		a.direction -= 1
		if a.direction < 0 {
			a.direction = WEST
		}
	}
	return !cell
}

func (a *ant) step(maxWidth, maxHeight int) {
	switch a.direction {
	case NORTH:
		a.y -= 1
		if a.y < 0 {
			a.y = maxHeight - 1
		}
	case EAST:
		a.x = (a.x + 1) % maxWidth
	case SOUTH:
		a.y = (a.y + 1) % maxHeight
	case WEST:
		a.x -= 1
		if a.x < 0 {
			a.x = maxWidth - 1
		}
	}
}

type board struct {
	cells  [][]bool
	width  int
	height int
	ant    ant
}
//...


func (b *board) nextGen() [][]bool {
	state := make([][]bool, b.height)

	for y := 0; y < b.height; y++ {
		state[y] = make([]bool, b.width)

		for x := 0; x < b.width; x++ {
			state[y][x] = b.cells[y][x]
		}
	}
	b.ant.step(len(b.cells), len(b.cells[0]))
	state[b.ant.y][b.ant.x] = b.ant.turn(state[b.ant.y][b.ant.x])

	return state
}

I’ve also changed the size of the grid and added an initialisation for our little ant in the newBoard function.

If all goes to plan you should get something like this Look at him go!

Ok, now, let’s break the rendering part down here

Rendering the grid

This is a pretty simple grid rendering, and just means figuring out a box size and rendering each cell appropriately.

Let’s break down the draw method where this happens

func (g *gameRenderer) draw(w, h int) image.Image {
    // Get our image object 
	img := g.imgCache
    // Draw over it with our background colour
	if img == nil || img.Bounds().Size().X != w || img.Bounds().Size().Y != h {
		img = image.NewRGBA(image.Rect(0, 0, w, h))
		g.imgCache = img
	}

    // Loop over each cell in our grid and draw it onto the image object
	for y := 0; y < h; y++ {
		for x := 0; x < w; x++ {
			xpos, ypos := g.game.cellForCoord(x, y, w, h)

			if xpos < g.game.board.width && ypos < g.game.board.height && g.game.board.cells[ypos][xpos] {
				img.Set(x, y, g.aliveColor)
			} else {
				img.Set(x, y, g.deadColor)
			}
		}
	}

	return img
}

Pretty bog standard so far. If you’ve ever used the Graphics2D library in Swing then this will be pretty familiar. Let’s go a little further up and see how this is invoked

func (g *game) CreateRenderer() fyne.WidgetRenderer {
	renderer := &gameRenderer{game: g}

	render := canvas.NewRaster(renderer.draw)
	renderer.render = render
	renderer.objects = []fyne.CanvasObject{render}
	renderer.ApplyTheme()

	return renderer
}

Here we see that we’re handing in the draw method as a parameter to Fyne’s canvas.NewRaster function. This means it’s called automatically by Fyne and will update as needed. Also of note here is the fyne.WidgetRender return type of this method, which allows us to just treat this as a normal widget subject to Fyne’s layouts and what not

type WidgetRenderer interface {
    Layout(Size)
    MinSize() Size

    Refresh()
    BackgroundColor() color.Color
    Objects() []CanvasObject
    Destroy()
}

Finally, let’s have a look at the Show function.

// Show starts a new ant
func Show(app fyne.App) {
    // Initialise our world state
    board := newBoard()
    // forgive the naming, stolen directly from the example code
    game := newGame(board)
    // Create our top level window
    window := app.NewWindow("Life")
    // Create our pause button
    pause := widget.NewButton("Pause", func() {
        game.paused = !game.paused
    })
    // Set the main window's content. The main component here is a border layout component,
    // with our grid in the middle and a button in the south position
    window.SetContent(fyne.NewContainerWithLayout(layout.NewBorderLayout(nil, pause, nil, nil), pause, game))
    // Set up our event handler. This method essentially pause on a spacebar
    window.Canvas().SetOnTypedRune(game.typedRune)

    // start the board animation before we show the window - it will block
    game.animate()

    window.ShowAndRun()
}

Nice and familiar feeling library. Lovely.

Adding a step button

Let’s add a new button that lets us step our ant. Firstly, I’m going to pull out a method to step the world once

// Pulled out of the animate method
func (g *game) stepWorld() {
	state := g.board.nextGen()
	g.board.renderState(state)
	widget.Refresh(g)
}

Next, we’ll create our button widget in the Show function

step := widget.NewButton("Step", func() {
		game.stepWorld()
	})

We’ll create our layout and button container with the two buttons

buttonLayout := layout.NewGridLayout(2)
buttonContainer := fyne.NewContainerWithLayout(buttonLayout, pause, step)

And we’ll modify the SetContent call to use the button container instead of the single button

window.SetContent(fyne.NewContainerWithLayout(
    layout.NewBorderLayout(nil, buttonContainer, nil, nil), buttonContainer, game))

And you can see here that we now have our second stepper button. Excellent! Step and pause, awesome!

Thoughts for now

Fyne is nice and familiar, and if you watch the linked video above you can see its bindings in action and a few other nice features. I think it’s got a simple enough interface that it isn’t frustrating for most of what I want to do, but at the same time it’ll be interesting to see what happens if I try to build a bigger application on top of it and whether the code layouts scale in a sensible way.