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
-
Native. Real computer program (binary executable): not a "web page".
This project was born in large part out of frustration with the web browser
becoming the defacto GUI framework for everything.
We do not enjoy creating complex UIs using web technologies. The process is
too complicated, and the result is unsatisfying.
-
Cross platform. Same codebase to produce programs for macOS, Windows, and Linux.
-
No boilerplate setup. Just provide your root view function.
Many native immediate mode libraries require a lot of setup boilerplate before
you can start using them. This creates high friction, which we aim to remove.
-
Highly ergonomic. UI builder code does not have to be "noisy".
Most of the time, you will just be creating a UI hierarchy, laying out items
and specifying attributes; not so different from creating a dom hierarchy and
styling it.
-
Immediate mode style. Describe what the UI should look like. No need to
create or manage "widget" objects or retain their states in your code.
Easily build dynamic UIs with fluid interactions with no code for book keeping
widget references or states.
-
Practical. Tailored towards useful utility-style computer programs; fancy
graphics are de-prioritized.
-
Robust text support. Complex script shaping, bidirectional layout,
and access to system fonts.
-
Flexbox style layout. Containers arrange items horizontally or vertically,
with options for padding, gaps, alignment, wrapping, floating, scrolling, and
size expansion.
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:
- What's the current mouse position?
- What keys are currently down?
- What text input was made this frame?
- Did mouse button get clicked this frame?
- Did this keyboard key get released this frame?
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
- Per frame state
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:
- Associating side data with the current ui container
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:
-
Wrapping and Expanding do not work well together. A row can wrap its items, but if any item sets the expand flag, it will not look right
-
Large text blocks will kill responsiveness.
-
Not many widgets are implemented by default
-
Styling can still be verbose at times
The following items are out of scope and will not be supported, at least for the time being:
-
Mobile Phones
-
Multiple Windows
-
Fancy Graphics
"