~gioverse/skel

Experimental application framework for https://gioui.org
deps: update to latest gio with a11y support
scheduler: protect channel send with atomics
scheduler: document bus invariant

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.

#scheduler.Bus and scheduler.Connection

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

  • the ability to easily dispatch asynchronous work without managing the lifecycle of new goroutines
  • 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 scheduler package provides the {Message,Schedule{Local,}{Ctx,}} methods to schedule work onto a persistent pool of background goroutines. These goroutines are shared by the entire application, and queue work when they are all busy. Parts of your UI can invoke expensive state updates using these methods, and then wait for the results of the state update to be sent over the scheduler.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 scheduler.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.
					conn.Schedule(func() interface{} {
						// Sleep to simulate expensive work like database
						// interactions, I/O, etc...
						time.Sleep(time.Millisecond * 200)
						return 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 scheduler.Bus in main (or at startup), and then pass each window a scheduler.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 scheduler.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, scheduler.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 helper function. It will construct and send the request for you, provided that it is given a scheduler.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 := scheduler.NewWorkerPool()
	w := window.NewWindower(bus)
	go func() {
		// Wait for all windows to close, then exit.
		w.Run()
		os.Exit(0)
	}()
	window.NewWindow(bus, loop)
	app.Main()
}

func loop(w *app.Window, conn scheduler.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 all of the windows update when you click the "Add" button in any window, though each increments its own state. If you increment a window, then create a new one and increment it, you'll have two different sums that both update. Try changing the call to scheduler.Connection.Schedule (where we add to the number) to ScheduleLocal. This ensures that the results of the scheduled work are only emitted on the local bus connection instead of to every bus connection, and ensures that each window updates independently.

#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 scheduler.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
	scheduler.Connection
}

// NewMyPage constructs a simple page that displays a message.
func NewMyPage(conn scheduler.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.
		m.ScheduleLocal(func() interface{} {
			return 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.