Announcing duit: developer ui toolkit
Hi! In this blog post I’m announcing duit, which is a
- pure go (*)
- cross-platform
- MIT-licensed
- UI toolkit
- for developers
It is pure Go – no C code or external library involved. The asterisk represents the runtime dependence on a helper tool. It provides duit with cross-platform drawing and mouse/keyboard interaction. More details later.
Cross-platform means duit applications run on the BSDs and Linux (X11), macOS (native drawing) and probably Plan 9. No proper Windows support yet, but it will happen, and in the mean time you can use duit through the Windows subsystem for Linux and an X server.
Look & feel
A video is worth a million words. Pay close attention and you’ll see in quick succession these applications created with duit: duitfiles, duitmap, duitsql, duitmail, acvi.
The video reveals a modern look & feel:
- buttons with rounded corners
- fresh colors — it’s the bootstrap4 color scheme!
- use of fontawesome5 icons
Ergo, duit is very webcool. But also different:
- it’s basic; no UI fluff, no animations
- simple non-web API
Why a new UI toolkit?!
The Go 2017 Survey results tell us there is a need for a UI library. Here’s an excerpt:
As in 2016, the most commonly requested missing library for Go is one for writing GUIs though the demand is not as pronounced as last year. No other missing library registered a significant number of responses.
Several attempts at a UI library exist. Some ceased development. Some presumably work but aren’t pure Go: wrappers around either cross-platform libraries like GTK & QT or platform-native UI libraries. Some depend on a huge web browser stack. I wanted a simple, pure Go, cross-platform library.
It sounds silly to seriously try writing a new UI library. It would take an enormous amount of time which I didn’t have with a full-time job. I told myself I just wanted to see how quickly and where I would strand. And then this happened:
On the first evening of actually trying to write some code, I was lucky enough to remember how acme does its drawing: with devdraw. And as if that wasn’t enough, I quickly ran into the 9fans.net/go/draw library by Russ Cox, it interfaces with devdraw. In literally only two hours I had my first button drawn! Very encouraging. And progress has been quick from then on: Most of duit was written in the two weeks around last year’s Christmas. With applications and subsequent library improvements written in the evenings of January. It’s been quiet since then, but I should have more time available soon.
Code
OK, you’re a programmer, not distracted by screenshots. Code matters. So here’s a fairly minimal program, richly annotated, that uses duit to display the following login screen:
package main
import (
"image"
"log"
"time"
"github.com/mjl-/duit"
)
func main() {
// create a new "developer ui"; this opens a new window.
// "ex/basic" is the window title. nil are options.
dui, err := duit.NewDUI("ex/basic", nil)
if err != nil {
log.Fatalf("new duit: %s\n", err)
}
// assign some UIs to variables, so we can reference
// and modify them in the button click handler.
status := &duit.Label{Text: "status: not logged in yet"}
username := &duit.Field{}
password := &duit.Field{Password: true}
login := &duit.Button{
Text: "Login",
Colorset: &dui.Primary,
Click: func() (e duit.Event) {
// click is called from the "main loop",
// which is the for-loop at the end of main.
// it is safe to modify the ui state from here.
// be sure to call MarkLayout or MarkDraw after changing a UI.
status.Text = "status: logging in ..."
dui.MarkLayout(status)
go func() {
// pretend to do a slow web api call
time.Sleep(1 * time.Second)
// this goroutine is not executed from the main loop.
// so it's not safe to modify UI state.
// we send a closure that modifies state on dui.Call,
// which will pass it on to dui.Input, which our main loop
// oop picks up and runs.
dui.Call <- func() {
status.Text = "status: logged in"
password.Text = ""
dui.MarkLayout(nil) // we're lazy, update all of the UI
}
}()
return
},
}
// dui.Top.UI is the top-level UI drawn on the dui's window
dui.Top.UI = &duit.Box{
Padding: duit.SpaceXY(6, 4), // inset from the window
Margin: image.Pt(6, 4), // space between kids in this box
// duit.NewKids is a convenience function turning UIs into Kids
Kids: duit.NewKids(
status,
&duit.Grid{
Columns: 2,
Padding: []duit.Space{
{Right: 6, Top: 4, Bottom: 4},
{Left: 6, Top: 4, Bottom: 4},
},
Valign: []duit.Valign{duit.ValignMiddle, duit.ValignMiddle},
Kids: duit.NewKids(
&duit.Label{Text: "username"},
username,
&duit.Label{Text: "password"},
password,
),
},
login,
),
}
// first time the entire UI is drawn
dui.Render()
// our main loop
for {
// where we listen on two channels
select {
case e := <-dui.Inputs:
// inputs are: mouse events, keyboard events, window resize events,
// functions to call, recoverable errors
dui.Input(e)
case warn, ok := <-dui.Error:
// on window close (clicking the X in the top corner),
// the channel is closed and the application should quit.
// otherwise, err is a warning or recoverable error.
if !ok {
return
}
log.Printf("duit: %s\n", warn)
}
}
}
Concepts
There’s more to say on concepts than fits in one attention span, so I’ll stick to mentioning just two concepts.
But before I do, in order to understand the text below, you need to know how I’m using the word “UI” from now on. When I say UI, I don’t mean “user interface”. Instead I mean “a type implementing the duit.UI interface”. All of duits user interface “widgets” – a word I don’t like and avoid – implement the UI interface. Whether it’s a duit.Button, or a container component such as a duit.Grid. It’s a UI.
1. Simple API
Simplicity is the first concept. It keeps the API understandable and makes writing programs with a graphical user interface bearable.
How does “simple API” translate into a Go user interface library?
- Creating a UI instance is just creating a struct. Zero-valued UI structs have reasonable behaviour.
- UIs are very loosely coupled, and self-contained. UIs don’t hold a reference to their parent UI. UIs only have reference to their children — in a struct Kid with some additional accounting information. You can take a UI out of the currently visible user interface, and put them back in later: Just plain variable assignments.
- You are in control of the “main loop”. I don’t like libraries where a library provides a “main” function and you no longer know what’s happening.
- No locking necessary. You’ll normally modify the UIs only from your own main loop. You send closures making UI modifications to your main loop.
One important consequence of this simplicity: You have to tell duit which UIs have changed, through MarkLayout and MarkDraw calls after you’ve modified a UI. No call? No screen updates.
2. Unified focus & hover and mouse warping
“Focus” and “hover” are the same concept in duit. That’s one concept less to worry about! No need to click to type – focus follows the mouse – as illustrated by this looping video:
This leads us to the second duit concept: mouse warping. Not very common in ui libraries. I took this convenient idea from acme. The idea is to warp the mouse pointer to where you will focus your eyes. For example, when opening a new file in acme – think of it as a tiled window manager if you don’t know what it is– the new window is automatically placed in an appropriate location. Your eyes will go there: they want to read or edit the text. So acme warps the mouse to that window for you. No need to make that movement yourself.
In duit applications, when you hit the indent key (tab) to move focus to the next UI, the mouse warps. Automatically placing focus on that UI. Your eyes will be there too. So the followup click can be quick.
But it’s not just duit library functionality. Applications should implement this concept as well. For example, in a database client, when selecting a database connection, and hitting the “connect”-button, the application should warp the mouse location to where your eyes will go: the list of tables available in the database.
Mouse warping lets you navigate well-known user interfaces quickly: clicks in quick succession – perhaps interspersed with some tabs – execute the suggested action. Again, a quick example:
UI interface
Finally we get to implementation details.
This is the interface all UIs implement:
type UI interface {
Layout(dui *DUI, self *Kid, sizeAvail image.Point, force bool)
Draw(dui *DUI, self *Kid, img *draw.Image, orig image.Point, m draw.Mouse, force bool)
Mouse(dui *DUI, self *Kid, m draw.Mouse, origM draw.Mouse, orig image.Point) (r Result)
Key(dui *DUI, self *Kid, k rune, m draw.Mouse, orig image.Point) (r Result)
FirstFocus(dui *DUI, self *Kid) (warp *image.Point)
Focus(dui *DUI, self *Kid, o UI) (warp *image.Point)
Mark(self *Kid, o UI, forLayout bool) (marked bool)
Print(self *Kid, indent int)
}
The first 4 functions do most of the heavy lifting. They will take most of the little effort of implementing a new UI.
Most functions take a DUI as parameter: A window with a user interface. It is passed explicitly so UIs don’t have to keep that state, and can stay more standalone. All functions take a Kid as parameter. It represents the state of a UI in a DUI: its location in another UI, and whether it is marked dirty for layout/draw.
Read the UI documentation for the full story.
UIs
At the time of writing, duit comes with these UIs:
Name | Description |
---|---|
“Leaf” UIs | |
Button | Single button with text, choice of colorsets, optional icon, click handler. |
Buttongroup | Multiple buttons of which one is active. |
Checkbox | Just the checkbox and a boolean representing the state. |
Radiobutton | The radiobutton, and the group it is part of. |
Edit | Powerful multi-line text editor with advanced mouse control and a vi mode. |
Field | Single-line input field. |
Image | Very basic UI that displays an image. Does not scale images (yet). |
Label | Multi-line read-only text. |
Hybrid between “leaf” and “container” UIs | |
List | List of text values with single or multi-select. |
Gridlist | Like list, but with a an array of text values displayed as a grid. |
“Container” UIs | |
Box | Lays out UIs from left to right, wrapping to next lines. |
Grid | Table-like layout. |
Middle | Vertically and horizontally centers its single child. |
Pick | For responsive layouts: Calls your function on change of available screen size, so you can adjust its UIs, e.g. change a Split from being vertical to horizontal. |
Place | For layouts with absolute positioning. Calls your function with available size, which must do the layout. |
Scroll | Draws scrollbar and scrolls the contents of its single kid. Only vertical for now. |
Split | Vertical or horizontal split (flip a boolean) of one or more child UIs. If a gutter is specified, the user can drag it to change dimensions. Duit remembers. |
Tabs | Consists of a Buttongroup and displays the associated UI of the currently active button. |
Even more UIs have not yet been implemented. Here are a few:
- Slider
- Progress bar
- Editable list & grid
- HTML viewer
- …
Write one. It’s easy & fun!
Devdraw
As mentioned earlier, duit gets its pure-go-ness and cross-platformness from using devdraw, which is neatly abstracted away for use in Go by the 9fans.net/go/draw library.
Devdraw is a unix stand-in for the Plan 9 graphics subsystem where it is available at /dev/draw. The go/draw library starts a new devdraw instance for each DUI, and then talks to it over a pipe. Sending it commands to draw pixels and text in all the right places. Devdraw sends back mouse and keyboard events.
Devdraw is part of Plan 9 from User Space, also known as plan9port. For Linux and BSD’s it has an X11 backend. For macOS, it has a Cocoa-based backend. There is no devdraw for Windows yet, although related programs (drawterm, Inferno) with almost identical underlying drawing libraries do have Windows support. It’s a matter of time.
Duit only depends on the go/draw library to do its drawing. A clearly demarcated interface. It can be replaced by one that talks to native graphics subsystems. Libraries to do so are probably already available, e.g. for X11. Duit can become truly pure Go — no asterisk!
Concluding
Duit lets you create applications with a graphical user interface the Go way.
Duit is still in its early stages. Lots of things to improve, change and break. But don’t let that stop you from giving it a try.
I’ll improve the demo programs. Start using them daily. That will reveal plenty of problems to fix. The duit code also needs a cleanup: It was written quickly to get passable-looking and basically working programs, but the code ain’t always pretty.
Later on – and I hope someone will beat me to it – devdraw support for Windows will follow, or native implementations of the go/draw library interface.
Code and installation instructions are at github.com/mjl-/duit. Questions? Feedback? Need more information to get started and write applications? Please let me know!
Want to help? The easiest way is to just write a program using duit and write about the experience.