GoForms
EN RU

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:

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

main.go
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:

terminal
sudo apt-get install gcc pkg-config libgl1-mesa-dev xorg-dev \
                     libxkbcommon-dev libwayland-dev
Why 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.

FileHoldsWritten 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.
MainForm-designer.go
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)
}
Form1.Designer.cs — the same thing
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.Form is embedded, so mf.Show(), mf.Close() and the form's events are promoted onto your type. Where C# would pass this, Go passes mf.Form.
  • One call sets the rectangle. SetBounds(x, y, w, h) instead of separate Location and Size.
  • .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.

EventArgumentFires
LoadEventArgsOnce, the first time the form is shown. Fill lists and set initial state here, not in the constructor.
ResizeEventArgsEvery time the window's size changes. Read the new size with ClientSize().
Closing*CancelEventArgsBefore the window goes away — from the X button, Alt+F4, or a Close() call alike. Set e.Cancel = true to veto.
ClosedEventArgsAfter the window is gone. This is where you drop your reference to the form.
Closing takes a pointer

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.

MainForm.go
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.

Wrong — freezes the window
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)
}
Right
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.

The symptom

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.

MainForm.go
// 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.

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.

ConfirmForm-designer.go — built in the designer
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
}
ConfirmForm.go — the two lines that make it a dialog
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:

ConfirmForm.go
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

EditCustomerForm.go
// 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

EditCustomerForm.go
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)
}
MainForm.go — the caller
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) })
		}
	}()
}
Read the field before fyne.Do, not inside it

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.

ShellForm-designer.go
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)
}
ShellForm.go
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()
}
MethodDoes
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.
SelectedIndexChangedFires 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 wantUse
A tool window the user keeps open beside the main oneA second form, Show()
An answer before anything else can happenA second form, ShowDialog() on a goroutine
The same window showing something elseViewContainer with TabsHidden
Pages the user picks between themselvesTabControl, 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.

EditorForm.go
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
			}
		})
}
Why a flag rather than unhooking the handler

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.

C# — returns a result
var r = MessageBox.Show(this,
    "Delete this row?", "Confirm",
    MessageBoxButtons.YesNo,
    MessageBoxIcon.Question);

if (r == DialogResult.Yes)
    DeleteRow();
Go — reports through a callback
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.

ArgumentValues
ButtonsMessageBoxOK, MessageBoxOKCancel, MessageBoxYesNo
IconMessageBoxNone, MessageBoxInformation, MessageBoxWarning, MessageBoxError, MessageBoxQuestion

Asking for a line of text

MainForm.go
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.

MainForm.go
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)
	})
}
TypeWinForms equivalentNotes
NewOpenFileDialog()OpenFileDialogReadAll() reads the chosen file without reopening it.
NewSaveFileDialog()SaveFileDialogWriteAll(data) writes to the chosen destination.
NewFolderBrowserDialog()FolderBrowserDialogReturns a directory path.
NewColorDialog()ColorDialogShow(parent, func(c goforms.Color)).
NewColorPickerButton(text, parent)A button that opens the colour dialog itself. In the designer's toolbox.
Sandboxed platforms return a handle, not a path

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.

x, y, width, height
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.

AnchorEffect on resize
AnchorTop | AnchorLeftStays put. This is the default and the WinForms default.
AnchorTop | AnchorRightSlides right with the edge. Toolbar buttons on the right.
AnchorLeft | AnchorRightStretches horizontally. Text boxes that should fill the form.
All fourFills. What a grid or a log list wants.
AnchorNoneKeeps 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.

Order decides the corners

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.

A classic shell layout
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.

PanelArranges childrenKey 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.
A three-track table
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
SetColumnStyle is indexed — the designer knows this

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.

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

EventArgument type
Click, DoubleClick, MouseDown, MouseUp, MouseMove, MouseWheelMouseEventArgs
KeyDown, KeyUpKeyEventArgs
KeyPressKeyPressEventArgs
MouseEnter, MouseLeave, GotFocus, LostFocus, Resize, Move, VisibleChanged, EnabledChangedEventArgs
Getting the type wrong will not compile

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.

GestureDoes
Drag a controlMoves it. Edges snap to siblings' left, right and centre, with a guide line drawn where they line up.
Drag the corner handleResizes it. The handle appears on the selected control only.
Drag the form's right or bottom edgeChanges its width or its height. An edge grip moves only its own axis.
Drag the form's cornerChanges both at once.
Ctrl or Shift clickExtends the selection. Dragging any member then moves the whole group.
Click the form's background or title bar, click beside the form, or press EscapeSelects the form itself — its title and size appear in the properties panel.
Drag from the toolboxAdds a control. Drop it on a container to make it a child of that container.
Escape is the reliable way back to the form

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

GroupWhat it edits
NameRenames 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.
ParentMoves the control into another container, including one half of a SplitContainer or a specific tab page.
BoundsX, Y, Width, Height.
Text / Items / ColumnsThe caption, or the list a ComboBox, ListBox, ListView or DataGridView carries.
CollectionTab titles, ToolStrip buttons, StatusStrip panels, tree nodes — with the right editor per type, including a click-handler box for ToolStrip buttons.
PropertiesEvery 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.
AlignAppears 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:

  1. A stub is created in the paired hand-written file, with the correct argument type — e goforms.MouseEventArgs for a Click, e goforms.KeyEventArgs for a KeyDown — fully qualified, and the goforms import is added if that file did not have one.
  2. The Handle call is written into initializeComponent. Re-wiring an event rewrites that line rather than adding a second one: Event.Handle is multicast, so an appended call would leave both handlers running.
  3. Your cursor is put inside the new stub.

Go to beside an already-wired event jumps to its definition, wherever it lives.

Where the stub lands

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

CommandDoes
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 DesignerOpens the designer for the current file.
GoForms: Open as TextThe inverse — the plain Go source.
GoForms: Tidy Designer FileRuns 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 ThemeOpens 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 TextThe inverse — the styles file as plain Go.
GoForms: Check SetupWhich 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.
KeyIn the designer
DeleteDeletes the selection.
Ctrl+ZUndo.
Ctrl+Y / Ctrl+Shift+ZRedo.
Ctrl / Shift + clickExtends the selection.
EscapeSelects the form, leaving a control's properties.
The designer has its own undo stack

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

SettingMeaning
goforms.goPathFull 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.frameworkPathA 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.

myapp-styles.go
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,
	}
}
main.go
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.

The hex box is not a convenience

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

Font.Family is remembered, not always honoured

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 AddControl of 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, …) and SetColumnStyle(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

WinFormsGoForms
class MainForm : Formtype MainForm struct { *goforms.Form }
thismf.Form where a *Form is wanted
Location + SizeSetBounds(x, y, w, h)
btn.Click += Handlerbtn.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(...) returnsShowMessageBox(..., func(r DialogResult)) calls back
e.Cancel = true in FormClosingthe same, on *goforms.CancelEventArgs
Invoke / BeginInvokefyne.Do(func(){ … })
Anchor = Top | RightSetAnchor(goforms.AnchorTop | goforms.AnchorRight)
Dock = DockStyle.FillSetDock(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.