GoForms
EN RU

Руководство GoForms

Всё, что не очевидно из API: как формы добираются друг до друга, как диалог возвращает ответ, в какой вы горутине, что дизайнер перепишет, а что не тронет, — и десяток граблей, о которых лучше узнать до, а не после.

Установка

GoForms — обычный Go-модуль. Ни вендоринга, ни директивы replace не требуется:

терминал
go get github.com/Go-Forms/GoForms

Путь модуля и имя пакета различаются — для Go это нормально, но увидеть стоит один раз: путьgithub.com/Go-Forms/GoForms, а связывает он имя goforms. Псевдоним не нужен.

main.go
package main

import "github.com/Go-Forms/GoForms"

func main() {
	// id — идентичность приложения для ОС; подойдёт любая строка
	// в обратной DNS-нотации, которой вы владеете. Вызывается первым.
	goforms.NewApplication("com.example.myapp")

	goforms.Run(mainform.NewMainForm().Form)
}

Отрисовкой занимается Fyne, а это CGo, поэтому для сборки нужен C-компилятор и заголовки OpenGL вашей платформы. На Debian и Ubuntu:

terminal
sudo apt-get install gcc pkg-config libgl1-mesa-dev xorg-dev \
                     libxkbcommon-dev libwayland-dev
Зачем libwayland-dev

Fyne на Linux по умолчанию собирает свою ветку Wayland и обращается к ней через #cgo pkg-config: wayland-client. Без этого пакета сборка падает с ошибкой pkg-config, в первой строке которой нет ни слова ни про Fyne, ни про Wayland.

Устройство формы

Форма — это два файла, ровно как форма WinForms есть Form1.cs плюс Form1.Designer.cs. Разделение — соглашение, на которое опирается дизайнер, а не требование фреймворка; но именно оно позволяет таскать контролы, не боясь, что дизайнер тронет ваш код.

ФайлСодержитКто пишет
MainForm-designer.go Поля структуры, по одному на контрол, и initializeComponent() — создание, границы, свойства, привязка событий. Дизайнер. Править руками можно, но будьте готовы к переформатированию.
MainForm.go Тела обработчиков и всё остальное, что пишете вы. Вы. Дизайнер только дописывает сюда заглушки.
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 — то же самое
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);
    }
}

Три отличия стоит назвать отдельно — именно на них спотыкаются чаще всего:

  • Встраивание, а не наследование. *goforms.Form встроен, поэтому mf.Show(), mf.Close() и события формы поднимаются на ваш тип. Там, где C# передал бы this, Go передаёт mf.Form.
  • Прямоугольник задаётся одним вызовом. SetBounds(x, y, w, h) вместо раздельных Location и Size.
  • .Handle(…) — это +=. События multicast: вызвали дважды — обработчик отработает дважды.

Жизненный цикл и события

Форма поднимает четыре события. Порядок такой, и Load срабатывает один раз: спрятать форму и показать снова не поднимет его второй раз — ровно как в WinForms.

СобытиеАргументКогда
LoadEventArgsОдин раз, при первом показе формы. Заполняйте списки и задавайте начальное состояние здесь, а не в конструкторе.
ResizeEventArgsПри каждом изменении размера окна. Новый размер читается через ClientSize().
Closing*CancelEventArgsПеред тем как окно исчезнет — от крестика, Alt+F4 или вызова Close() одинаково. Присвойте e.Cancel = true, чтобы отменить.
ClosedEventArgsПосле того как окно закрылось. Здесь вы сбрасываете свою ссылку на форму.
Closing принимает указатель

Closing — это Event[*CancelEventArgs], а не Event[CancelEventArgs]. Указатель здесь обязателен: весь смысл в том, что ваш обработчик пишет e.Cancel, а фреймворк читает это обратно. Обработчик со значением не подойдёт по типу события и просто никогда не будет вызван.

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

Правило UI-потока

Самое важное на этой странице. Прочитайте до того, как напишете обработчик, который чего-то ждёт.

Каждый обработчик события выполняется в UI-горутине — той единственной, которая рисует. Пока работает ваш обработчик, ничего не перерисовывается и ввод не обрабатывается. Отсюда два правила, и нарушение любого даёт замерзшее окно, а не сообщение об ошибке.

1. Никогда не блокируйте внутри обработчика

Всё, что ждёт, — HTTP-запрос, обращение к базе, time.Sleep и в особенности ShowDialog() — должно выполняться в горутине, которую вы запустили сами.

2. Возвращайтесь через fyne.Do

Выйдя из UI-горутины, вы не имеете права трогать контролы. Возвращайтесь через fyne.Do — он ставит функцию в очередь на выполнение в UI-горутине.

Неправильно — окно замрёт
func (mf *MainForm) btnLoad_Click(
	sender any, e goforms.MouseEventArgs,
) {
	// Блокирует ту самую горутину, которая должна
	// рисовать индикатор, так что он не появится.
	rows := fetchFromServer()
	mf.grid.SetRows(rows)
}
Правильно
func (mf *MainForm) btnLoad_Click(
	sender any, e goforms.MouseEventArgs,
) {
	mf.status.SetText("Loading…")

	go func() {
		rows := fetchFromServer()  // вне UI-потока

		fyne.Do(func() {          // обратно в него
			mf.grid.SetRows(rows)
			mf.status.SetText("Ready")
		})
	}()
}

fyne.Do живёт в fyne.io/fyne/v2, поэтому файл, который его использует, импортирует Fyne напрямую. Это единственное место, где GoForms не прячет Fyne, — потому что поток, в который ставится задача, принадлежит ему.

Как это выглядит

Заблокировавший обработчик не падает и ничего не пишет в лог. Окно перестаёт перерисовываться — белеет или застывает на последнем кадре, — и в конце концов ОС предлагает его убить. Если форма замирает ровно в момент клика, смотрите сначала на обработчик этого клика.

Открыть другую форму

Немодальная форма — это Show(). Внимания требует только ссылка: больше форму никто не держит, и ничто не мешает открыть вторую её копию.

MainForm.go
// Поле формы, а не локальная переменная: локальная выйдет из области
// видимости, как только обработчик вернётся, и второй клик по кнопке
// откроет второе окно поверх первого.
type MainForm struct {
	*goforms.Form
	// ... поля от дизайнера ...
	settings *settingsform.SettingsForm
}

func (mf *MainForm) btnSettings_Click(sender any, e goforms.MouseEventArgs) {
	if mf.settings == nil {
		mf.settings = settingsform.NewSettingsForm()

		// Сброс поля в Closed — то, из-за чего следующий клик откроет
		// новое окно, а не попытается показать закрытое.
		mf.settings.Closed.Handle(func(sender any, e goforms.EventArgs) {
			mf.settings = nil
		})
	}
	mf.settings.Show()
}

Show() на уже видимой форме выводит её вперёд, поэтому проверка выше даёт «открыть или сфокусировать уже открытую» — то, чего от меню «Сервис» и ждут.

Прятать вместо закрытия, когда повторное открытие должно быть дешёвым

Hide() сохраняет форму и её состояние; Close() уничтожает окно. После скрытия Load заново не сработает, поэтому спрятанная и снова показанная форма сохраняет всё, что пользователь в неё ввёл.

Свой диалог

Отдельного типа для диалога нет. Диалог — это форма, кнопки которой вызывают CloseWithResult вместо Close. Весь механизм. Собирается в дизайнере, как любая другая форма.

ConfirmForm-designer.go — собрано в дизайнере
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 — две строки, которые делают её диалогом
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 запоминает результат и дальше идёт тем же путём, что обычное закрытие: Closing по-прежнему получает право вето, а отменённое закрытие оставляет диалог открытым и без записанного результата.

Чтобы это ощущалось диалогом

Три необязательных штриха на самой форме, которые отличают диалог от просто небольшого окна:

ConfirmForm.go
func (f *ConfirmForm) ConfirmForm_Load(sender any, e goforms.EventArgs) {
	f.SetFixedSize(true)      // без ползунка размера
	f.CenterOnScreen()        // ShowDialog делает это и сам
	f.btnOK.SetAnchor(goforms.AnchorBottom | goforms.AnchorRight)
}

Передача данных в обе стороны

ShowDialog возвращает только DialogResult. Всё, что богаче, едет на структуре самого диалога — ровно то же, что C# делает публичным свойством, и потому конструктор и поля добавляете вы.

Внутрь: аргумент конструктора

EditCustomerForm.go
// Сгенерированный NewEditCustomerForm() ничего не принимает. Добавьте
// свой конструктор рядом, а не правьте designer-файл — его дизайнер
// перегенерирует.
func NewEditCustomerFormFor(c Customer) *EditCustomerForm {
	f := NewEditCustomerForm()
	f.customer = c
	f.txtName.SetText(c.Name)
	f.txtEmail.SetText(c.Email)
	return f
}

Наружу: поле, которое вызывающий читает после закрытия

EditCustomerForm.go
type EditCustomerForm struct {
	*goforms.Form
	// ... designer fields ...

	// Result — то, что вызывающий читает, когда ShowDialog вернул OK.
	// Читать безопасно тогда и только тогда: ShowDialog не вернётся,
	// пока форма не закрыта, так что писать в него уже некому.
	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 — вызывающая сторона
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) })
		}
	}()
}
Читайте поле до fyne.Do, а не внутри

saved := dlg.Result выполняется в горутине, которой принадлежит dlg, и в UI-горутину переезжает копия. Чтение dlg.Result изнутри замыкания было бы обращением второй горутины к памяти диалога — здесь практически безобидным, но это ровно та конструкция, на которую go test -race и создан ругаться.

Смена вида внутри одного окна

Иногда второе окно не нужно вовсе — нужно заменить содержимое текущего, как это делает мастер или окно настроек, переходя между страницами. ViewContainer — это оно: TabControl, у которого полосу вкладок можно спрятать, и тогда переключением страниц управляете вы.

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 превращает его в обычный стек страниц: страницы
	// на месте, но полосы, по которой можно кликнуть, нет.
	f.views.SetTabAlignment(goforms.TabsHidden)
	f.AddControl(f.views)
}
ShellForm.go
func (f *ShellForm) ShellForm_Load(sender any, e goforms.EventArgs) {
	// Каждая страница — контейнер: кладите на неё контролы ровно
	// так же, как на 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()
}
МетодЧто делает
AddPage(title)Добавляет страницу и возвращает её. Полученный *ViewPage принимает детей как Panel.
SetSelectedIndex(i)Переключает на страницу по индексу.
SelectNext() / SelectPrevious()Шаг вперёд и назад — для кнопок мастера.
SelectedPage() / SelectedIndex()Читает текущую страницу.
SetTabAlignment(a)TabsTop, TabsBottom, TabsLeft, TabsRight, TabsHidden. Left и right бесплатно дают навигацию сайдбаром.
SelectedIndexChangedСрабатывает и на клик по вкладке, и на SetSelectedIndex, поэтому код, управляющий контейнером с ваших кнопок, видит то же событие.

Какой из трёх подходов выбрать

Что нужноЧто использовать
Вспомогательное окно, которое пользователь держит открытым рядом с главнымВторая форма, Show()
Ответ, без которого дальше ничего не происходитВторая форма, ShowDialog() в горутине
То же окно, показывающее другое содержимоеViewContainer с TabsHidden
Страницы, между которыми выбирает сам пользовательTabControl или ViewContainer с видимой полосой

Закрытие, отмена, подтверждение

Любой путь наружу из формы — крестик, Alt+F4, Close(), CloseWithResult() — сначала проходит через Closing. Один обработчик покрывает все.

Подтверждение закрытия — тот случай, на котором спотыкаются, потому что очевидный вариант не работает: спросить пользователя внутри Closing и дождаться ответа нельзя — ждать там значит ждать в UI-горутине. Сначала наложите вето, потом спрашивайте, и закройте снова, когда узнаете ответ.

EditorForm.go
func (f *EditorForm) EditorForm_Closing(sender any, e *goforms.CancelEventArgs) {
	if !f.dirty || f.confirmed {
		return // терять нечего, или пользователь уже согласился
	}

	// Остановить это закрытие, затем спросить. ShowMessageBox не
	// блокирует - он сообщает через колбэк - поэтому обработчик
	// возвращается сразу, и UI живёт, пока окно на экране.
	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() // на этот раз Closing пропустит
			}
		})
}
Почему флаг, а не отписка от события

У Event[T] нет -=: обработчики можно добавлять, но не убирать. Булев флаг, который проверяет обработчик, — это идиома, и читается он честнее: «пользователь уже подтвердил» — факт о форме, а не о событии.

Сообщения и ввод строки

Это встроенные диалоги, и у них есть общее свойство, отличающее их от WinForms: ни один не блокирует. Вместо этого они сообщают через колбэк — именно поэтому их можно вызывать прямо из обработчика клика, ничего не заморозив.

C# — возвращает результат
var r = MessageBox.Show(this,
    "Delete this row?", "Confirm",
    MessageBoxButtons.YesNo,
    MessageBoxIcon.Question);

if (r == DialogResult.Yes)
    DeleteRow();
Go — сообщает через колбэк
goforms.ShowMessageBox(mf.Form,
	"Delete this row?", "Confirm",
	goforms.MessageBoxYesNo,
	goforms.MessageBoxQuestion,
	func(r goforms.DialogResult) {
		if r == goforms.DialogYes {
			mf.deleteRow()
		}
	})

Колбэк уже выполняется в UI-горутине, поэтому может трогать контролы напрямую — fyne.Do не нужен. Передавайте nil, если ответ вам не важен.

АргументЗначения
КнопкиMessageBoxOK, MessageBoxOKCancel, MessageBoxYesNo
ЗначокMessageBoxNone, MessageBoxInformation, MessageBoxWarning, MessageBoxError, MessageBoxQuestion

Запросить строку текста

MainForm.go
goforms.ShowInputBox(mf.Form, "New folder", "Name:",
	func(text string, ok bool) {
		if ok && text != "" {
			mf.createFolder(text)
		}
	})

ok равен false, когда пользователь отменил, — и это не то же самое, что пустая строка. Проверяйте оба, если пустое имя для вас что-то значит.

Файлы, папки, цвета

Файловые диалоги — это объекты, которые вы настраиваете и затем показываете, по образцу OpenFileDialog и родственных. Как и окно сообщения, отвечают через колбэк.

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 читает то, что диалог вернул последним, так что
		// переоткрывать файл по пути не нужно.
		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)
	})
}
ТипАналог в WinFormsПримечания
NewOpenFileDialog()OpenFileDialogReadAll() читает выбранный файл, не переоткрывая его.
NewSaveFileDialog()SaveFileDialogWriteAll(data) пишет в выбранное место.
NewFolderBrowserDialog()FolderBrowserDialogВозвращает путь к каталогу.
NewColorDialog()ColorDialogShow(parent, func(c goforms.Color)).
NewColorPickerButton(text, parent)Кнопка, которая сама открывает диалог цвета. Есть в панели инструментов дизайнера.
В песочнице возвращается хэндл, а не путь

Предпочитайте ReadAll и WriteAll самостоятельному переоткрытию по пути. Путь, который отдаёт ОС в песочнице, не всегда тот, который вашему процессу разрешено открыть второй раз.

Позиция, докинг, якоря

У каждого контрола есть прямоугольник, задаваемый одним вызовом. Координаты отсчитываются от того, что его содержит: контрол на Panel позиционируется от левого верхнего угла панели, а не формы.

x, y, ширина, высота
btn.SetBounds(20, 60, 100, 30)

Якоря — что происходит при изменении размера формы

Якорь прибивает край контрола к тому же краю родителя. Прибейте два противоположных края — и контрол растянется между ними.

ЯкорьПоведение при изменении размера
AnchorTop | AnchorLeftОстаётся на месте. Значение по умолчанию — и здесь, и в WinForms.
AnchorTop | AnchorRightЕдет вправо вместе с краем. Кнопки панели справа.
AnchorLeft | AnchorRightРастягивается по горизонтали. Текстовые поля во всю ширину формы.
Все четыреЗаполняет. То, что нужно таблице или списку лога.
AnchorNoneСохраняет расстояние до центра — плавает.

Докинг — прижать к краю

Пристыкованный контрол полностью игнорирует сохранённую позицию и занимает край родителя во всю ширину или высоту того, что осталось после других пристыкованных соседей.

Порядок решает, кому достанутся углы

Докинг применяется в порядке добавления контролов. Добавьте верхнюю плиту первой — она займёт всю ширину, и боковым достанется меньше высоты; добавьте первой боковую — она возьмёт всю высоту. Правило то же, что в WinForms, и именно поэтому дизайнер рисует пристыкованные контролы там, где они окажутся, а не там, куда их бросили.

Классическая раскладка оболочки
f.toolbar.SetDock(goforms.DockTop)     // во всю ширину, сверху
f.status.SetDock(goforms.DockBottom)   // во всю ширину, снизу
f.tree.SetDock(goforms.DockLeft)       // что осталось, вдоль края
f.content.SetDock(goforms.DockFill)    // всё оставшееся

f.AddControl(f.toolbar)               // этот порядок и есть раскладка
f.AddControl(f.status)
f.AddControl(f.tree)
f.AddControl(f.content)

Панели раскладки

Когда арифметика надоедает, работу берут на себя четыре контейнера. Все они есть в панели инструментов дизайнера, и все принимают детей ровно так же, как Panel.

ПанельКак раскладывает детейКлючевое API
FlowLayoutPanel В строку или колонку, с переносом по краю. SetFlowDirection(FlowLeftToRight | FlowTopDown | FlowRightToLeft | FlowBottomUp), SetWrapContents(bool)
TableLayoutPanel По сетке, в порядке добавления. SetColumnStyle(i, Absolute(110) | Percent(40) | AutoSize()), SetRowStyle, AddControlSpanning(c, col, row, colSpan, rowSpan)
SplitContainer На две половины с перетаскиваемым разделителем. Panel1(), Panel2() — обе настоящие контейнеры — и SetSplitterDistance(0.42)
ScrollBox Не меняет, но прокручивает, если не помещаются. Добавляйте детей как обычно.
Таблица из трёх дорожек
f.table.SetColumnStyle(0, goforms.Absolute(110)) // фиксированные 110px
f.table.SetColumnStyle(1, goforms.Percent(40))   // 40% от оставшегося
f.table.SetColumnStyle(2, goforms.AutoSize())    // по ширине содержимого

f.table.AddControl(cell)                       // заполняет ячейки по порядку
f.table.AddControlSpanning(wide, 0, 2, 3, 1)      // колонка 0, строка 2, ширина 3
SetColumnStyle индексный — дизайнер это знает

Вызовы SetColumnStyle(0, …) и SetColumnStyle(1, …) говорят разное, поэтому проход очистки их не трогает. Он схлопывает только сеттеры одного значения, где поздний вызов действительно перекрывает ранний.

Каталог контролов

Тридцать четыре типа, каждый назван по классу System.Windows.Forms, который он замещает, — так что знакомая документация WinForms продолжает работать и для имён, и для свойств, и для событий.

ГруппаТипы
Обычные Label, Button, TextBox, MaskedTextBox, RichTextBox, CheckBox, RadioButton, ComboBox, ListBox, CheckedListBox, PictureBox, ProgressBar, TrackBar, ScrollBar, NumericUpDown, DomainUpDown, DateTimePicker, MonthCalendar, LinkLabel, ColorPickerButton
Контейнеры Panel, GroupBox, TabControl, SplitContainer, FlowLayoutPanel, TableLayoutPanel, ScrollBox, ViewContainer, Splitter
Меню и панели MenuStrip, ToolStrip, StatusStrip, ContextMenu
Данные DataGridView, ListView, TreeView

Для ввода текста есть три конструктора, потому что TextBox из WinForms — это три разных виджета внутри: NewTextBox(), NewMultilineTextBox() и NewPasswordTextBox().

События, которые есть у каждого контрола

Помимо собственных событий, каждый контрол наследует набор взаимодействия от Control. Панель событий в дизайнере рисует между ними разделитель, чтобы было видно, где что.

СобытиеТип аргумента
Click, DoubleClick, MouseDown, MouseUp, MouseMove, MouseWheelMouseEventArgs
KeyDown, KeyUpKeyEventArgs
KeyPressKeyPressEventArgs
MouseEnter, MouseLeave, GotFocus, LostFocus, Resize, Move, VisibleChanged, EnabledChangedEventArgs
Ошибка в типе не скомпилируется

Event[T].Handle принимает EventHandler[T], поэтому обработчик с неверным типом аргумента — это ошибка компиляции, а не молча не вызываемый обработчик. Привязывайте события из дизайнера, и тип он подберёт сам.

Таймеры и фоновая работа

Timer повторяет System.Windows.Forms.Timer: тикает в UI-горутине, поэтому его обработчик может трогать контролы напрямую.

mf.clock = goforms.NewTimer(1000)  // интервал в миллисекундах
mf.clock.Tick.Handle(func(sender any, e goforms.EventArgs) {
	mf.setStatus(1, time.Now().Format("15:04:05"))
})
mf.clock.Start()

// Остановите его, когда форма закрывается, иначе он продолжит
// тикать по контролам, которых уже нет.
mf.Closed.Handle(func(sender any, e goforms.EventArgs) {
	mf.clock.Stop()
})

Таймер — для периодической работы с интерфейсом. Для одной долгой операции берите горутину и fyne.Do, как в разделе Правило UI-потока: таймер, сработавший, пока предыдущий тик ещё выполняется, встанет за ним в очередь.

Дизайнер: холст и выделение

Откройте любой *-designer.go — вместо текстового редактора откроется дизайнер. GoForms: Open as Text переключает на исходник, а кнопка в заголовке вкладки возвращает обратно.

ЖестЧто делает
Перетащить контролДвигает его. Края привязываются к левому, правому краю и центру соседей, а в месте совпадения рисуется направляющая.
Потянуть угловой ползунокМеняет размер. Ползунок появляется только у выделенного контрола.
Потянуть правый или нижний край формыМеняет её ширину или высоту. Краевой ползунок двигает только свою ось.
Потянуть угол формыМеняет обе сразу.
Клик с Ctrl или ShiftРасширяет выделение. Перетаскивание любого участника двигает всю группу.
Клик по фону формы или её заголовку, клик рядом с формой или EscapeВыделяет саму форму — её заголовок и размер появляются в панели свойств.
Перетащить из панели инструментовДобавляет контрол. Бросьте на контейнер, чтобы он стал его дочерним.
Escape — надёжный путь обратно к форме

Клик по фону работает только пока фон есть, а у формы, закрытой пристыкованным контролом от края до края, его нет. Escape не требует никуда попадать и работает на любой форме. Во время набора в поле свойства он отдаётся полю — как и ожидается.

У формы крупнее видимой области холста ползунки уезжают за прокрутку. Выделите форму и введите числа в Width и Height.

Холст показывает, что произойдёт на самом деле

Пристыкованные контролы рисуются прижатыми к краю родителя, а не по координатам, куда их бросили, потому что холст выполняет тот же проход раскладки, что и фреймворк. Ширины колонок таблицы приходят из того же кода подгонки, что работает в рантайме. Если холст и запущенное приложение расходятся — это баг, о котором стоит сообщить, а не то, что надо обходить.

Свойства и события

Панель свойств

ГруппаЧто правит
NameПереименовывает поле структуры, все ссылки на него и любой обработчик, названный по нему, — в обоих файлах. Недопустимые имена отвергаются, а не применяются наполовину.
ParentПереносит контрол в другой контейнер, включая половину SplitContainer или конкретную вкладку.
BoundsX, Y, ширина, высота.
Text / Items / ColumnsПодпись или список, который несёт ComboBox, ListBox, ListView либо DataGridView.
CollectionЗаголовки вкладок, кнопки ToolStrip, панели StatusStrip, узлы дерева — со своим редактором на каждый тип, включая поле обработчика клика для кнопок ToolStrip.
PropertiesКаждый известный каталогу сеттер, с редактором по типу: чекбокс для булевых, счётчик для чисел, выпадающий список для перечислений, ряд чекбоксов для наборов флагов вроде Anchor.
AlignПоявляется при выделении больше одного контрола: выровнять по левому, правому, верхнему, нижнему краю, по центру по горизонтали или вертикали, одинаковая ширина, одинаковая высота. Всё выравнивается по последнему кликнутому контролу — как в WinForms.

Панель событий

Каждый контрол сначала перечисляет свои события, затем унаследованные от Control, с разделителем между ними. Введите имя или примите предложенное <id>_<Event>, нажмите Wire — и произойдут сразу три вещи:

  1. В парном рукописном файле создаётся заглушка с правильным типом аргумента — e goforms.MouseEventArgs для Click, e goforms.KeyEventArgs для KeyDown — с полным квалификатором, и импорт goforms добавляется, если его в файле не было.
  2. Вызов Handle пишется в initializeComponent. Повторная привязка переписывает эту строку, а не добавляет вторую: Event.Handle — multicast, поэтому дописанный вызов оставил бы работать оба обработчика.
  3. Курсор ставится внутрь новой заглушки.

Go to рядом с уже привязанным событием прыгает к его определению, где бы оно ни было.

Куда попадёт заглушка

Сначала парный файл — Foo-designer.go парен с Foo.go. Если его нет — другой .go в том же каталоге, где уже объявлен тип получателя; если и такого нет — первый по алфавиту не-designer файл; а если в каталоге вообще ничего нет, парный файл создаётся с нуля, вместе с объявлением пакета и импортом.

Команды и горячие клавиши

Всё перечисленное — в палитре команд (Ctrl+Shift+P, затем наберите «GoForms»).

КомандаЧто делает
GoForms: Create New Project…Создаёт приложение — go.mod, main.go, MainForm — из пустого шаблона или из примера. Спрашивает, брать ли опубликованный модуль или локальный чекаут (по умолчанию опубликованный, ему ничего не нужно на диске) и как проект должен выглядеть: стандартный вид, светлая или тёмная схема, либо пустая тема.
GoForms: New Form…Добавляет пару <Name>.go + <Name>-designer.go в любую папку. Есть и в контекстном меню папки в проводнике.
GoForms: Open Visual DesignerОткрывает дизайнер для текущего файла.
GoForms: Open as TextОбратное действие — обычный исходник на Go.
GoForms: Tidy Designer FileЗапускает проход очистки вручную. Дизайнер делает это после каждой своей правки, так что команда нужна для файлов, изменённых вне него.
GoForms: Edit ThemeОткрывает <проект>-styles.go как визуальный редактор тем. Находит файл сам и спрашивает, только если их несколько.
GoForms: Open Theme as TextОбратное действие — файл стилей как обычный Go.
GoForms: Check SetupКакой go найден и где искали, собирается ли и отвечает ли вспомогательный CLI, где лежит чекаут фреймворка. Начинайте отсюда, когда что-то не работает.
GoForms: Set Framework Path…Указывает на локальный чекаут GoForms — для проектов, которые собираются против него.
КлавишаВ дизайнере
DeleteУдаляет выделенное.
Ctrl+ZОтменить.
Ctrl+Y / Ctrl+Shift+ZПовторить.
Клик с Ctrl / ShiftРасширяет выделение.
EscapeВыделяет форму, уводя из свойств контрола.
У дизайнера свой стек отмены

Он правит файл через вспомогательный процесс, который пишет на диск напрямую, поэтому редакторская отмена этих изменений не видит. Ctrl+Z внутри дизайнера шагает по его снимкам. Ctrl+Z в текстовом редакторе отменяет то, что вы набрали там, — это другая история.

Настройки

НастройкаЗначение
goforms.goPathПолный путь к исполняемому go — на случай, когда редактор не может его найти. GUI-редактор наследует PATH сессии рабочего стола, а не тот, который строит ваш шелл, поэтому Go в /usr/local/go/bin или под управлением asdf/mise ему часто не виден.
goforms.frameworkPathЛокальный чекаут GoForms — используется, когда новый проект решает собираться против него.

Редактор тем

Весь вид приложения — это один литерал goforms.Theme: одиннадцать цветов и четыре метрики, переданные в goforms.SetTheme до создания любой формы. Каждое пропущенное поле оставляет дефолт Fyne для этого свойства — именно поэтому тема может поменять один цвет, не перечисляя остальные десять.

GoForms: Edit Theme открывает этот файл — <проект>-styles.go рядом с main.go — как визуальный редактор: пикер и hex-поле на каждый цвет, числа для метрик и превью рядом.

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())   // до того, как появится хоть одна форма

goforms.Run(mainform.NewMainForm().Form)

Незаданное — это выбор, а не пустота

У каждой строки есть кнопка сброса к дефолту Fyne, а незаданный цвет всё равно рисуется в превью тем значением, к которому проваливается, — так что превью показывает, как тема будет выглядеть на самом деле, а не только то, что она задаёт. Поле, содержащее выражение вместо литерала — например, цвет из вашей константы, — показывается только для чтения: редактор не станет схлопывать решение, причины которого не видит.

Hex-поле — не удобство

У <input type="color"> нет альфа-канала, поэтому ввод #4c97ff40 в поле рядом с пикером — единственный способ получить полупрозрачный цвет, которого Selection обычно и хочет.

Задать тему без редактора

Ничего особенного в нём нет. Это обычный Go, поэтому тему можно собрать на месте, прочитать из конфига или переключить в рантайме — SetTheme перекрашивает и уже открытые формы, и все созданные позже.

// Взять встроенную и поменять одну вещь.
t := goforms.DarkTheme()
t.Primary = goforms.RGB(0xE0, 0x4C, 0x2E)
goforms.SetTheme(t)

// Или прочитать действующую.
if goforms.CurrentTheme().Dark {
	// ...
}

Стиль одного контрола или одной формы

Тема действует на всё приложение. Для чего-то более узкого есть Style — шрифт, цвет текста и цвет фона в одной связке — и отдельные сеттеры.

accent := goforms.Style{
	Font:      goforms.NewFont("", 14, true, false),
	ForeColor: goforms.RGB(90, 170, 255),
}

btn.SetStyle(accent)      // один контрол
panel.ApplyStyle(accent)  // дети контейнера, рекурсивно
form.ApplyStyle(accent)   // все контролы формы

lbl.SetForeColor(goforms.RGB(90, 170, 255))  // или по одному
panel.SetBackColor(goforms.RGB(60, 110, 180))

nil-цвет или нулевой Font внутри Style означают «не трогать», поэтому Style может менять только то, что ему нужно.

Font.Family запоминается, но применяется не везде

Размер, жирность и курсив работают везде. Семейство применяется только там, где контрол рисует свой текст сам; встроенные виджеты Fyne рисуют шрифтом темы и не дают задать семейство поштучно. Это ограничение тулкита, а не упущение здесь.

Что убирает очистка

Designer-файл управляется машиной, и долгая сессия оставляет в нём операторы, которые уже ничего не значат: сеттер, записанный на каждое перетаскивание, событие, перепривязанное в стопку вызовов Handle, контрол, добавленный в два контейнера. В дизайнере этого не видно — модель отражает только последнее значение каждого — поэтому мусор копится незаметно.

Поэтому каждая правка заканчивается проходом очистки. Он убирает:

  • вызовы сеттеров, которые уже перекрыты более поздним вызовом;
  • все привязки события, кроме последней;
  • повторные AddControl одного и того же контрола;
  • дублирующиеся объявления полей структуры;
  • строки целиком, состоящие из закомментированного сгенерированного оператора.

За всеми пятью стоит одно правило: два оператора, утверждающие один и тот же факт, — это два ответа на один вопрос, и наблюдаемым может быть только последний. Поэтому последний остаётся, а ранние уходят.

Что дизайнер не тронет

Этой части стоит доверять — именно она делает безопасной ручную правку designer-файла.

Переписываются только операторы, которые описаны в каталоге. Timer, MenuStrip, цикл, вызов вашего кода, вспомогательная функция — всё, что инструмент не распознаёт, остаётся ровно на месте, и при правке, и при очистке.

Конкретно:

  • Вызовы добавления элементов не схлопываются никогда. AddTab("Page") дважды — это две вкладки с одинаковым заголовком, а не одна строка, написанная дважды.
  • Индексные сеттеры не схлопываются никогда. SetColumnStyle(0, …) и SetColumnStyle(1, …) говорят разное.
  • Заголовок формы, не являющийся строковым литералом, отвергается. Если у вас он приходит из константы или вызова функции, дизайнер сообщит об этом, а не заменит выражение литералом.
  • Нераспознанный тип контрола показывается только для чтения. Он появляется на холсте и в панели свойств, чтобы вы его видели, но ничего в нём не переписывается.
  • Каждая правка атомарна. Если хоть одна часть пакета не применилась, файл возвращается ровно к тому, чем был.
  • Очистка, после которой файл перестал бы разбираться, отменяется, а не записывается.

Designer-файл можно свободно править руками. Ожидайте, что его прогонят через gofmt и что всё действительно лишнее уберут при следующем сохранении из дизайнера.

C# и Go рядом

WinFormsGoForms
class MainForm : Formtype MainForm struct { *goforms.Form }
thismf.Form, где ожидается *Form
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()из горутины
DialogResult = OK; Close()CloseWithResult(goforms.DialogOK)
MessageBox.Show(...) возвращает значениеShowMessageBox(..., func(r DialogResult)) вызывает колбэк
e.Cancel = true в FormClosingто же самое, на *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)

Грабли, о которых стоит знать

ShowDialog из обработчика даёт дедлок

Самая дорогая по времени, потому что ошибки нет — окно просто встаёт. См. Правило UI-потока. Если форма замирает на клике, сначала читайте обработчик этого клика.

.Handle добавляет, но никогда не заменяет

Привязали тот же обработчик дважды — он выполнится дважды, а -= не существует. В designer-файле об этом позаботились: повторная привязка переписывает строку. Но код, который привязывает события в цикле или в методе, способном выполниться не один раз, будет накапливать обработчики.

Работайте в Load, а не в конструкторе

NewMainForm() возвращается до того, как окно появится. Заполнение списков, измерения и вообще всё, что трогает окно, — в обработчик Load.

Пристыкованный контрол игнорирует свои границы

SetBounds на контроле, у которого Dock отличен от DockNone, не меняет ничего видимого — это правильное поведение, такое же, как в WinForms. Если контрол не двигается, проверьте его Dock.

Порядок докинга — это порядок добавления

Два пристыкованных соседа спорят за угол, и выигрывает добавленный первым. Поменять победителя можно, переставив вызовы AddControl.

Координаты ребёнка отсчитываются от родителя

Контрол в (0, 0) на Panel сидит в левом верхнем углу панели, а не формы. Перенос контрола между контейнерами в дизайнере сохраняет его позицию внутри нового родителя — обычно это то, чего вы хотите, и изредка неожиданно.

Сгенерированный designer-файл — не место для логики

Он перегенерируется. Всё, что не описано в каталоге, выживает, но класть туда бизнес-логику — значит бороться с инструментом: второй файл существует ровно для того, чтобы этого не приходилось делать.

Останавливайте таймеры в Closed

Таймер переживает форму, которая его создала, и продолжает бить по контролам, которых уже нет.

Читайте результат диалога до перехода между горутинами

Скопируйте нужное из диалога в той горутине, которой он принадлежит, и передавайте копию в fyne.Do — см. Передача данных в обе стороны.

Куда дальше

  • GoFormsShowcase — по форме на тему с живым логом событий. Любое утверждение с этой страницы можно проверить запуском.
  • Справочная документация — контролы, раскладка, события и дизайнер, подробнее по каждой теме.
  • Issues — если что-то здесь окажется неверным.