The GoForms guide
Everything that is not obvious from the API: how forms reach each other, how a dialog returns an answer, which goroutine you are on, what the designer will and will not rewrite — and the handful of traps that are worth knowing before you hit them.
Installing
GoForms is a normal Go module. Nothing needs vendoring and nothing needs a
replace directive:
go get github.com/Go-Forms/GoForms
The module path and the package name differ, which is normal in Go and worth
seeing once: the path is
github.com/Go-Forms/GoForms, and it binds the
name goforms. No alias is needed.
package main import "github.com/Go-Forms/GoForms" func main() { // The id is the application identity the OS uses; any reverse-DNS // string you own is fine. Call this before anything else. goforms.NewApplication("com.example.myapp") goforms.Run(mainform.NewMainForm().Form) }
Rendering is done by Fyne, which is CGo, so a build needs a C compiler and your platform's OpenGL headers. On Debian or Ubuntu:
sudo apt-get install gcc pkg-config libgl1-mesa-dev xorg-dev \
libxkbcommon-dev libwayland-dev
Fyne compiles its Wayland path by default on Linux and reaches it through
#cgo pkg-config: wayland-client. Leaving it out
fails the build with a pkg-config error that mentions neither Fyne nor
Wayland in its first line.
Anatomy of a form
A form is two files, exactly as a WinForms form is
Form1.cs plus
Form1.Designer.cs. The split is a convention the
designer relies on, not something the framework enforces — but keeping to it
is what lets you drag controls around without the designer ever touching
your code.
| File | Holds | Written by |
|---|---|---|
MainForm-designer.go |
The struct's fields, one per control, and initializeComponent() — construction, bounds, properties, event wiring. |
The designer. Editable by hand, but expect it to be reformatted. |
MainForm.go |
Handler bodies and everything else you write. | You. The designer only ever appends a stub to it. |
package mainform import "github.com/Go-Forms/GoForms" type MainForm struct { *goforms.Form btnGreet *goforms.Button } func NewMainForm() *MainForm { mf := &MainForm{Form: goforms.NewForm("Main", 400, 300)} mf.initializeComponent() return mf } func (mf *MainForm) initializeComponent() { mf.SetClientSize(400, 300) mf.btnGreet = goforms.NewButton("Greet") mf.btnGreet.SetBounds(20, 20, 100, 30) mf.btnGreet.Click.Handle(mf.btnGreet_Click) mf.AddControl(mf.btnGreet) }
namespace MyApp; partial class MainForm { private Button btnGreet; private void InitializeComponent() { this.ClientSize = new Size(400, 300); this.btnGreet = new Button(); this.btnGreet.Text = "Greet"; this.btnGreet.Location = new Point(20, 20); this.btnGreet.Size = new Size(100, 30); this.btnGreet.Click += this.btnGreet_Click; this.Controls.Add(this.btnGreet); } }
Three differences are worth naming, because they are the ones that keep catching people:
-
Embedding, not inheritance.
*goforms.Formis embedded, somf.Show(),mf.Close()and the form's events are promoted onto your type. Where C# would passthis, Go passesmf.Form. -
One call sets the rectangle.
SetBounds(x, y, w, h)instead of separateLocationandSize. -
.Handle(…)is+=. Events are multicast: calling it twice runs the handler twice.
Lifecycle and events
A form raises four events. They fire in this order, and
Load fires once — hiding a form and
showing it again does not raise it a second time, which is what WinForms
does too.
| Event | Argument | Fires |
|---|---|---|
Load | EventArgs | Once, the first time the form is shown. Fill lists and set initial state here, not in the constructor. |
Resize | EventArgs | Every time the window's size changes. Read the new size with ClientSize(). |
Closing | *CancelEventArgs | Before the window goes away — from the X button, Alt+F4, or a Close() call alike. Set e.Cancel = true to veto. |
Closed | EventArgs | After the window is gone. This is where you drop your reference to the form. |
Closing is
Event[*CancelEventArgs], not
Event[CancelEventArgs]. It has to be a pointer,
because the whole point is that your handler writes
e.Cancel and the framework reads it back. A
handler declared with a value receiver would compile against the wrong
event type and simply never be called.
func (mf *MainForm) MainForm_Load(sender any, e goforms.EventArgs) { mf.cboCountry.SetItems(loadCountries()) mf.cboCountry.SetSelectedIndex(0) } func (mf *MainForm) MainForm_Closing(sender any, e *goforms.CancelEventArgs) { if mf.dirty { e.Cancel = true goforms.ShowMessageBox(mf.Form, "Save your changes first.", "Unsaved work", goforms.MessageBoxOK, goforms.MessageBoxWarning, nil) } }
The UI-thread rule
This is the single most important thing on the page. Read it before writing any handler that waits for something.
Every event handler runs on the UI goroutine — the one goroutine that draws. While your handler is running, nothing repaints and no input is processed. Two rules follow, and breaking either produces a frozen window rather than an error message.
1. Never block inside a handler
Anything that waits — an HTTP call, a database query, a
time.Sleep, and in particular
ShowDialog() — must run on a goroutine you start
yourself.
2. Come back through fyne.Do
Once you are off the UI goroutine you may not touch controls. Hop back with
fyne.Do, which queues a function to run on the UI
goroutine.
func (mf *MainForm) btnLoad_Click( sender any, e goforms.MouseEventArgs, ) { // Blocks the goroutine that has to draw the // spinner, so nothing is ever drawn. rows := fetchFromServer() mf.grid.SetRows(rows) }
func (mf *MainForm) btnLoad_Click( sender any, e goforms.MouseEventArgs, ) { mf.status.SetText("Loading…") go func() { rows := fetchFromServer() // off the UI thread fyne.Do(func() { // back onto it mf.grid.SetRows(rows) mf.status.SetText("Ready") }) }() }
fyne.Do comes from
fyne.io/fyne/v2, so a file that uses it imports
Fyne directly. That is the one place GoForms does not hide it, because the
thread it queues onto is Fyne's.
A handler that blocks does not crash and logs nothing. The window stops repainting — it goes white or keeps its last frame — and the OS eventually offers to kill it. If a form freezes the moment you click something, look at that click handler first.
Opening another form
A non-modal form is Show(). The one thing that
needs care is the reference: nothing else holds the form alive, and nothing
stops you opening a second copy of it.
// A field on the form, not a local: a local would go out of scope the // moment the handler returned, and clicking the button twice would open // two windows on top of each other. type MainForm struct { *goforms.Form // ... designer fields ... settings *settingsform.SettingsForm } func (mf *MainForm) btnSettings_Click(sender any, e goforms.MouseEventArgs) { if mf.settings == nil { mf.settings = settingsform.NewSettingsForm() // Clearing the field on Closed is what makes the next click // open a fresh window instead of trying to show a closed one. mf.settings.Closed.Handle(func(sender any, e goforms.EventArgs) { mf.settings = nil }) } mf.settings.Show() }
Calling Show() on a form that is already visible
brings it forward, so the guard above gives you "open it, or focus the one
already open" — the behaviour people expect from a Tools menu.
Hide instead of close, when reopening should be cheap
Hide() keeps the form and its state alive;
Close() destroys the window.
Load will not fire again after a hide, so a form
you hide and re-show keeps everything the user typed into it.
Modal forms and results
ShowDialog() blocks until the form closes and
returns its DialogResult — the same shape as
WinForms. The difference that matters is where you may call it.
ShowDialog() blocks the goroutine it is called
on. Called from a click handler, that is the UI goroutine — the one that
has to draw the dialog you are waiting for. The application deadlocks
against itself, with the dialog never appearing.
private void btnAbout_Click(object s, EventArgs e) { using var dlg = new AboutForm(); if (dlg.ShowDialog(this) == DialogResult.OK) { ApplyChanges(); } }
func (mf *MainForm) btnAbout_Click( sender any, e goforms.MouseEventArgs, ) { go func() { dlg := aboutform.NewAboutForm() result := dlg.ShowDialog() // blocks here if result == goforms.DialogOK { fyne.Do(func() { mf.applyChanges() }) } }() }
The five results are
DialogNone,
DialogOK,
DialogCancel,
DialogYes and
DialogNo.
A form closed with the X button or Close()
returns DialogNone, which is how you tell "the
user dismissed it" from "the user chose Cancel".
Writing your own dialog
There is no separate dialog type. A dialog is a form whose buttons call
CloseWithResult instead of
Close — that is the whole mechanism. Build it in
the designer like any other form.
type ConfirmForm struct { *goforms.Form lblMessage *goforms.Label btnOK *goforms.Button btnCancel *goforms.Button } func NewConfirmForm() *ConfirmForm { f := &ConfirmForm{Form: goforms.NewForm("Confirm", 360, 150)} f.initializeComponent() return f }
func (f *ConfirmForm) btnOK_Click(sender any, e goforms.MouseEventArgs) { f.CloseWithResult(goforms.DialogOK) } func (f *ConfirmForm) btnCancel_Click(sender any, e goforms.MouseEventArgs) { f.CloseWithResult(goforms.DialogCancel) }
CloseWithResult records the result and then goes
through the same path as an ordinary close — so
Closing still gets its chance to veto, and a
cancelled close leaves the dialog open with no result recorded.
Make it feel like a dialog
Three optional touches, all on the form itself, that separate a dialog from a window that happens to be small:
func (f *ConfirmForm) ConfirmForm_Load(sender any, e goforms.EventArgs) { f.SetFixedSize(true) // no resize grip f.CenterOnScreen() // ShowDialog does this too f.btnOK.SetAnchor(goforms.AnchorBottom | goforms.AnchorRight) }
Passing data both ways
ShowDialog returns only a
DialogResult. Anything richer travels on the
dialog's own struct — which is the same thing C# does with a public property,
and is why the constructor and the fields are yours to add.
In: a constructor argument
// The generated NewEditCustomerForm() takes nothing. Add your own // constructor beside it rather than editing the designer file, which // the designer regenerates. func NewEditCustomerFormFor(c Customer) *EditCustomerForm { f := NewEditCustomerForm() f.customer = c f.txtName.SetText(c.Name) f.txtEmail.SetText(c.Email) return f }
Out: a field the caller reads after it closes
type EditCustomerForm struct { *goforms.Form // ... designer fields ... // Result is what the caller reads once ShowDialog returns OK. It is // safe to read then, and only then: ShowDialog does not return until // the form has closed, so nothing is still writing to it. Result Customer } func (f *EditCustomerForm) btnSave_Click(sender any, e goforms.MouseEventArgs) { f.Result = Customer{ Name: f.txtName.Text(), Email: f.txtEmail.Text(), } f.CloseWithResult(goforms.DialogOK) }
func (mf *MainForm) btnEdit_Click(sender any, e goforms.MouseEventArgs) { selected := mf.selectedCustomer() go func() { dlg := NewEditCustomerFormFor(selected) if dlg.ShowDialog() == goforms.DialogOK { saved := dlg.Result fyne.Do(func() { mf.applyCustomer(saved) }) } }() }
saved := dlg.Result happens on the goroutine
that owns dlg, and the copy is what crosses to
the UI goroutine. Reading dlg.Result from
inside the closure would be a second goroutine touching the dialog's
memory — harmless in practice here, but exactly the shape
go test -race is built to complain about.
Swapping views inside one window
Sometimes you do not want a second window at all — you want the window's
contents replaced, the way a wizard or a settings app moves between pages.
ViewContainer is that: a
TabControl whose tab strip can be hidden, so
you decide what switches pages.
func (f *ShellForm) initializeComponent() { f.SetClientSize(900, 600) f.views = goforms.NewViewContainer(900, 560) f.views.SetBounds(0, 40, 900, 560) f.views.SetAnchor(goforms.AnchorTop | goforms.AnchorLeft | goforms.AnchorRight | goforms.AnchorBottom) // TabsHidden makes it a plain page stack: the pages are still // there, but nothing draws a strip the user could click. f.views.SetTabAlignment(goforms.TabsHidden) f.AddControl(f.views) }
func (f *ShellForm) ShellForm_Load(sender any, e goforms.EventArgs) { // Each page is a container: put controls on it exactly as you // would on a Panel. welcome := f.views.AddPage("Welcome") details := f.views.AddPage("Details") f.views.AddPage("Done") lbl := goforms.NewLabel("Step one of three.") lbl.SetBounds(24, 24, 400, 24) welcome.AddControl(lbl) f.txtName = goforms.NewTextBox() f.txtName.SetBounds(24, 24, 300, 30) details.AddControl(f.txtName) f.views.SetSelectedIndex(0) } func (f *ShellForm) btnNext_Click(sender any, e goforms.MouseEventArgs) { f.views.SelectNext() } func (f *ShellForm) btnBack_Click(sender any, e goforms.MouseEventArgs) { f.views.SelectPrevious() }
| Method | Does |
|---|---|
AddPage(title) | Adds a page and returns it. The returned *ViewPage takes children like a Panel. |
SetSelectedIndex(i) | Switches to a page by index. |
SelectNext() / SelectPrevious() | Steps through, for wizard buttons. |
SelectedPage() / SelectedIndex() | Reads the current page. |
SetTabAlignment(a) | TabsTop, TabsBottom, TabsLeft, TabsRight, TabsHidden. Left and right give you a sidebar navigation for free. |
SelectedIndexChanged | Fires for a tab click and for SetSelectedIndex, so code driving it from your own buttons sees the same event. |
Which of the three approaches to use
| You want | Use |
|---|---|
| A tool window the user keeps open beside the main one | A second form, Show() |
| An answer before anything else can happen | A second form, ShowDialog() on a goroutine |
| The same window showing something else | ViewContainer with TabsHidden |
| Pages the user picks between themselves | TabControl, or ViewContainer with a visible strip |
Closing, cancelling, confirming
Every route out of a form — the X button, Alt+F4,
Close(),
CloseWithResult() — goes through
Closing first. One handler covers them all.
Confirming a close is the case that catches people, because the obvious
version does not work: you cannot ask the user inside
Closing and wait for the answer — waiting there
is waiting on the UI goroutine. Veto first, ask afterwards, and close again
once you know.
func (f *EditorForm) EditorForm_Closing(sender any, e *goforms.CancelEventArgs) { if !f.dirty || f.confirmed { return // nothing to lose, or the user already said yes } // Stop this close, then ask. ShowMessageBox does not block - it // reports through the callback - so this handler returns at once // and the UI keeps running while the box is up. e.Cancel = true goforms.ShowMessageBox(f.Form, "Discard your changes?", "Unsaved work", goforms.MessageBoxYesNo, goforms.MessageBoxQuestion, func(r goforms.DialogResult) { if r == goforms.DialogYes { f.confirmed = true f.Close() // this time Closing lets it through } }) }
Event[T] has no -=:
handlers can be added but not removed. A boolean the handler checks is the
idiom, and it reads more honestly anyway — "the user has already confirmed"
is a fact about the form, not about the event.
Message and input boxes
These are the built-ins, and they share one property that differs from WinForms: none of them block. They report through a callback instead, which is what lets you call them straight from a click handler without freezing anything.
var r = MessageBox.Show(this, "Delete this row?", "Confirm", MessageBoxButtons.YesNo, MessageBoxIcon.Question); if (r == DialogResult.Yes) DeleteRow();
goforms.ShowMessageBox(mf.Form, "Delete this row?", "Confirm", goforms.MessageBoxYesNo, goforms.MessageBoxQuestion, func(r goforms.DialogResult) { if r == goforms.DialogYes { mf.deleteRow() } })
The callback already runs on the UI goroutine, so it may touch controls
directly — no fyne.Do needed. Pass
nil when you do not care about the answer.
| Argument | Values |
|---|---|
| Buttons | MessageBoxOK, MessageBoxOKCancel, MessageBoxYesNo |
| Icon | MessageBoxNone, MessageBoxInformation, MessageBoxWarning, MessageBoxError, MessageBoxQuestion |
Asking for a line of text
goforms.ShowInputBox(mf.Form, "New folder", "Name:", func(text string, ok bool) { if ok && text != "" { mf.createFolder(text) } })
ok is false when the user cancelled, which is
not the same as an empty string — check both if an empty name means
something to you.
Files, folders, colours
The file dialogs are objects you configure and then show, mirroring
OpenFileDialog and friends. Like the message box,
they answer through a callback.
func (mf *MainForm) btnOpen_Click(sender any, e goforms.MouseEventArgs) { dlg := goforms.NewOpenFileDialog() dlg.Title = "Open a report" dlg.Filter = goforms.FileDialogFilter{ Description: "CSV files", Extensions: []string{".csv"}, } dlg.Show(mf.Form, func(path string, ok bool) { if !ok { return } // ReadAll reads whatever the dialog last returned, so the // file does not have to be reopened by path. data, err := dlg.ReadAll() if err != nil { goforms.ShowMessageBox(mf.Form, err.Error(), "Could not read", goforms.MessageBoxOK, goforms.MessageBoxError, nil) return } mf.load(data) }) }
| Type | WinForms equivalent | Notes |
|---|---|---|
NewOpenFileDialog() | OpenFileDialog | ReadAll() reads the chosen file without reopening it. |
NewSaveFileDialog() | SaveFileDialog | WriteAll(data) writes to the chosen destination. |
NewFolderBrowserDialog() | FolderBrowserDialog | Returns a directory path. |
NewColorDialog() | ColorDialog | Show(parent, func(c goforms.Color)). |
NewColorPickerButton(text, parent) | — | A button that opens the colour dialog itself. In the designer's toolbox. |
Prefer ReadAll and
WriteAll over reopening the path yourself.
The path a sandboxed OS hands back is not always one your process is
allowed to open a second time.
Position, dock, anchor
Every control has a rectangle, set in one call. Coordinates are relative to whatever contains it: a control on a Panel is positioned from the panel's top-left corner, not the form's.
btn.SetBounds(20, 60, 100, 30)
Anchoring — what happens when the form is resized
An anchor pins an edge of the control to the same edge of its parent. Pin two opposite edges and the control stretches between them.
| Anchor | Effect on resize |
|---|---|
AnchorTop | AnchorLeft | Stays put. This is the default and the WinForms default. |
AnchorTop | AnchorRight | Slides right with the edge. Toolbar buttons on the right. |
AnchorLeft | AnchorRight | Stretches horizontally. Text boxes that should fill the form. |
| All four | Fills. What a grid or a log list wants. |
AnchorNone | Keeps its distance from the centre — it floats. |
Docking — glue it to an edge
A docked control ignores its stored position entirely and takes an edge of its parent, using the full width or height of whatever the other docked siblings have left over.
Docking is applied in the order controls were added. Add the top slab first and it takes the full width, leaving the sides shorter; add a side first and it takes the full height instead. This is the same rule WinForms uses, and it is why the designer draws docked controls where they will actually be rather than where they were dropped.
f.toolbar.SetDock(goforms.DockTop) // full width, at the top f.status.SetDock(goforms.DockBottom) // full width, at the bottom f.tree.SetDock(goforms.DockLeft) // what is left, down the side f.content.SetDock(goforms.DockFill) // everything remaining f.AddControl(f.toolbar) // this order is the layout f.AddControl(f.status) f.AddControl(f.tree) f.AddControl(f.content)
Layout panels
When arithmetic gets tiresome, four containers do the work. All of them are in the designer's toolbox, and all of them take children exactly as a Panel does.
| Panel | Arranges children | Key API |
|---|---|---|
FlowLayoutPanel |
In a row or column, wrapping at the edge. | SetFlowDirection(FlowLeftToRight | FlowTopDown | FlowRightToLeft | FlowBottomUp), SetWrapContents(bool) |
TableLayoutPanel |
On a grid, in the order added. | SetColumnStyle(i, Absolute(110) | Percent(40) | AutoSize()), SetRowStyle, AddControlSpanning(c, col, row, colSpan, rowSpan) |
SplitContainer |
In two halves with a draggable bar. | Panel1(), Panel2() — both real containers — and SetSplitterDistance(0.42) |
ScrollBox |
Unchanged, but scrolls if they overflow. | Add children as usual. |
f.table.SetColumnStyle(0, goforms.Absolute(110)) // fixed 110px f.table.SetColumnStyle(1, goforms.Percent(40)) // 40% of what is left f.table.SetColumnStyle(2, goforms.AutoSize()) // as wide as its content f.table.AddControl(cell) // fills cells in order f.table.AddControlSpanning(wide, 0, 2, 3, 1) // col 0, row 2, 3 wide
Calls like SetColumnStyle(0, …) and
SetColumnStyle(1, …) say different things, so
the designer's tidy pass leaves them alone. It only collapses setters that
take a single value, where a later call genuinely overrides an earlier one.
The control catalogue
Thirty-four types, each named after the
System.Windows.Forms class it stands in for — so
the WinForms documentation you already know still applies to the names, the
properties and the events.
| Group | Types |
|---|---|
| Common | Label, Button, TextBox, MaskedTextBox, RichTextBox, CheckBox, RadioButton, ComboBox, ListBox, CheckedListBox, PictureBox, ProgressBar, TrackBar, ScrollBar, NumericUpDown, DomainUpDown, DateTimePicker, MonthCalendar, LinkLabel, ColorPickerButton |
| Containers | Panel, GroupBox, TabControl, SplitContainer, FlowLayoutPanel, TableLayoutPanel, ScrollBox, ViewContainer, Splitter |
| Menus & toolbars | MenuStrip, ToolStrip, StatusStrip, ContextMenu |
| Data | DataGridView, ListView, TreeView |
Three constructors exist for text entry, because a WinForms
TextBox is three different widgets underneath:
NewTextBox(),
NewMultilineTextBox() and
NewPasswordTextBox().
Events every control has
On top of its own events, each control inherits the interaction set from
Control. The designer's Events panel draws a
divider between the two, so you can see which is which.
| Event | Argument type |
|---|---|
Click, DoubleClick, MouseDown, MouseUp, MouseMove, MouseWheel | MouseEventArgs |
KeyDown, KeyUp | KeyEventArgs |
KeyPress | KeyPressEventArgs |
MouseEnter, MouseLeave, GotFocus, LostFocus, Resize, Move, VisibleChanged, EnabledChanged | EventArgs |
Event[T].Handle takes
EventHandler[T], so a handler with the wrong
argument type is a compile error, not a handler that silently never runs.
Wire events from the designer and it picks the type for you.
Timers and background work
Timer mirrors
System.Windows.Forms.Timer: it ticks on the UI
goroutine, so its handler may touch controls directly.
mf.clock = goforms.NewTimer(1000) // interval in milliseconds mf.clock.Tick.Handle(func(sender any, e goforms.EventArgs) { mf.setStatus(1, time.Now().Format("15:04:05")) }) mf.clock.Start() // Stop it when the form goes away, or it keeps ticking against // controls that no longer exist. mf.Closed.Handle(func(sender any, e goforms.EventArgs) { mf.clock.Stop() })
A timer is for periodic UI work. For a long single operation, use a
goroutine and fyne.Do, as in
The UI-thread rule — a timer that fires while a
previous tick is still running will queue behind it.
The designer: canvas and selection
Open any *-designer.go file and the designer
opens instead of the text editor. GoForms: Open as Text
switches to the source, and the button in the editor's title bar toggles
back.
| Gesture | Does |
|---|---|
| Drag a control | Moves it. Edges snap to siblings' left, right and centre, with a guide line drawn where they line up. |
| Drag the corner handle | Resizes it. The handle appears on the selected control only. |
| Drag the form's right or bottom edge | Changes its width or its height. An edge grip moves only its own axis. |
| Drag the form's corner | Changes both at once. |
| Ctrl or Shift click | Extends the selection. Dragging any member then moves the whole group. |
| Click the form's background or title bar, click beside the form, or press Escape | Selects the form itself — its title and size appear in the properties panel. |
| Drag from the toolbox | Adds a control. Drop it on a container to make it a child of that container. |
Clicking the background works only when there is background left to click, and a form filled edge to edge by a docked control has none. Escape needs nowhere to click, so it works on any form. While you are typing in a property field it is left to the field, as you would expect.
On a form larger than the visible canvas the grips sit past the scroll. Select the form and type into Width and Height instead.
The canvas shows what will actually happen
Docked controls are drawn glued to their parent's edge, not at the coordinates they were dropped at, because the canvas runs the framework's own arrange pass. A grid's column widths come from the same fitting code the runtime uses. If the canvas and the running app disagree, that is a bug worth reporting rather than something to design around.
Properties and events
The properties panel
| Group | What it edits |
|---|---|
| Name | Renames the struct field, every reference to it, and any handler still named after it — across both files. Invalid names are refused rather than half-applied. |
| Parent | Moves the control into another container, including one half of a SplitContainer or a specific tab page. |
| Bounds | X, Y, Width, Height. |
| Text / Items / Columns | The caption, or the list a ComboBox, ListBox, ListView or DataGridView carries. |
| Collection | Tab titles, ToolStrip buttons, StatusStrip panels, tree nodes — with the right editor per type, including a click-handler box for ToolStrip buttons. |
| Properties | Every setter the catalogue knows, with an editor chosen by type: a checkbox for booleans, a spinner for numbers, a dropdown for enums, a row of checkboxes for flag sets like Anchor. |
| Align | Appears with more than one control selected: align left, right, top, bottom, centre horizontally or vertically, same width, same height. Everything aligns to the last-clicked control, as in WinForms. |
The events panel
Each control lists its own events first, then the ones inherited from
Control, with a divider between. Type a name or
accept the suggested <id>_<Event>,
press Wire, and three things happen at once:
-
A stub is created in the paired hand-written file, with the correct
argument type —
e goforms.MouseEventArgsfor aClick,e goforms.KeyEventArgsfor aKeyDown— fully qualified, and thegoformsimport is added if that file did not have one. -
The
Handlecall is written intoinitializeComponent. Re-wiring an event rewrites that line rather than adding a second one:Event.Handleis multicast, so an appended call would leave both handlers running. - Your cursor is put inside the new stub.
Go to beside an already-wired event jumps to its definition, wherever it lives.
The paired file first — Foo-designer.go pairs
with Foo.go. If that does not exist, another
.go file in the same directory that already
declares the receiver type; failing that, the alphabetically first
non-designer file; and if the directory has nothing else at all, the
counterpart is created from scratch, complete with package clause and
import.
Commands and shortcuts
Everything below is on the command palette (Ctrl+Shift+P, then type "GoForms").
| Command | Does |
|---|---|
| GoForms: Create New Project… | Scaffolds an app — go.mod, main.go, a MainForm — from an empty or an example template. Asks whether to use the published module or a local checkout (the published module is the default and needs nothing on disk), and how the project should look: the default, a light or dark scheme, or an empty theme. |
| GoForms: New Form… | Adds a <Name>.go + <Name>-designer.go pair to any folder. Also on the Explorer's folder right-click menu. |
| GoForms: Open Visual Designer | Opens the designer for the current file. |
| GoForms: Open as Text | The inverse — the plain Go source. |
| GoForms: Tidy Designer File | Runs the cleanup pass by hand. The designer does it after every edit of its own, so this is for files edited outside it. |
| GoForms: Edit Theme | Opens the project's <project>-styles.go as a visual theme editor. Finds the file itself, and asks only if there is more than one. |
| GoForms: Open Theme as Text | The inverse — the styles file as plain Go. |
| GoForms: Check Setup | Which go was found and everywhere it looked, whether the helper CLI builds and answers, and where the framework checkout is. Start here when something does not work. |
| GoForms: Set Framework Path… | Points at a local GoForms checkout, for projects that build against one. |
| Key | In the designer |
|---|---|
| Delete | Deletes the selection. |
| Ctrl+Z | Undo. |
| Ctrl+Y / Ctrl+Shift+Z | Redo. |
| Ctrl / Shift + click | Extends the selection. |
| Escape | Selects the form, leaving a control's properties. |
It edits the file through a helper process that writes to disk directly, so the editor's own undo never sees those changes. Ctrl+Z inside the designer steps through the designer's snapshots. Ctrl+Z in the text editor undoes what you typed there, which is a different history.
Settings
| Setting | Meaning |
|---|---|
goforms.goPath | Full path to a go executable, for when the editor cannot find one. A GUI editor inherits the desktop session's PATH, not the one your shell builds, so a Go under /usr/local/go/bin or managed by asdf/mise is often invisible to it. |
goforms.frameworkPath | A local GoForms checkout, used when a new project opts to build against one. |
The theme editor
A project's whole look is one goforms.Theme
literal: eleven colours and four metrics, handed to
goforms.SetTheme before any form is created.
Every field left out keeps Fyne's default for that property, which is what
lets a theme change one colour without restating the other ten.
GoForms: Edit Theme opens that file —
<project>-styles.go, beside
main.go — as a visual editor: a picker and a hex
box per colour, numbers for the metrics, and a preview beside them.
package main import "github.com/Go-Forms/GoForms" func Theme() goforms.Theme { return goforms.Theme{ Name: "Midnight", Dark: true, Background: goforms.RGB(0x1E, 0x1F, 0x22), Foreground: goforms.RGB(0xE6, 0xE7, 0xEA), Primary: goforms.RGB(0x4C, 0x97, 0xFF), Selection: goforms.RGBA(0x4C, 0x97, 0xFF, 0x40), Padding: 6, } }
goforms.NewApplication("com.example.myapp") goforms.SetTheme(Theme()) // before any form exists goforms.Run(mainform.NewMainForm().Form)
Unset is a choice, not a blank
Every row has a clear button that returns the field to Fyne's default, and an unset colour is still drawn in the preview as the default it falls through to — so the preview shows what the theme will actually look like, not only the parts it sets. A field holding an expression rather than a literal, like a colour from one of your own constants, is shown read-only: the editor will not flatten a decision it cannot see the reason for.
<input type="color"> has no alpha
channel, so typing #4c97ff40 into the box
beside the picker is the only way to get a translucent colour — which
Selection usually wants.
Setting a theme without the editor
Nothing about it is special. It is ordinary Go, so it can be built inline,
read from a config file, or switched at runtime — SetTheme
restyles every form that is already open as well as any created later.
// Start from one of the built-ins and change one thing. t := goforms.DarkTheme() t.Primary = goforms.RGB(0xE0, 0x4C, 0x2E) goforms.SetTheme(t) // Or read the one in force. if goforms.CurrentTheme().Dark { // ... }
Styling one control, or one form
A theme is application-wide. For anything narrower there is
Style — a font, a foreground and a background
bundled together — and the individual setters.
accent := goforms.Style{ Font: goforms.NewFont("", 14, true, false), ForeColor: goforms.RGB(90, 170, 255), } btn.SetStyle(accent) // one control panel.ApplyStyle(accent) // a container's children, recursively form.ApplyStyle(accent) // every control on a form lbl.SetForeColor(goforms.RGB(90, 170, 255)) // or one at a time panel.SetBackColor(goforms.RGB(60, 110, 180))
A nil colour or a zero
Font in a Style
means "leave that alone", so a Style can change only the part it cares
about.
Size, bold and italic work everywhere. The family only takes effect where a control draws its own text; Fyne's built-in widgets render with the theme's font and expose no per-instance family. That is a toolkit limit, not an omission here.
What tidy removes
A designer file is machine-managed, and a long session leaves statements
behind that no longer say anything: a setter written once per drag, an event
re-wired into a stack of Handle calls, a control
added to two containers. None of it shows in the designer — the model
reflects only the last value of each — so it accumulates unnoticed.
Every edit therefore ends with a cleanup pass. It removes:
- setter calls that a later call already overrides;
- all but the last wiring of an event;
- repeated
AddControlof the same control; - duplicate struct field declarations;
- whole lines that are nothing but a commented-out generated statement.
The rule behind all five is the same: two statements that state the same single fact are two answers to one question, and only the last of them can be observable. So the last survives and the earlier ones go.
What the designer will not touch
This is the part worth trusting, because it is what makes hand-editing a designer file safe.
Only statements the catalogue models are ever rewritten.
A Timer, a MenuStrip,
a loop, a call into your own code, a helper function — anything the tool does
not recognise is left exactly where it is, by both the edit pass and the
cleanup pass.
Specifically:
-
Item-adding calls are never collapsed.
AddTab("Page")twice means two pages with the same title, not one line written twice. -
Indexed setters are never collapsed.
SetColumnStyle(0, …)andSetColumnStyle(1, …)say different things. - A form title that is not a plain string literal is refused. If yours comes from a constant or a function call, the designer reports that rather than replacing the expression with a literal.
- An unrecognised control type is shown read-only. It appears on the canvas and in the properties panel so you can see it, but nothing about it is rewritten.
- Every edit is atomic. If any part of a batch fails to apply, the file is restored to exactly what it was.
- A cleanup that would not leave the file parseable is abandoned rather than written.
You can hand-edit a designer file freely. Expect it to be gofmt'd, and expect anything genuinely redundant to be removed next time the designer saves it.
C# to Go, side by side
| WinForms | GoForms |
|---|---|
class MainForm : Form | type MainForm struct { *goforms.Form } |
this | mf.Form where a *Form is wanted |
Location + Size | SetBounds(x, y, w, h) |
btn.Click += Handler | btn.Click.Handle(handler) |
void H(object s, EventArgs e) | func H(sender any, e goforms.EventArgs) |
Controls.Add(c) | AddControl(c) |
form.Show() | form.Show() |
form.ShowDialog() | form.ShowDialog() — from a goroutine |
DialogResult = OK; Close() | CloseWithResult(goforms.DialogOK) |
MessageBox.Show(...) returns | ShowMessageBox(..., func(r DialogResult)) calls back |
e.Cancel = true in FormClosing | the same, on *goforms.CancelEventArgs |
Invoke / BeginInvoke | fyne.Do(func(){ … }) |
Anchor = Top | Right | SetAnchor(goforms.AnchorTop | goforms.AnchorRight) |
Dock = DockStyle.Fill | SetDock(goforms.DockFill) |
Application.Run(new MainForm()) | goforms.Run(NewMainForm().Form) |
Traps worth knowing
ShowDialog from a handler deadlocks
The one that costs the most time, because there is no error — the window just stops. See The UI-thread rule. If a form freezes on a click, read that click's handler first.
.Handle adds, it never replaces
Wiring the same handler twice runs it twice, and there is no
-=. In a designer file this is handled for you —
re-wiring rewrites the line — but code that wires events in a loop or in a
method that can run more than once will accumulate handlers.
Do work in Load, not in the constructor
NewMainForm() returns before the window exists.
Filling lists, measuring, or anything that touches the window belongs in a
Load handler.
A docked control ignores its bounds
Setting SetBounds on something with a
Dock other than DockNone
changes nothing visible — that is correct behaviour, and the same as
WinForms. If a control will not move, check its Dock.
Dock order is add order
Two docked siblings compete for the corner, and the one added first wins.
Reordering the AddControl calls is how you change
which.
A child's coordinates are relative to its parent
A control at (0, 0) on a Panel sits at the
panel's top-left corner, not the form's. Moving a control between containers
in the designer keeps its position within the new parent, which is usually
what you want and occasionally surprising.
The generated designer file is not a place for logic
It is regenerated. Anything the catalogue does not model survives, but putting business logic there is fighting the tool — the other file exists precisely so you do not have to.
Stop your timers on Closed
A timer outlives the form that made it, and keeps firing at controls that are gone.
Read dialog results before crossing goroutines
Copy what you need out of the dialog on the goroutine that owns it, and pass
the copy into fyne.Do — see
Passing data both ways.
Where to go next
- GoFormsShowcase — one form per subject with a live event log. Every claim on this page can be checked by running it.
- The reference docs — controls, layout, events and the designer, in more detail per topic.
- Issues — for anything here that turns out to be wrong.