~gioverse/skel

Experimental application framework for https://gioui.org
stream: add ProviderRebuilder to replace multiplex
stream: define reference counting mechanism to share streams
stream: define split helper on results

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.

It has been through a number of revisions as we've worked to learn how to build larger and more composable Gio applications.

The first iteration of skel used exclusively packages bus, router, and window to build applications. Parts of your application could send messages to one another over the bus, and windows could manage their contents using routers.

The second iteration introduced package future, providing a somewhat easier means of kicking off asynchronous communication from the event loop of an application window.

The current iteration adds package stream, providing a general purpose way to consume data from asynchronous sources directly within layout. Streams are new, and have the potential to replace the other constructs provided by this module. Time will tell.

The following subheadings describe each set of concepts and their intended use.

#Streams

Your GUI wants to display information from asynchronous sources sometimes, but it can be difficult to connect asynchronous data with the layout goroutine without generating race conditions or displaying stale data. A stream is essentially a special varible that you can read from the layout goroutine without blocking. The variable will always provide the most recent results of an asynchronous computation pipeline. This allows you to write layout code depending upon the results from a stream and receive the latest data each frame.

Streams powering visible widgets must be read from every frame. We don't want to waste resources on streams that update widgets which aren't being displayed, so streams are designed to shut down when their widget is not visible. We determine this by checking (once per frame) which streams have been read since the last check. Any stream that has not been read during the frame will go inert until it is read again.

Streams for any given application window are powered by a stream.Controller. This type connects the managed streams to the frame lifecycle of the window.

Construct a controller with:

func NewController(ctx context.Context, invalidator func()) *Controller

The provided ctx can be cancelled to shut down all streaming work for the window, and the provided invalidator function will be used by the controller to ensure the window generates a new frame when active streams emit new values. Proper use might look like this;

func loop(w *app.Window) error {
	// Make a context that lives as long as the window.
	windowCtx, cancel := context.WithCancel(context.Background())
	defer cancel()
	// Make a controller for this window.
	controller := stream.NewController(windowCtx, w.Invalidate)

	var ops op.Ops
	for {
		switch event := w.NextEvent().(type) {
		case system.DestroyEvent:
			return event.Err
		case system.FrameEvent:
			gtx := layout.NewContext(&ops, event)

			// Your layout here, passing the controller so that code can instantiate streams with it.
			layoutUI(gtx, controller)

			event.Frame(gtx.Ops)

			// Transition any active unread streams to be inactive.
			controller.Sweep()
		}
	}
	return nil
}

A stream is created like so:

// Make a stream that will emit increasing integers every second.
myStream := stream.New(controller, func(ctx context.Context) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		ticker := time.NewTicker(time.Second)
		defer ticker.Stop()
		ticks := 0
		for {
			select {
			case <-ticker.C:
				ticks++
				out <- ticks
			case <- ctx.Done():
				return
			}
		}
	}()
	return out
})

The parameters for the constructor are the controller which will manage the stream's lifecycle (connecting this stream to the parent window and ensuring that it shuts down correctly when not in use) and a function used to start the stream when it is active. That function is provided with a context that will be cancelled when the stream goes inert, and is expected to return a receive-only channel of values that will be closed when the context is cancelled. Within this function, you can perform arbitrary asynchronous computation, returning the final channel in a complex asynchronous pipeline.

To read a stream, you can invoke one of several methods prefixed with Read.

ticks, status := myStream.Read(gtx)
if status == stream.Waiting {
	// We haven't received a value over the stream yet.
} else {
	// We have a value, so do something with ticks.
}

If the exact status of the stream isn't important for your purposes, the ReadDefault and ReadInto methods are more ergonomic.

ticks := myStream.ReadDefault(gtx, 0)
var ticks int // Assume we declared this elsewhere, perhaps as a field.
myStream.ReadInto(gtx, &ticks, 0)

Here's a complete example that displays a label counting up once each second:

func tickStreamProvider(ctx context.Context) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		ticker := time.NewTicker(time.Second)
		ticks := 0
		for {
			select {
			case <-ticker.C:
				ticks++
				out <- ticks
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

func loop(w *app.Window) error {
	// Make a context that lives as long as the window.
	windowCtx, cancel := context.WithCancel(context.Background())
	defer cancel()
	// Make a controller for this window.
	controller := stream.NewController(windowCtx, w.Invalidate)
	tickStream := stream.New(controller, tickStreamProvider)

	th := material.NewTheme()
	th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
	ticks := 0

	var ops op.Ops
	for {
		switch event := w.NextEvent().(type) {
		case system.DestroyEvent:
			return event.Err
		case system.FrameEvent:
			gtx := layout.NewContext(&ops, event)

			tickStream.ReadInto(gtx, &ticks, 0)
			material.H1(th, strconv.Itoa(ticks)).Layout(gtx)

			event.Frame(gtx.Ops)

			// Transition any active unread streams to be inactive.
			controller.Sweep()
		}
	}
}

You can try this example yourself with go run ./example/readme/stream.

For a more sophisticated example (simulating the users list of a chat application with users going on/offline, changing their usernames, etc...) see go run ./example/readme/stream2 and the corresponding source code.

See the package godoc for more detailed usage info and special types making error handling easier.

The stream API is a result of collaboration among Pedro Leite Rocha, Jack Mordaunt, and Chris Waldon. Thanks to Pedro for asking why we couldn't have nicer things.

#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()
	th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
	var (
		ops op.Ops
		add widget.Clickable
	)
	currentNumber := 5

	events := make(chan event.Event)
	acks := make(chan struct{})

	go func() {
		for {
			ev := w.NextEvent()
			events <- ev
			<-acks
			if _, ok := ev.(system.DestroyEvent); ok {
				return
			}
		}
	}()

	for {
		select {
		case event := <-events:
			switch event := event.(type) {
			case system.DestroyEvent:
				acks <- struct{}{}
				return event.Err
			case system.FrameEvent:
				gtx := layout.NewContext(&ops, event)
				if add.Clicked(gtx) {
					// 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
				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)
			}
			acks <- struct{}{}
		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 {
	th := material.NewTheme()
	th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
	var (
		ops            op.Ops
		add, newWindow widget.Clickable
	)
	currentNumber := 5

	events := make(chan event.Event)
	acks := make(chan struct{})

	go func() {
		for {
			ev := w.NextEvent()
			events <- ev
			<-acks
			if _, ok := ev.(system.DestroyEvent); ok {
				return
			}
		}
	}()

	for {
		select {
		case event := <-events:
			switch event := event.(type) {
			case system.DestroyEvent:
				acks <- struct{}{}
				return event.Err
			case system.FrameEvent:
				gtx := layout.NewContext(&ops, event)
				if add.Clicked(gtx) {
    				// ...
				}
				if newWindow.Clicked(gtx) {
					// 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)
				// ...
				event.Frame(&ops)
			}
			acks <- struct{}{}
		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)

	events := make(chan event.Event)
	acks := make(chan struct{})

	go func() {
		for {
			ev := w.NextEvent()
			events <- ev
			<-acks
			if _, ok := ev.(system.DestroyEvent); ok {
				return
			}
		}
	}()

	for {
		select {
		case event := <-events:
			switch event := event.(type) {
			case system.DestroyEvent:
				acks <- struct{}{}
				return event.Err
			case system.FrameEvent:
				// Lay out the UI here
				gtx := layout.NewContext(&ops, event)
				page.Layout(gtx)
				event.Frame(&ops)
			}
			acks <- struct{}{}
		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 {
	th := material.NewTheme()
	th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
	return &MyPage{
		Connection: conn,
		Theme:      th,
		message:    "hello",
	}
}

// Update the page's state.
func (m *MyPage) Update(data any) 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(gtx) {
		// 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.