-
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/mike-ward/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/mike-ward/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.
Getting Started
Widgets
Layout & Interaction
Visuals
Reference