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
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
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
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!
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.