-
Notifications
You must be signed in to change notification settings - Fork 1
Advanced
This page covers multi-window applications, the command/shortcut system, touch gestures, native OS integration, audio, and accessibility.
Applications with more than one window use gui.NewApp() and backend.RunApp.
app := gui.NewApp()
app.ExitMode = gui.ExitOnMainClose // quit when the first window closes
w1 := gui.NewWindow(gui.WindowCfg{State: &Main{}, Title: "Main"})
w2 := gui.NewWindow(gui.WindowCfg{State: &Inspector{}, Title: "Inspector"})
backend.RunApp(app, w1, w2)Open additional windows at runtime:
app.OpenWindow(gui.WindowCfg{State: &Detail{}, Title: "Detail"})Cross-window communication:
// Call fn in every open window's render goroutine.
app.Broadcast(func(w *gui.Window) {
gui.State[Main](w).NotifyCount++
})
// Target a specific window.
w2.QueueCommand(func(w *gui.Window) {
gui.State[Inspector](w).SelectedID = newID
})QueueCommand defers the callback to the next frame — it is safe to call from any
goroutine.
The command system centralises actions with keyboard shortcuts, enable/disable logic, and
automatic integration with the CommandPalette widget.
w.RegisterCommand(gui.Command{
ID: "file.save",
Label: "Save",
Group: "File",
Icon: gui.IconSave,
Shortcut: gui.Shortcut{Key: gui.KeyS, Modifiers: gui.ModCtrl},
Execute: func(e *gui.Event, w *gui.Window) {
save(gui.State[App](w))
},
CanExecute: func(w *gui.Window) bool {
return gui.State[App](w).HasUnsavedChanges
},
Global: true, // fires before focus dispatch
})Global: true means the shortcut fires even when a text input has focus. Use false
(the default) for commands that should yield to the focused widget's own key handling.
When CanExecute returns false, the shortcut is skipped and any button bound to the
command disables itself automatically.
Dispatch order per key event:
- Global commands (
Global: true) - Focused widget's
OnKeyDown - Tab / Shift+Tab focus cycling
- Non-global commands (
Global: false)
gui.CommandButton creates a button that is automatically labelled and disabled from a
registered command:
gui.CommandButton(w, "file.save", gui.ButtonCfg{ID: "btn-save"})The button shows the command's Label and shortcut hint, and is disabled whenever
CanExecute returns false — no manual wiring needed.
| Constant | Key |
|---|---|
ModCtrl |
Ctrl |
ModShift |
Shift |
ModAlt |
Alt / Option |
ModSuper |
⌘ on macOS, Win on Windows |
Combine: gui.ModCtrl | gui.ModShift.
Touch gestures are delivered through the same EventHandlers callbacks as mouse events.
Add OnGesture to any container or DrawCanvas:
gui.DrawCanvas(gui.DrawCanvasCfg{
ID: "gesture-pad",
Width: 400,
Height: 300,
Color: gui.RGBA(30, 30, 40, 255),
OnGesture: func(_ *gui.Layout, e *gui.Event, w *gui.Window) {
a := gui.State[App](w)
switch e.GestureType {
case gui.GestureTap:
a.Markers = append(a.Markers, Point{e.CentroidX, e.CentroidY})
case gui.GesturePan:
if e.GesturePhase == gui.GesturePhaseChanged {
a.OffsetX += e.GestureDX
a.OffsetY += e.GestureDY
}
case gui.GesturePinch:
a.Scale *= e.PinchScale
case gui.GestureRotate:
a.Rotation += e.GestureRotation
}
e.IsHandled = true
},
})Gesture types:
| Constant | Description |
|---|---|
GestureTap |
Single finger tap |
GestureDoubleTap |
Two taps in quick succession |
GestureLongPress |
Finger held without movement |
GesturePan |
Single finger drag |
GestureSwipe |
Fast pan ending with high velocity |
GesturePinch |
Two-finger spread or squeeze |
GestureRotate |
Two-finger twist |
Gesture phases (for continuous gestures: Pan, Pinch, Rotate):
| Constant | When |
|---|---|
GesturePhaseBegan |
First recognition |
GesturePhaseChanged |
Ongoing update |
GesturePhaseEnded |
Final event |
GesturePhaseCancelled |
Cancelled by system |
Relevant event fields:
| Field | Available for |
|---|---|
e.CentroidX, e.CentroidY
|
All gestures — centre of active touches |
e.GestureDX, e.GestureDY
|
Pan, Swipe — delta from previous event |
e.VelocityX, e.VelocityY
|
Swipe — velocity at end |
e.PinchScale |
Pinch — cumulative scale (1.0 = unchanged) |
e.GestureRotation |
Rotate — cumulative radians |
On desktop, the framework synthesises mouse events so gesture-enabled widgets also respond to mouse click and drag.
All native dialog methods are on *gui.Window and are non-blocking. Results arrive via
OnDone callback on the main goroutine.
w.NativeOpenDialog(gui.NativeOpenDialogCfg{
Title: "Open File",
StartDir: "/home/user/documents",
AllowMultiple: false,
Filters: []gui.NativeFileFilter{
{Name: "Go source", Extensions: []string{"go"}},
{Name: "All files", Extensions: []string{"*"}},
},
OnDone: func(result gui.NativeDialogResult, w *gui.Window) {
if result.Status != gui.DialogOK || len(result.Paths) == 0 {
return
}
loadFile(w, result.Paths[0].Path)
},
})w.NativeSaveDialog(gui.NativeSaveDialogCfg{
Title: "Save As",
DefaultName: "output.json",
DefaultExtension: "json",
ConfirmOverwrite: true,
Filters: []gui.NativeFileFilter{
{Name: "JSON", Extensions: []string{"json"}},
},
OnDone: func(result gui.NativeDialogResult, w *gui.Window) {
if result.Status == gui.DialogOK && len(result.Paths) > 0 {
writeFile(w, result.Paths[0].Path)
}
},
})w.NativeFolderDialog(cfg) opens a folder picker with the same OnDone pattern.
w.NativeMessageDialog(gui.NativeMessageDialogCfg{
Title: "Export complete",
Body: "The file was saved successfully.",
Level: gui.AlertInfo,
})
w.NativeConfirmDialog(gui.NativeConfirmDialogCfg{
Title: "Delete item?",
Body: "This action cannot be undone.",
Level: gui.AlertWarning,
OnDone: func(result gui.NativeAlertResult, w *gui.Window) {
if result.Status == gui.DialogOK {
deleteSelected(w)
}
},
})gui.NativeSaveDiscardDialog adds a three-way Discard option, returning DialogDiscard
for "don't save", DialogOK for "save", and DialogCancel for "cancel".
Alert levels: AlertInfo, AlertWarning, AlertCritical.
Audio is opt-in via the github.com/go-gui-org/go-gui/gui/audio package, which wraps
SDL_mixer. It is excluded from JS, Android, and iOS builds via a build tag.
Initialise once before loading any sounds, typically in main:
import "github.com/go-gui-org/go-gui/gui/audio"
if err := audio.Init(); err != nil {
log.Fatal(err)
}
defer audio.Quit()// Load from file (WAV, OGG, MP3, FLAC, MOD).
snd, err := audio.LoadSound("click.wav")
// Or from embedded bytes.
snd, err := audio.LoadSoundBytes(clickWAV)
// Play once on the first available channel.
snd.PlayOnce()
// Per-sound volume (0.0–1.0).
snd.SetVolume(0.6)
// Free when no longer needed.
snd.Free()snd.Play(channel, loops) targets a specific mixer channel and loops n times (0 = once,
-1 = infinite).
mus, err := audio.LoadMusic("background.ogg")
mus.Play(-1) // loop indefinitely
// ...
audio.HaltMusic()
audio.FadeOutMusic(500) // fade over 500 ms
audio.PauseMusic()
audio.ResumeMusic()
mus.Free()audio.SetMasterVolume(0.8) // sound effects, 0.0–1.0
audio.SetMusicVolume(0.5) // music track, 0.0–1.0Standard widgets (Button, TextInput, Slider, ProgressBar, etc.) expose accessibility
metadata automatically. The framework builds an A11yNode tree from the layout after each
frame and hands it to the platform backend:
- macOS — NSAccessibility (VoiceOver)
- Linux — AT-SPI2 (Orca, Accerciser)
- Web — ARIA attributes
For custom DrawCanvas widgets, set A11YLabel and A11YDescription on the config:
gui.DrawCanvas(gui.DrawCanvasCfg{
ID: "chart",
A11YLabel: "Sales chart",
A11YDescription: "Bar chart showing monthly sales for 2025",
OnDraw: renderChart,
})These strings are picked up by the a11y tree walk and surfaced to assistive technology.
Opt-in with DebugTimeTravel: true on WindowCfg. Implement the Snapshotter interface
on your state struct:
type App struct { Count int }
func (s *App) Snapshot() any { c := *s; return &c }
func (s *App) Restore(v any) { *s = *v.(*App) }
app := gui.NewApp()
main := gui.NewWindow(gui.WindowCfg{
State: &App{},
DebugTimeTravel: true,
})
backend.RunApp(app, main)A scrubber window opens alongside the app. Drag the slider or use ←/→/Home/End to
step through captured states. Space freezes the app; Esc resumes live.
Use w.Now() instead of time.Now() anywhere the view depends on the clock — this lets
the scrubber produce correct output when rewound. The feature is zero-cost when the flag is
off and read-only: rewinding does not undo past I/O side effects.
The layout inspector is a live debugging overlay that shows the current layout tree — every
node's type, ID, position, size, sizing mode, padding, color, and event handlers. Press
Ctrl+I (or ⌘I on macOS) to toggle it.
Set DebugInspector: true on WindowCfg to start with the inspector open:
w := gui.NewWindow(gui.WindowCfg{
State: &App{},
DebugInspector: true,
})The inspector renders as a resizable sidebar with a tree view on the left and a property panel on the right. Click any node in the tree to inspect its properties.
Spell check integrates with OS-level spell checkers — NSSpellChecker on macOS, Enchant on
Linux — through NativePlatform.SpellCheck.
type NativeSpellChecker interface {
SpellCheck(text string) []SpellRange
SpellSuggest(text string, startByte, lenBytes int) []string
SpellLearn(word string)
}Widgets with editable text surfaces (inputs, markdown views) register themselves with the spell check backend automatically. On platforms without a native spell checker, the methods return empty results (no-op, not an error).
gui.InputMask applies a character-by-character mask to text input. Six built-in presets
cover common formats:
type InputMaskPreset uint8
const (
MaskNone InputMaskPreset = iota
MaskPhoneUS // (555) 123-4567
MaskCreditCard16 // 1234 5678 9012 3456
MaskCreditCardAmex // 1234 567890 12345
MaskExpiryMMYY // 12/34
MaskCVC // 123
)Attach a mask to any InputCfg:
gui.Input(gui.InputCfg{
ID: "cc-number",
MaskPreset: gui.MaskCreditCard16,
OnTextChanged: func(text string, w *gui.Window) {
gui.State[App](w).CardNumber = text
},
})For custom masks, set Mask: "##/## ####" and provide MaskTokens that define matcher
functions per symbol:
gui.Input(gui.InputCfg{
ID: "custom-code",
Mask: "AA-999",
MaskTokens: map[rune]gui.MaskTokenDef{
'A': {Matcher: unicode.IsUpper, Symbol: 'A'},
'9': {Matcher: unicode.IsDigit, Symbol: '9'},
},
})Literal characters in the mask (spaces, dashes, parentheses) are inserted automatically and skipped during cursor navigation.
The system tray API creates OS-native tray icons with context menus:
gui.NewSystemTray(gui.SystemTrayCfg{
IconPNG: trayIconBytes,
Tooltip: "My App",
Menu: []gui.NativeMenuItemCfg{
{Text: "Show Window", OnAction: onShow},
{Text: "Quit", OnAction: onQuit},
},
OnAction: func(id string) {
switch id {
case "Show Window":
w.Restore()
case "Quit":
app.Quit()
}
},
})System tray support varies by platform: full menu support on Linux (StatusNotifierItem),
basic icon + menu on Windows, and limited support on macOS. Check NativePlatform for
NativeSystemTray availability.
Set the window titlebar to dark or light mode independently of the app theme:
w.TitlebarDark(true) // dark titlebar (Windows DWM API, macOS NSAppearance)
w.TitlebarDark(false) // light / system defaultThe change takes effect immediately. No-op on platforms without titlebar color support.
gui.RichText(gui.RichTextCfg{...}) renders text with multiple inline styles — bold,
italic, underline, strike, code — applied to ranges within a single string. Styles are
defined via a Ranges slice:
gui.RichText(gui.RichTextCfg{
Text: "**bold** and *italic* in one widget.",
Ranges: []gui.TextRange{
{Start: 0, End: 8, Style: gui.CurrentTheme().B1Bold},
{Start: 13, End: 20, Style: gui.CurrentTheme().B1Italic},
},
TextStyle: gui.CurrentTheme().B1,
})For full Markdown rendering, use w.Markdown() instead — see
Display Widgets.
gui.LoadFont(path string, size float32) (gui.Font, error) loads a .ttf or .otf file
and returns a font handle usable in TextStyle:
customFont, err := gui.LoadFont("assets/Inter-Regular.ttf", 16)
if err != nil {
log.Fatal(err)
}
gui.Text(gui.TextCfg{
Text: "Custom font",
TextStyle: gui.TextStyle{
FontFamily: customFont.Family,
Size: 16,
},
})Fonts are cached by path + size — loading the same font twice returns the cached handle.
Getting Started
Widgets
Layout & Interaction
Visuals
Reference