~gioverse/skel

Experimental application framework for https://gioui.org
window: [hotfix] drain bus conn on window close
deps: update to latest gio and gio-x
future: provide a convenient variant using a default context

refs

main
browse  log 

clone

read-only
https://git.sr.ht/~gioverse/skel
read/write
git@git.sr.ht:~gioverse/skel

You can also use your local clone with git send-email.

#skel

Go Reference

Skel is an experimental application framework for Gio.

There is a non-trivial example application in the ./example folder demonstrating its use.

Skel provides several useful abstractions for building Gio applications. They are designed to work together, but can be used a la carte as well.

#bus.Bus and bus.Connection

The interfaces in the bus package provide applications with two important capabilities:

  • the ability to easily dispatch asynchronous work
  • the ability to send and recieve events across the entire application

A tricky aspect Gio development has always been managing the interactions between the state that an application is presenting and modification to that state. Performing expensive state updates (like HTTP requests to an API) on the layout goroutine results in terrible UI performance. Directly modifying the state of the UI from a new goroutine doing the state update results in race conditions.

The bus.Connection type provides the Loopback, Broadcast, and Omnicast methods as threadsafe mechanisms to send messages from any goroutine to just one connection, every connection but the one in use, or all connections (respectively). Your application can invoke expensive state updates on new goroutines, send the results with these methods, and then wait for the results of the state update to arrive over the bus.Connection.Output() channel.

Here's a simple Gio window event loop making use of this technique to choose a number asynchronously and add it to the current window's state:

type NewNumberEvent int

func loop(w *app.Window, conn bus.Connection) error {
	th := material.NewTheme(gofont.Collection())
	var (
		ops op.Ops
		add widget.Clickable
	)
	currentNumber := 5

	for {
		select {
		case event := <-w.Events():
			switch event := event.(type) {
			case system.DestroyEvent:
				return event.Err
			case system.FrameEvent:
				if add.Clicked() {
					// Schedule asynchronous work to fetch an new number. In real applications,
					// these are often expensive and blocking operations.
					go func() {
						// Sleep to simulate expensive work like database
						// interactions, I/O, etc...
						time.Sleep(time.Millisecond * 200)
						conn.Loopback(NewNumberEvent(rand.Intn(100) + 1))
					}()
				}
				// Lay out the UI here
				gtx := layout.NewContext(&ops, event)
				layout.Flex{
					Axis: layout.Vertical,
				}.Layout(gtx,
					layout.Rigid(
						material.H1(th, strconv.Itoa(currentNumber)).Layout,
					),
					layout.Rigid(material.Button(th, &add, "Add").Layout),
				)
				event.Frame(&ops)
			}
		case update := <-conn.Output():
			// We got an update from the bus. Update application state
			// accordingly.
			switch update := update.(type) {
			case NewNumberEvent:
				currentNumber += int(update)
				w.Invalidate()
			}
		}
	}
}

You can run the above example with go run ./example/readme/bus.

Applications should construct a bus.Bus in main (or at startup), and then pass each window a bus.Connection from that bus's Connect() method. Orchestrating those connections for multiple windows might sound like a pain, but the next abstraction makes it a breeze.

#window.Windower

To make multi-window Gio applications easier, skel provides a type that manages the entire lifecycle of Gio windows. The window.Windower is created by wrapping a bus.Bus, and it listens to that bus and responds to window-management requests sent on the bus.

To create a window, the Windower looks for window.CreateWindowRequests on the application bus. The CreateWindowRequest provides a window.WindowFunc and a set of app.Option that should be provided to Gio when constructing the window. WindowFunc's signature should be familiar from the example above: func (*app.Window, bus.Connection) error. Any event loop function like the one above can be provided, and it will be launched in a new window.

Since constructing window.CreateWindowRequests isn't especially ergonomic, skel provides the window.NewWindow and window.NewWindowForBus helper functions. These will construct and send the request for you, provided they are given the proper type from package bus, a window.WindowFunc, and (optionally) some app.Options.

Here's how to make the previous example into a multi-window application (code that did not change has been ellided):

func main() {
	bus := bus.New()
	w := window.NewWindower(bus)
	go func() {
		// Wait for all windows to close, then exit.
		w.Run()
		os.Exit(0)
	}()
	window.NewWindowForBus(bus, loop)
	app.Main()
}

func loop(w *app.Window, conn bus.Connection) error {
    // ...
	var (
		ops            op.Ops
		add, newWindow widget.Clickable // Add a new button.
	)
	// ...

	for {
		select {
		case event := <-w.Events():
			switch event := event.(type) {
			// ...
			case system.FrameEvent:
				if add.Clicked() {
    				// ...
				}
				if newWindow.Clicked() {
					// Launch a new window running another copy of this event
					// loop.
					window.NewWindow(conn, loop)
				}
				// Lay out the UI here (laying out the new button has been ellided)
				// ... 
			}
		case update := <-conn.Output():
			// Handle any requests to modify the window that came over the bus.
			window.Update(w, update)
            // ...
		}
	}
}

You can run this example with go run ./example/readme/windower.

Notice how only one of the windows update when you click the "Add" button in any window. Try changing the call of bus.Connection.Loopback to bus.Connection.Omnicast. This will send the event to all connections, not just the one you invoke it on. Every window will now increment when you click add in any window.

#router.Page and router.Router

Another useful skel abstraction has to do with switching the contents of a single Gio window. Often your application will have multiple "screens", "views", or "pages" of content that the user navigates between. The router.Page interface is designed to encapsulate that idea.

router.Page exposes two methods. One updates the state of the page by passing new state (usually read from the application bus), returning whether the page changed. The other lays out the page. That's it. Here's a simple app using a page. Note how simple the event loop has become.

func loop(w *app.Window, conn bus.Connection) error {
	var ops op.Ops
	page := NewMyPage(conn)

	for {
		select {
		case event := <-w.Events():
			switch event := event.(type) {
			case system.DestroyEvent:
				return event.Err
			case system.FrameEvent:
				// Lay out the UI here
				gtx := layout.NewContext(&ops, event)
				page.Layout(gtx)
				event.Frame(&ops)
			}
		case update := <-conn.Output():
			// Handle any requests to modify the window that came over the bus.
			window.Update(w, update)
			// Check for application state updates and handle them.
			if page.Update(update) {
				w.Invalidate()
			}
		}
	}
}

// MessageEvent is an event indicating that a new message has
// been chosen.
type MessageEvent string

// MyPage is a simple router.Page that displays a message and
// has a button to request a new message.
type MyPage struct {
	*material.Theme
	message string
	btn     widget.Clickable
	bus.Connection
}

// NewMyPage constructs a simple page that displays a message.
func NewMyPage(conn bus.Connection) *MyPage {
	return &MyPage{
		Connection: conn,
		Theme:      material.NewTheme(gofont.Collection()),
		message:    "hello",
	}
}

// Update the page's state.
func (m *MyPage) Update(data interface{}) bool {
	// Check if the data is a type we care about.
	switch data := data.(type) {
	default:
		return false
	case MessageEvent:
		// Update our message.
		m.message = string(data)
	}
	return true
}

// Layout the page.
func (m *MyPage) Layout(gtx C) D {
	if m.btn.Clicked() {
		// Schedule some work. We'll get the results in the Update() method.
		go func() {
			m.Connection.Loopback(MessageEvent("It's " + time.Now().Format("15:04:05")))
		}()
	}
	return layout.Flex{
		Axis:    layout.Vertical,
		Spacing: layout.SpaceAround,
	}.Layout(gtx,
		layout.Rigid(material.H1(m.Theme, m.message).Layout),
		layout.Rigid(material.Button(m.Theme, &m.btn, "New message").Layout),
	)
}

You can try this application with go run ./example/readme/singlepage/.

We also provide a router.Router that can display one of several router.Pages. It maintains history so that users can go "back", and it implements router.Page so that it can be used anywhere a page can be.

See ./example/page/ui.go for how to use the router. go run ./example to see a larger application leveraging all of the skel abstractions together.