The interface
The ui package is an immediate-mode toolkit. The game rebuilds the interface every frame by calling widget methods, and each widget returns what happened. There is no widget tree to create, update or free.
Create a ui.Context once during Init and reuse it on the rendering
goroutine:
g.ui, err = ctx.NewUI(ui.Theme{})
if err != nil {
return err
}
The zero theme selects the dark theme with the engine's shared Go Regular
font at 14 view units. A custom theme preserves all settings; a nil
Font is filled with that default. Clipboard access and input-method
placement are connected automatically. Recreate the interface during
device recovery because its graphics context has changed.
Use ui.New(ctx.Gfx, theme) when supplying and wiring these dependencies
yourself. The context retains focus,
scroll positions, caret, selection and undo history by widget identity;
the game owns widget values. The engine releases fonts and skin textures
created through ctx.Gfx at graphics teardown. Keep labels,
container structure and the order of repeated labels stable to retain
that interaction state.
Frames and containers
Build the interface inside Begin, which finishes the frame when its
body returns. Containers take their contents as closures too:
u.Begin(ctx.Input, func() {
u.Panel("Options", ui.Rect{X: 20, Y: 20, W: 300, H: 260}, func() {
u.Slider("Volume", &volume, 0, 1)
u.Checkbox("Fullscreen", &fullscreen)
u.Columns([]float32{2, 1}, func() {
u.Dropdown("Quality", &quality, []string{"Low", "High"})
u.Button("Apply")
})
u.ScrollArea("log", ui.Rect{X: 30, Y: 160, W: 280, H: 100}, 40*28, func() {
for _, line := range log {
u.Label(line)
}
})
})
})
Widgets stack top to bottom inside a panel; Row and Columns lay a
few side by side. Label wraps to the width it is given.
Anchored, Stretched and Split compute rectangles from the view.
Use Anchored for a panel pinned to a corner, Stretched for one that
grows with the window, and Split for a sidebar beside a main area.
Tabs switches between sets of widgets, Table
lays out rows of Cell values (or any widget) under a header, Tree
and TreeOpen nest collapsible sections, and Window is a panel the
user drags by its title and resizes by its corner. MenuBar, Menu and
MenuItem make a menu bar with drop-down lists, and Modal dims
everything else and blocks background widgets until its flag is cleared.
A modal's body draws after ordinary widgets. Submit a newly opened modal
before background controls to block those controls in its first frame;
on later frames the modal owns input from the first widget regardless of
submission order. Gate gameplay input separately using the open flag.
WantsKeyboard reports text-editor focus only; it does not report a
modal or navigation focus. WantsMouse reports panel hover and dragging.
These queries describe the most recent Begin and do not consume input.
view := ui.Rect{W: ctx.Width, H: ctx.Height}
tools, scene := ui.Split(view, 0.25, 8)
u.Begin(ctx.Input, func() {
u.MenuBar(ui.Rect{X: 0, Y: 0, W: ctx.Width, H: 22}, func() {
u.Menu("File", func() {
if u.MenuItem("Quit") {
ctx.Quit()
}
})
})
u.Panel("Tools", tools, func() { u.Button("Brush") })
u.Panel("Scene", ui.Stretched(scene, 8, 8, 8, 8), func() {
u.Tabs([]string{"World", "Entities"}, &g.tab)
})
// g.inspector is a ui.Rect the game keeps; the user moves it.
u.Window("Inspector", &g.inspector, func() { u.Label("Drag my title") })
u.Panel("HUD", ui.Anchored(view, ui.TopRight, 160, 48, 12), func() {
u.Label("Score 0")
})
})
Widgets
Besides the labels, buttons, checkboxes, sliders, progress bars and
dropdowns above there are Radio and RadioGroup; IntSlider and
Spinner for whole numbers; ListBox for a scrolling selection;
ColorPicker with a hue bar and a saturation-value square;
CurveEditor for a value that changes over a span; Image,
ImageRegion and IconButton for pictures; Tooltip and Separator;
and RichLabel for markup with bold, italic, colour and links, which
returns the link that was clicked (set Theme.BoldFont and
ItalicFont for the faces).
CurveEditor(label, &points, lo, hi, height) draws a curve as a graph
and edits it in place: drag a point to move it, click an empty part of
the graph to add one, right-click a point to remove it. The points are
lin.Vec2 pairs kept in increasing x, x running 0 to 1 across the graph
and y from lo to hi up it; the first and last keep their x so the
curve always spans the range. particle.Curve converts both ways with
Points and CurveOf, which is how the gallery's particle editor tunes
size and alpha over a particle's life.
u.Panel("Settings", ui.Rect{X: 20, Y: 20, W: 320, H: 300}, func() {
u.Label("Long labels wrap to the width of the panel.")
u.Slider("Volume", &g.volume, 0, 1)
u.IntSlider("Lives", &g.lives, 1, 9)
u.TextField("Player name", &g.name)
u.Dropdown("Quality", &g.quality, []string{"Low", "Medium", "High"})
u.ListBox("Maps", 56, []string{"Archipelago", "Pangaea"}, &g.mapIdx)
u.Separator()
if u.Button("Apply") {
g.save()
}
u.Tooltip("Writes the settings file.") // describes the widget above
if link := u.RichLabel("[b]Bold[/b] and a [link=docs]link[/link]."); link != "" {
g.open(link)
}
})
TextField edits one line and TextArea several wrapped lines. Both
have a caret, a selection made with Shift and the arrows or by dragging,
Home and End, word jumps with Ctrl or Cmd and the arrows, select all,
cut, copy, paste, and undo and redo (Ctrl or Cmd with Z, Shift+Z or Y).
ctx.NewUI connects the system clipboard and input-method placement.
When using ui.New directly, set the interface's Clipboard to the
engine's Context. Text fields show the input method's
composition underlined and report their rectangle through
OnTextInputRect so the platform can place candidate windows; wire
that to ctx.SetTextInputRect once when constructing the interface manually.
An open modal owns text input, clipboard shortcuts and composition. Fields behind it release text focus and cannot edit their values or move the input method's candidate rectangle. Closing the modal leaves them unfocused until the user selects a field again.
State and identity
Widgets keep no state of their own. Values live in the game's variables
and are passed by pointer. A widget's identity comes from its label and
the enclosing containers, and widgets with the same label in one
container are told apart by the order they are called in, so a list of
identical buttons works as long as the order is stable. A widget
returns true when something happened to it, such as a click, a
changed value or a toggled box.
u.Begin(ctx.Input, func() {
u.Panel("Audio", ui.Rect{X: 20, Y: 20, W: 260, H: 120}, func() {
// The value lives in the game; the widget reports the change.
if u.Slider("Music", &g.volume, 0, 1) {
ctx.Audio.SetMasterVolume(g.volume)
}
if u.Checkbox("Fullscreen", &g.fullscreen) {
ctx.SetFullscreen(g.fullscreen)
}
})
})
WantsMouse and WantsKeyboard tell the game whether the interface
consumed the pointer or has a text field focused, so a click on a button
does not also fire the game's own click handler.
if !u.WantsMouse() && ctx.Input.MousePressed(input.MouseLeft) {
p := ctx.Input.MousePos()
g.shootAt(p.X, p.Y)
}
if !u.WantsKeyboard() && ctx.Input.KeyPressed(input.KeyEscape) {
g.pause()
}
Keyboard and gamepad
Tab and Shift-Tab move a focus ring between widgets; Enter, Space or a
gamepad's A button activate the focused one, and the d-pad's up and
down move focus.
Moving to a TextField or TextArea transfers text input to that editor
at its retained caret and selection; moving to another kind of widget
releases text input. Enter or Escape can still release a field's focus
without its retained navigation ring taking the keyboard back.
A ListBox, a ReorderableList, a row of Tabs, a Table's rows, a
Tree, a RadioGroup and an open Dropdown are each one Tab stop.
Inside one, the arrows move between the items (up and down through rows
and tree nodes, left and right along tabs and radios), Home and End go
to the ends, PageUp and PageDown move a page of rows, and Enter or A
activates the item. A row that focus moves to inside a ScrollArea or
a list scrolls into view. Tab leaves for the next widget, and Shift-Tab
comes back to the item it left. On a gamepad, up and down on the d-pad
step through everything in order, so a list is walked row by row and
focus then continues to the next widget. Left and right move along tabs
and radios.
Right opens a focused tree node and Left closes it. A focused Slider,
IntSlider or Spinner steps with the left and right arrows, the
minus and plus keys, or the d-pad. Table returns the row clicked or
activated, or -1.
A Table in a ScrollArea only builds the rows in view: rows scrolled
out keep their place, their Tab stop and their accessibility entry, but
their cells are not called, so a table of thousands of rows costs about
what its visible rows do. The arrows, PageUp, PageDown, Home and End
still move through every row and scroll the focused one into view. The
row holding the focused or held widget is always built, so a text field
in a row keeps the keyboard while it is scrolled away. Widgets inside a
row are scoped to that row, so a Button("Buy") in every row has its own
identity. A row skipped this frame keeps the height it had when it was
last built.
u.Theme.FocusWidth = 3 // the ring Tab draws; zero means 2
u.Panel("Party", ui.Rect{X: 20, Y: 20, W: 300, H: 200}, func() {
// The table is one Tab stop; the arrows walk its rows.
if row := u.Table([]string{"Name", "HP"}, []float32{2, 1}, len(g.party),
func(row, col int) {
if col == 0 {
u.Cell(g.party[row].Name)
} else {
u.Cell(strconv.Itoa(g.party[row].HP))
}
}); row >= 0 {
g.selected = row // clicked, or Enter or A on the focused row
}
u.RadioGroup(&g.difficulty, []string{"Easy", "Hard"})
})
Drag and drop
DragSource wraps widgets in a region that can be picked up. Pressing
on it and moving the pointer a few units starts a drag carrying a
payload, and a ghost of the label follows the pointer; set DragGhost
on the context to draw something else, such as the item's icon.
DropTarget makes the previous widget a place the drag can end, and
DropTargetRect does the same for a rectangle drawn without widgets,
such as a cell of an inventory grid. Both take an accept function (nil
accepts anything), outline the target in the accent colour while an
accepted payload hovers over it, and report the payload on the frame
the pointer is released there. Dragging returns the payload of the
drag in progress, so the game can draw a target differently while a
drag is under way. Escape cancels a drag and nothing is dropped.
u.DragGhost = func(label string, payload any, x, y float32) {
ctx.Gfx.DrawText(g.font, label, x, y, gfx.RGB(255, 255, 255))
}
for _, item := range inventory {
u.DragSource(item.Name, item, func() { u.Button(item.Name) })
}
u.Label("Equip")
if _, ok := u.Dragging(); ok {
u.Label("Drop it here")
}
if p, ok := u.DropTarget("equip", func(p any) bool { return p.(Item).Wearable }); ok {
equip(p.(Item))
}
ReorderableList shows rows that can be dragged to a new place, with a
marker where the row will land. It reports the row and its new index,
and Move applies the change to the slice. With a row focused, Ctrl or
Cmd with Up or Down moves it one step, and focus follows the row.
if from, to, ok := u.ReorderableList("Turn order", g.order, 100); ok {
ui.Move(g.order, from, to)
}
Themes
A Theme holds every colour and measure. Build one from a Palette of
seven colours with FromPalette, or pick a built-in one:
theme, _ := ui.NamedTheme("gruvbox", font) // dark, light, nord, gruvbox, solarized-dark, solarized-light, sepia, high-contrast
theme.RowHeight = 32
u.Theme = theme
To match a game's own art, pass its seven colours to FromPalette and
leave the other measures at their defaults:
u.Theme = ui.FromPalette(font, ui.Palette{
Background: gfx.Hex(0x2b1d2e), // panels
Surface: gfx.Hex(0x3d2b42), // buttons and fields
Border: gfx.Hex(0x6b4f72),
Text: gfx.Hex(0xf2e8f4),
TextDim: gfx.Hex(0xa892ad),
Accent: gfx.Hex(0xffb454), // sliders, focus, checks
Title: gfx.Hex(0xffd9a0),
})
The theme can change at any time; the next frame draws with it. The
high-contrast theme has thicker borders and a wider focus ring, and
Theme.FocusWidth sets the ring for any theme. ThemeNames lists the
built-in ones, for a dropdown in a settings panel.
Skins
ui.Rect is lin.Rect and ui.Slice is gfx.NineSlice, so the game
can draw with the same values outside the interface.
A theme may carry a Skin of nine-slice textures for panels, buttons
in three states, fields, checkboxes, tracks, fills, knobs and scroll
thumbs. Any slice left nil falls back to the theme's flat colours, so a
skin can start with one button image and grow from there. The borders
of each slice are in texture pixels.
btn, err := ctx.Gfx.NewTexture(buttonPNG, gfx.TextureOptions{Linear: true, NoMipmaps: true})
if err != nil {
return err
}
panel, err := ctx.Gfx.NewTexture(panelPNG, gfx.TextureOptions{Linear: true, NoMipmaps: true})
if err != nil {
return err
}
u.Theme.Skin = &ui.Skin{
Button: &ui.Slice{Tex: btn, Left: 11, Top: 11, Right: 11, Bottom: 11},
Panel: &ui.Slice{Tex: panel, Left: 14, Top: 14, Right: 14, Bottom: 14},
}
The engine releases these textures during graphics teardown. Call
Destroy earlier when replacing a skin and reclaiming its resources. The gallery
example draws a complete skin procedurally; a game would load PNGs the
same way.
Accessibility
Accessible returns the last frame's widgets in reading order with a
role, label, value, rectangle and state, so a game or a platform layer
can hand them to a screen reader or drive the interface from them in a
test. Table rows, lists and their rows, drag sources and drop targets
have roles of their own, and the state shows a row being dragged or a
target ready for a drop. The engine does not yet connect this tree to
the operating system's screen reader.
u.Begin(ctx.Input, func() { g.buildInterface() })
for _, n := range u.Accessible() {
if n.Focused { // "slider Volume 0.80"
g.speak(n.Role + " " + n.Label + " " + n.Value)
}
if n.Role == "listitem" && n.State {
g.speak(n.Label + " picked up")
}
}
The list covers the frame that was built most recently, so read it
after Begin returns. A
returned slice is borrowed and reused by later frames; copy it before
keeping it past the next Begin. A
test finds a widget by role and label and clicks the centre of its
Rect, which is how the package's own navigation tests work.