Skip to content

Advanced

mike-ward edited this page Jun 14, 2026 · 2 revisions

Advanced

This page covers multi-window applications, the command/shortcut system, touch gestures, native OS integration, audio, and accessibility.


Multi-window

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.


Commands and keyboard shortcuts

The command system centralises actions with keyboard shortcuts, enable/disable logic, and automatic integration with the CommandPalette widget.

Registering commands

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:

  1. Global commands (Global: true)
  2. Focused widget's OnKeyDown
  3. Tab / Shift+Tab focus cycling
  4. Non-global commands (Global: false)

CommandButton

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.

Modifier constants

Constant Key
ModCtrl Ctrl
ModShift Shift
ModAlt Alt / Option
ModSuper ⌘ on macOS, Win on Windows

Combine: gui.ModCtrl | gui.ModShift.


Gestures

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.


Native dialogs

All native dialog methods are on *gui.Window and are non-blocking. Results arrive via OnDone callback on the main goroutine.

File dialogs

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.

Message and confirm dialogs

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

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()

Sound effects

// 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).

Music

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()

Volume

audio.SetMasterVolume(0.8)   // sound effects, 0.0–1.0
audio.SetMusicVolume(0.5)    // music track, 0.0–1.0

Accessibility

Standard 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.


Time-travel debugging

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.


Inspector

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 checking

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).


Input masks

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.


System tray

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.


Titlebar appearance

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 default

The change takes effect immediately. No-op on platforms without titlebar color support.


Rich text

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.


Font management

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.

Clone this wiki locally