Getting Started Overview

Practical cross-platform immediate-mode GUI framework for Go


package main

import (
   	app "go.hasen.dev/shirei/giobackend"

   	. "go.hasen.dev/shirei"
   	. "go.hasen.dev/shirei/tw"
   	. "go.hasen.dev/shirei/widgets"
)

func main() {
   	app.SetupWindow("Small Demo", 400, 200)
   	app.Run(RootView)
}

var name string
var response = "Please give me your name"
var colorBG bool = true

func RootView() {
   	ModAttrs(Gap(10))
   	if colorBG {
  		ModAttrs(BG(220, 20, 90, 1))
   	}

   	Layout(TW(Pad(20), Gap(10)), func() {
  		Label("Name:", FontWeight(WeightBold))
  		Layout(TW(Row, CrossMid, Gap(10)), func() {
 			TextInput(&name)
 			if Button(SymInfo, "Hello") {
				if name == "" {
   					response = "Uh, sorry who are you again?"
				} else {
   					response = "Well, hello " + name + "!"
				}
 			}
  		})
  		Label(response)
   	})

   	Layout(TW(Expand, Row, CrossMid, Pad2(10, 30), Gap(10), BG(0, 0, 98, 1),
        Shd(2), BW(1), Bo(0, 0, 0, 1)), func() {
  		ToggleSwitch(&colorBG)
  		Label("Subtle Color Background")
   	})
}

shi•rei is a practical GUI framework to create desktop applications as native Go programs, not as webpages, while having a high level of flexibility and fluidity!

The name "shi•rei" is derived from the Japanese spelling of "Simple Layout". It is also the reading of「司令」"Command".
ンプル・レイアウト → シレイ → 司令

Import package name: go.hasen.dev/shirei

Github repo: https://github.com/hasenj/go-shirei

Note: shi•rei is still work in progress! Some features are missing, and there are a few known issues. I think you can have fun playing around with it, but I would not recommend using it for production yet.

Getting Started

Copy the code sample above to a new file in an empty directory, then run the following to download the dependencies:

❯ go mod init main
❯ go mod tidy
❯ go run .

You can browse the demo directories on the github repo for more examples.

For instance, demo7 is shows misc controls, some of which are implemented in the file itself (the range picker).


ModAttrs(Pad(10), Gap(10), BG(60, 10, 90, 1))
ScrollOnInput()

Label(fmt.Sprintf("Active: %v", active))
ToggleSwitch(&active)

Nil()

Label(fmt.Sprintf("Range:  from: %f   to: %f", from, to))
RangePicker(&from, &to, rmin, rmax)

Label("Regular Text Input")
TextInput(&label)

Label("Password Input")
PasswordInput(&passwd)
Label(passwd, Sz(8), Clr(0, 0, 80, 0.5))

Label("Directory input")
DirectoryInput(&dirpath)

Label("Color", Sz(20), FontWeight(WeightBold), ClrV(color))
ColorInput(&color, colors)

Label("Click for a tool tip:")
Layout(TW(Row, Spacing(10)), func() {
    TooltipDemo("Hello", "This is just a greeting")
    TooltipDemo("World", "It means 世界!!")
})

TooltipHost()
PopupsHost()
        

Features

Immediate mode

Practical experience has shown that an immediate mode API is the only sane way to build GUI applications.

No one wants to write code to keep track of UI widgets and bidirectionally mirror their state with their application data.

Unfortunately, there's a lot of misunderstanding about what "immediate mode" means, so let's clarify what it means for shi•rei.

(1) No widget management

Your code does not need to care at all about UI widgets. You want to display a button and perform some action when it's clicked. You just say:

if Button("Click Here") {
    // take action when button is clicked
}

You want to iterate some list and for each item render a custom view with buttons, labels, and various elements. You just run a for loop:

for _, item := range myList {
    itemView(item)
}

When your data changes, you don't want to track all the previously created widgets and mutate them manually to put them in sync. You just render the view again.

(2) Widget state is managed by the system

Just because you don't manage widgets, doesn't mean they don't exist. The system knows about widgets and keeps track of their state for you. This means the system knows which elements can receive focus, so it can support cycling focus via the 'tab' key. It keeps track of text input fields so it can position the cursor, animate, apply selection, cut and paste, etc.

UI elements are assigned an automatic id based on their placement in the UI hierarchy, but the caller can override this by supplying their own ids.

When you supply an id for an element, it must be unique and stable.

Unique means no two elements produced in the same frame can share the same ID.

If two elements in the same view are assigned the same ID, the UI will behave erratically.

Stable means that you can use string literals, but should not use strings created dynamically on the fly.

(3) Events don't require callbacks

Input device state is just data that UI code can query like any other data on the system:

etc

(4) No special "state" variables

Web based UI frameworks like React tend to have a special kind of variables they call "state", that you cannot just modify like you would any other kind of variable in your program.

In React (and other web frameworks), when a "state" variable is modified, the UI must be updated, or at least a portion of it, so those changes can be reflected back to the user.

In addition, a "state" variable might have "dependencies", and you end up with a dependency tree that the system keeps track of.

Rendering "immediate mode" style means you don't have any of that. You always re-render the full UI, so you need not make a distinction between variables that affect the UI and variables that don't.

(5) Animations

A common misconception about immediate mode UI is that you cannot do things like animation, or that you have to write tons of boilerplate to make them work.

But like we mentioned earlier, shi•rei does internally keep track of widget states, so it can and does animate them!

shi•rei creates snappy animations when the UI state changes across frames, without any boilerplate code on the caller side.

Overview

Below we document the core aspects of the system. As the API is still in flux, we're focusing on the big picture, leaving out the details.

Containers & Layout System

shi•rei uses a flexbox-like model where all UI elements are created out of containers that can contain other containers.

Text and images are not a special type of container; just data that is "attached" to a container. A container can have both an image and child containers (the image becomes the "background").

Containers are positioned within their parent according to whether the parent has the "row" flag set. If it does, child elements are arranged horizontally, if it's not set, child elements are arranged vertically.

The "gap" and "padding" attributes on the parent further affect how children are positioned.


package main

import (
	app "go.hasen.dev/shirei/giobackend"

	. "go.hasen.dev/shirei"
	. "go.hasen.dev/shirei/tw"
)

func main() {
	app.SetupWindow("Layout DEMO", 700, 400)
	app.Run(rootView)
}

var box = TW(MinSize(100, 100), BG(220, 80, 40, 1), BR(2))
var cont = TW(Row, BW(1), Pad(20), Gap(10), Bo(0, 0, 0, 1), BG(50, 80, 80, 1))

func rootView() {
	Layout(cont, func() {
		Element(TWW(box, MinHeight(200)))
		Element(TWW(box))
		Element(TWW(box))
		Element(TWW(box))
	})
}
    

Sizing happens in two passes:

First, child elements are arranged within their parent, and after they are done, the parent's size is computed such that it can contain all the children.

This process propagates bottom to top: after the last child of each element is positioned and sized, its parent size is computed, and the same thing then happens at the parent level.

Next, "min size" attributes are applied, and the second sizing pass begins: each child element can grow or expand.

If an element sets the "expand" flag, its size on the cross-axis dimension of its parent is stretched to take the maximum available space.


package main

import (
	app "go.hasen.dev/shirei/giobackend"

	. "go.hasen.dev/shirei"
	. "go.hasen.dev/shirei/tw"
)

func main() {
	app.SetupWindow("Layout DEMO", 700, 400)
	app.Run(rootView)
}

var box = TW(MinSize(100, 100), BG(220, 80, 40, 1), BR(2), Pad(4))
var cont = TW(Row, BW(1), Pad(20), Gap(10), Bo(0, 0, 0, 1), BG(50, 80, 80, 1))

var lbl = TCompose(Sz(11), Clr(0, 0, 100, 1))

func rootView() {
	Layout(cont, func() {
		Layout(TWW(box, MinHeight(200)), func() {
			Label("height: 200", lbl)
		})
		Element(TWW(box))
		Layout(TWW(box, Expand), func() {
			Label("expand", lbl)
		})
		Element(TWW(box))
	})
}
    

When elements set the "grow" attribute, if there's space left on the main axis, that spaces is divided among those elements in proportion to how much "growth" they have requested. If element A sets Grow = 1, and element B sets Grow = 3, then the remaining space is given out such that B gets three times as much growth space as A.

In other words, while the first pass was about figuring out the minimum size required for an element to contain its children, the second pass is about growing the child's size if necessary to comply with attributes like min size, grow, and expand.

This process propagates top down. After each element's size is adjusted, its children are adjusted in turn.

Aligning child elements

Child elements can be aligned on both the main axis and cross axis.

For Row containers, the main axis is the horizontal axis, and the cross axis is the vertical axis.


package main

import (
	app "go.hasen.dev/shirei/giobackend"

	. "go.hasen.dev/shirei"
	. "go.hasen.dev/shirei/tw"
)

func main() {
	app.SetupWindow("Layout DEMO", 700, 400)
	app.Run(rootView)
}

var box = TW(MinSize(100, 100), BG(220, 80, 40, 1), BR(2), Pad(4))
var cont = TW(Row, BW(1), Pad(20), Gap(10), Bo(0, 0, 0, 1), BG(50, 80, 80, 1), Expand, CrossMid)

var lbl = TCompose(Sz(11), Clr(0, 0, 100, 1))

func rootView() {
	Layout(cont, func() {
		Layout(TW(Float(2, 2)), func() {
			Label("expand, cross-align: middle", Sz(11))
		})
		Layout(TWW(box, MinHeight(200)), func() {
			Label("height: 200", lbl)
		})
		Layout(TWW(box, Grow(1)), func() {
			Label("grow: 1", lbl)
		})
		Layout(TWW(box, Expand), func() {
			Label("expand", lbl)
		})
		Layout(TWW(box, Grow(3)), func() {
			Label("grow: 3", lbl)
		})
	})
}

    

Color and gradients

Background color, border color, and text color are specified as a Vec4, that is a [4]float32, with the 4 values denoting HSLA

Linear gradients are specified using the Gradient attribute, which is also a Vec4, but in order to make the Zero value mean no-gradient, it denotes a delta to be applied to the background. For example, to make the bottom part of the gradient the same as the top but a bit darker, you can use Vec4{0, 0, -10, 0}


pack        package main

import (
	app "go.hasen.dev/shirei/giobackend"

	. "go.hasen.dev/shirei"
	. "go.hasen.dev/shirei/tw"
)

func main() {
	app.SetupWindow("Gradient DEMO", 400, 300)
	app.Run(rootView)
}

var box = TW(MinSize(100, 100), BG(220, 80, 48, 1), BR(2), Pad(4))
var cont = TW(Row, Pad(20), Gap(10))

var lbl = TCompose(Sz(11), Clr(0, 0, 100, 1))

func rootView() {
	Layout(cont, func() {
		Layout(TWW(box), func() {
			Label("flat bg", lbl)
		})
		Layout(TWW(box, Grad(0, 0, -10, 0)), func() {
			Label("gradient", lbl)
			Label("light: -10", lbl)
		})
		Layout(TWW(box, Grad(0, -40, -10, -0.5)), func() {
			Label("gradient", lbl)
			Label("sturation: -30", lbl)
			Label("light: -10", lbl)
			Label("alpha: -0.5", lbl)
		})
	})
}
    

Floating

A child element can decide to "float" inside its parent. This means its position will no longer be calculated during layout, instead, it specifies where it wants to be positioned within its parent

Tailwind style attributes

Specifying attributes means building a struct like this:

Attrs {
    Row: true,
    Gap: 10,
    Padding: Vec4{10, 20, 30, 10},
    Background: Vec4{220, 50, 50, 1},
}

This can get very verbose, so we have an alternative approach: the TW (tailwind) function. It takes a series of "attribute modifier" functions.

TW(Row, Gap(10), Pad(10), BG(220, 50, 50 1))

An attribute function just takes a pointer to an attribute and does whatever it wants to it. The ones that take parmeters have to return a new function using closures.

func Row(a *Attrs) {
    a.Row = true
}

func Pad(v float32) AttrsFn {
    return func(a *Attrs) {
        a.Padding = N4(v)
    }
}

Text Input

Currently only a single-line text input field is implemented. It takes a *string in order to edit a string directly.

This is the power of immediate mode editing: you don't need to do anything special to "bind" a variable to a text input widget, or use a callback.

Just pass the pointer, and the system will handle it!

If someone else changes the string, it will be reflected in the input field, as in the following example:


package main

import (
    app "go.hasen.dev/shirei/giobackend"

    . "go.hasen.dev/shirei"
    . "go.hasen.dev/shirei/tw"
    . "go.hasen.dev/shirei/widgets"
)

func main() {
    app.SetupWindow("Text Input DEMO", 400, 300)
    app.Run(rootView)
}

var text string = "Hello?"

func rootView() {
    ModAttrs(Spacing(20))

    Label("Input:", FontWeight(WeightBold))
    TextInput(&text)

    if Button(0, "World") {
        text = "Hello World!"
    }
    if Button(0, "世界") {
        text = "Hello 世界!"
    }

    Label("Current Value:")
    Label(text)
}

The text input component itself is implemented as a component using the same layout system that you can use.

The blinking cursor is just a 1-pixel wide box that uses float positioning.

Blinking is achieved by having the alpha component of the box's background color alternate between 0 and 1

Popup Menu

We provide a function to create a popup panel attached to an element and controlled by a boolean variable.

But in order to use that function, you need to have put PopupHost() at the end of the root view function, or a defer PopupsHost() at the top (same thing).

Otherwise, the popup panel you create will not have any place to go, and it would in fact leak memory!

This is one of the areas where the API is still in flux; hopefully we will soon have a better solution!

The element the menu is attached to is meant to be the button that triggered it.

If you use the usually button pattern if Button("label") { ... } then the id of that button can be obtained using GetLastId().


package main

import (
   	app "go.hasen.dev/shirei/giobackend"

   	. "go.hasen.dev/shirei"
   	. "go.hasen.dev/shirei/tw"
   	. "go.hasen.dev/shirei/widgets"
)

func main() {
   	app.SetupWindow("Popup Demo", 300, 200)
   	app.Run(RootView)
}

var popup bool

func RootView() {
   	defer PopupsHost()

   	ModAttrs(Pad(20))

   	if Button(SymList, "Show Popup!") {
  		popup = !popup
   	}
   	PopupPanel(&popup, GetLastId(), TW(Spacing(10), BR(4)), func() {
  		Label("Hello Popups!")
   	})
}

Debug Panel

Printf debugging does not work well in UI because the values are changing frequently, and when you have several values that change across time as a function of user input, it's not so useful to have a huge debug log.

Instead, a debug panel shows the relevant variables in real time and how they change as you interact with UI.

Just like the popups panel, we need to call DebugPanel at the root view


var debugPanel bool

func RootView() {
	ModAttrs(Spacing(20))

	defer DebugPanel(debugPanel)

	Label("Magic Color Box!", Sz(20), FontWeight(WeightBold))

	DebugVar("mouse.x", InputState.MousePoint[0])
	DebugVar("mouse.y", InputState.MousePoint[1])

	Layout(TW(FixSize(360, 100), BR(10)), func() {
		var hue = Use[float32]("H")
		var light = Use[float32]("L")
		var origin = GetRenderData().ResolvedOrigin
		if IsHovered() {
			*hue = InputState.MousePoint[0] - origin[0]
			*light = 50 + ((InputState.MousePoint[1] - origin[1]) / 4)
			ModAttrs(Shd(2))

			DebugVar("hue", *hue)
			DebugVar("light", *light)
		}
		*light = max(50, *light)
		ModAttrs(BG(*hue, 60, *light, 1))
	})

	Layout(TW(Row, CrossMid, Gap(10)), func() {
		ToggleSwitch(&debugPanel)
		Label("Show Debug Panel")
	})
}

Frame Input and Input State

Inputs are not events, rather they are global state that can be queries. There are two types of inputs:

Cumulative state includes things like the current mouse position, what keys are down, etc. It's not a thing that specifically happened this frame.

Frame specific state is something that happened this frame, such as weather a mouse button was clicked, and if so, which, or if a keyboard button was pressed or released.

Hover/Click/Focus/Active container detection

The naive approach to hover detection would keep track of where containers were drawing last frame and check if the mouse position is within the bounds of their rectangle, but this would not work due to floating: elements can be drawn over other elements, and when that happens, they "block" the mouse pointer from hovering or clicking the element that is behind them.

Our system keeps track not only of element positions but also their hierarchy, so we can provide proper hover detection.

We provide the following function to robustly detect when an element is being hovered:

func IsHovered() bool
func IsHoveredDirectly() bool

You can call these inside the builder function to ask the system whether the container you're currently rendering is hovered. It will use data from the previous frame, which should be more than good enough for %99 of cases.

The difference between the two functions is that IsHovered returns true if any of the child containers is hovered, where is IsHoveredDirectly only return true when the mouse cursor is hovering the container but not any of its children.

Checking whether you got a click this frame requires check botht he hover state and whether the current frame had a click event:

func IsClicked() bool {
    return IsHovered() && FrameInput.Mouse == MouseClick
}

For buttons where you need to do a full press (click then release inside the same container) then you can use the function PressAction() which will return true when a press "gesture" is complete.

func PressAction() bool

Additionally, the function sets the current container as the "active" container in the duration between the mouse button being down and up.

You can query at anytime whether the current container is the active one using IsActive(), which can be used for example to implement dragging.

func IsActive() bool

Here's for example the implementation of the splitter from demo5:

func ViewSplitter(s *float32, row bool) {
    var sz = Vec2{splitterSize, splitterSize}
    Layout(TW(FixSizeV(sz), Expand, BG(0, 0, 70, 1)), func() {
        PressAction()
        if IsActive() {
            if row {
                *s -= FrameInput.Motion[0]
            } else {
                *s -= FrameInput.Motion[1]
            }
        }
    })
}

Scrollable content

If you set a maximum size on a container and set the clip flag, not all content will be visible.

You can implement scrolling for such a container by simply calling ScrollOnInput() as the first line in the container builder code.

See demo2 for example code.

Hooks: UI State, Side-Data

While we are not huge fans of how hooks in React ended up being used in practice, we believe there's the core of a good idea:

There are times where certain state is needed for the UI interaction but not needed anywhere else in the applicaiton.

In such cases, we might not want to "pollute" our application data with this UI-specific state.

For example, if you want to have a menu button to trigger a menu, there'a some boiler plate to setup as we saw in the example earlier, where you need to declare a boolean, change its state, etc. It's not much, but still adds a bit of friction everytime you want to add a menu button.

Hooks come in very handy here.

This is our implementation of a menu button helper:

func MenuButtonExt(label string, attrs ButtonAttrs, fn func()) {
    Layout(TW(), func() {
        type MenuState struct {
            open   bool
            btnId  any
            menuId any
        }
        var state = Use[MenuState]("menu-state")
        if ButtonExt(label, attrs) {
            state.open = !state.open
        }

[... trimmed ...]

The signature of the "hook" function is:

func Use[T any](itemKey any) *T

The important thing to note here is that it returns a pointer to the type you want to hook, and if this is the first call, then an instance will be created and a pointer to it will be returned.

The itemKey acts as a "slot" for the hooked data; usually a string key works well.

The hooked data is scopred relative to the current container. The itemKey does not need to be globally unique.

In addition, we allow "hooking" side data to other real data.

This is for the case where some data is UI-specific, you don't want to change your data types to include them, but they are not bound to one specific view: you want other views to also be able to access it.

func UseData[T any](data any, itemKey any) *T

Instead of being scoped to the current container, it is scoped to some specific item in your data. The data parameter should be a stable pointer to something in your application data.

Known Issues & Limitations

The following are known issues and limitations that we plan to tackle:

The following items are out of scope and will not be supported, at least for the time being:

"