mjl blog
feed
April 17th 2018

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:

NameDescription
“Leaf” UIs
ButtonSingle button with text, choice of colorsets, optional icon, click handler.
ButtongroupMultiple buttons of which one is active.
CheckboxJust the checkbox and a boolean representing the state.
RadiobuttonThe radiobutton, and the group it is part of.
EditPowerful multi-line text editor with advanced mouse control and a vi mode.
FieldSingle-line input field.
ImageVery basic UI that displays an image. Does not scale images (yet).
LabelMulti-line read-only text.
 
Hybrid between “leaf” and “container” UIs
ListList of text values with single or multi-select.
GridlistLike list, but with a an array of text values displayed as a grid.
 
“Container” UIs
BoxLays out UIs from left to right, wrapping to next lines.
GridTable-like layout.
MiddleVertically and horizontally centers its single child.
PickFor 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.
PlaceFor layouts with absolute positioning. Calls your function with available size, which must do the layout.
ScrollDraws scrollbar and scrolls the contents of its single kid. Only vertical for now.
SplitVertical 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.
TabsConsists 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.

Comments

go+gui=love
Nice! I believe https://github.com/google/gxui has windows support and other goodies that could be raided
Benjamin
This is truly encouraging! 

What an amazing piece of work you’ve shown here. 

I will definitely play with it and if I can contribute anything back, I will
aprice2704
Love the idea of pure-Go ui -- much needed imo.
Everything worked nicely once I had plan9port  compiled (Linux mint), for which I had to do (if memory serves):
```
sudo su -
cd /usr/local
git clone https://github.com/9fans/plan9port plan9
apt install libxt-dev
apt install libfreetype6-dev
apt install libfontconfig1-dev
apt install fonts-lato
cd plan9
./INSTALL
```
then added
```
export PLAN9=/usr/local/plan9 export PLAN9
export PATH=$PATH:$PLAN9/bin export PATH
export font="/mnt/font/Lato Regular/12a/font"
```
to my .bashrc.

After that, I could cd to example directories, go build & run examples nicely (from CLI, but not File Manager oddly -- pipe issues?) *after* starting fontsrv with "fontsrv &"

(for the uninitiated, the font path above is a virtual path served by fontsrv. This puzzled me for a while :/ )

hth

Andy
Werner
there have been some forks also, e.g. look at github.com/as/shiny which might come close to what you do, or github.com/aarzilli/nucular which is an immediate mode (imgui) in pure go based on shiny.
mjl
good point, i'm definitely going to look at shiny more closely and maybe borrow some code. (: it seems shiny has many of the cross-platform problems solved. it can also use its x11 backend. i think i remembered from shiny that such an approach was possible. still, easiest for duit if the 9fans.net/go/draw library interface can be implemented cleanly.
devdraw does quite a lot, including font rendering.
Werner
Looks great!
Would it be possible to implement a windows backend (or general cross-platform portability) using golang.org/x/exp/shiny?
Or is it using a different concept?