Руководство GoForms
Всё, что не очевидно из API: как формы добираются друг до друга, как диалог возвращает ответ, в какой вы горутине, что дизайнер перепишет, а что не тронет, — и десяток граблей, о которых лучше узнать до, а не после.
Установка
GoForms — обычный Go-модуль. Ни вендоринга, ни директивы
replace не требуется:
go get github.com/Go-Forms/GoForms
Путь модуля и имя пакета различаются — для Go это нормально, но увидеть стоит
один раз: путь — github.com/Go-Forms/GoForms,
а связывает он имя goforms. Псевдоним не нужен.
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:
sudo apt-get install gcc pkg-config libgl1-mesa-dev xorg-dev \
libxkbcommon-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 |
Тела обработчиков и всё остальное, что пишете вы. | Вы. Дизайнер только дописывает сюда заглушки. |
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); } }
Три отличия стоит назвать отдельно — именно на них спотыкаются чаще всего:
-
Встраивание, а не наследование.
*goforms.Formвстроен, поэтомуmf.Show(),mf.Close()и события формы поднимаются на ваш тип. Там, где C# передал быthis, Go передаётmf.Form. -
Прямоугольник задаётся одним вызовом.
SetBounds(x, y, w, h)вместо раздельныхLocationиSize. -
.Handle(…)— это+=. События multicast: вызвали дважды — обработчик отработает дважды.
Жизненный цикл и события
Форма поднимает четыре события. Порядок такой, и
Load срабатывает один раз: спрятать
форму и показать снова не поднимет его второй раз — ровно как в WinForms.
| Событие | Аргумент | Когда |
|---|---|---|
Load | EventArgs | Один раз, при первом показе формы. Заполняйте списки и задавайте начальное состояние здесь, а не в конструкторе. |
Resize | EventArgs | При каждом изменении размера окна. Новый размер читается через ClientSize(). |
Closing | *CancelEventArgs | Перед тем как окно исчезнет — от крестика, Alt+F4 или вызова Close() одинаково. Присвойте e.Cancel = true, чтобы отменить. |
Closed | EventArgs | После того как окно закрылось. Здесь вы сбрасываете свою ссылку на форму. |
Closing — это
Event[*CancelEventArgs], а не
Event[CancelEventArgs]. Указатель здесь
обязателен: весь смысл в том, что ваш обработчик пишет
e.Cancel, а фреймворк читает это обратно.
Обработчик со значением не подойдёт по типу события и просто никогда не
будет вызван.
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(). Внимания требует
только ссылка: больше форму никто не держит, и ничто не мешает открыть вторую
её копию.
// Поле формы, а не локальная переменная: локальная выйдет из области // видимости, как только обработчик вернётся, и второй клик по кнопке // откроет второе окно поверх первого. 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 заново не сработает, поэтому спрятанная и
снова показанная форма сохраняет всё, что пользователь в неё ввёл.
Модальные формы и результат
ShowDialog() блокирует до закрытия формы и
возвращает её DialogResult — форма та же, что в
WinForms. Важное отличие в том, откуда его можно вызывать.
ShowDialog() блокирует ту горутину, из которой
вызван. Из обработчика клика это UI-горутина — та самая, которая должна
нарисовать диалог, которого вы ждёте. Приложение встаёт в дедлок само с
собой, а диалог так и не появляется.
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() // блокирует здесь if result == goforms.DialogOK { fyne.Do(func() { mf.applyChanges() }) } }() }
Результатов пять:
DialogNone,
DialogOK,
DialogCancel,
DialogYes и
DialogNo.
Форма, закрытая крестиком или Close(), вернёт
DialogNone — так вы отличите «пользователь
отмахнулся» от «пользователь выбрал Отмену».
Свой диалог
Отдельного типа для диалога нет. Диалог — это форма, кнопки которой вызывают
CloseWithResult вместо
Close. Весь механизм. Собирается в дизайнере, как
любая другая форма.
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 запоминает результат и дальше
идёт тем же путём, что обычное закрытие: Closing
по-прежнему получает право вето, а отменённое закрытие оставляет диалог
открытым и без записанного результата.
Чтобы это ощущалось диалогом
Три необязательных штриха на самой форме, которые отличают диалог от просто небольшого окна:
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# делает публичным свойством, и потому
конструктор и поля добавляете вы.
Внутрь: аргумент конструктора
// Сгенерированный NewEditCustomerForm() ничего не принимает. Добавьте // свой конструктор рядом, а не правьте designer-файл — его дизайнер // перегенерирует. func NewEditCustomerFormFor(c Customer) *EditCustomerForm { f := NewEditCustomerForm() f.customer = c f.txtName.SetText(c.Name) f.txtEmail.SetText(c.Email) return f }
Наружу: поле, которое вызывающий читает после закрытия
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) }
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 выполняется в горутине,
которой принадлежит dlg, и в UI-горутину
переезжает копия. Чтение dlg.Result изнутри
замыкания было бы обращением второй горутины к памяти диалога — здесь
практически безобидным, но это ровно та конструкция, на которую
go test -race и создан ругаться.
Смена вида внутри одного окна
Иногда второе окно не нужно вовсе — нужно заменить содержимое текущего, как
это делает мастер или окно настроек, переходя между страницами.
ViewContainer — это оно: TabControl,
у которого полосу вкладок можно спрятать, и тогда переключением страниц
управляете вы.
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) }
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-горутине. Сначала наложите вето, потом спрашивайте, и
закройте снова, когда узнаете ответ.
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: ни один не блокирует. Вместо этого они сообщают через колбэк — именно поэтому их можно вызывать прямо из обработчика клика, ничего не заморозив.
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() } })
Колбэк уже выполняется в UI-горутине, поэтому может трогать контролы
напрямую — fyne.Do не нужен. Передавайте
nil, если ответ вам не важен.
| Аргумент | Значения |
|---|---|
| Кнопки | MessageBoxOK, MessageBoxOKCancel, MessageBoxYesNo |
| Значок | MessageBoxNone, MessageBoxInformation, MessageBoxWarning, MessageBoxError, MessageBoxQuestion |
Запросить строку текста
goforms.ShowInputBox(mf.Form, "New folder", "Name:", func(text string, ok bool) { if ok && text != "" { mf.createFolder(text) } })
ok равен false, когда пользователь отменил, — и
это не то же самое, что пустая строка. Проверяйте оба, если пустое имя для
вас что-то значит.
Файлы, папки, цвета
Файловые диалоги — это объекты, которые вы настраиваете и затем показываете,
по образцу OpenFileDialog и родственных. Как и
окно сообщения, отвечают через колбэк.
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() | OpenFileDialog | ReadAll() читает выбранный файл, не переоткрывая его. |
NewSaveFileDialog() | SaveFileDialog | WriteAll(data) пишет в выбранное место. |
NewFolderBrowserDialog() | FolderBrowserDialog | Возвращает путь к каталогу. |
NewColorDialog() | ColorDialog | Show(parent, func(c goforms.Color)). |
NewColorPickerButton(text, parent) | — | Кнопка, которая сама открывает диалог цвета. Есть в панели инструментов дизайнера. |
Предпочитайте ReadAll и
WriteAll самостоятельному переоткрытию по пути.
Путь, который отдаёт ОС в песочнице, не всегда тот, который вашему процессу
разрешено открыть второй раз.
Позиция, докинг, якоря
У каждого контрола есть прямоугольник, задаваемый одним вызовом. Координаты отсчитываются от того, что его содержит: контрол на Panel позиционируется от левого верхнего угла панели, а не формы.
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(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, MouseWheel | MouseEventArgs |
KeyDown, KeyUp | KeyEventArgs |
KeyPress | KeyPressEventArgs |
MouseEnter, MouseLeave, GotFocus, LostFocus, Resize, Move, VisibleChanged, EnabledChanged | EventArgs |
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 не требует никуда попадать и работает на любой форме. Во время набора в поле свойства он отдаётся полю — как и ожидается.
У формы крупнее видимой области холста ползунки уезжают за прокрутку. Выделите форму и введите числа в Width и Height.
Холст показывает, что произойдёт на самом деле
Пристыкованные контролы рисуются прижатыми к краю родителя, а не по координатам, куда их бросили, потому что холст выполняет тот же проход раскладки, что и фреймворк. Ширины колонок таблицы приходят из того же кода подгонки, что работает в рантайме. Если холст и запущенное приложение расходятся — это баг, о котором стоит сообщить, а не то, что надо обходить.
Свойства и события
Панель свойств
| Группа | Что правит |
|---|---|
| Name | Переименовывает поле структуры, все ссылки на него и любой обработчик, названный по нему, — в обоих файлах. Недопустимые имена отвергаются, а не применяются наполовину. |
| Parent | Переносит контрол в другой контейнер, включая половину SplitContainer или конкретную вкладку. |
| Bounds | X, Y, ширина, высота. |
| Text / Items / Columns | Подпись или список, который несёт ComboBox, ListBox, ListView либо DataGridView. |
| Collection | Заголовки вкладок, кнопки ToolStrip, панели StatusStrip, узлы дерева — со своим редактором на каждый тип, включая поле обработчика клика для кнопок ToolStrip. |
| Properties | Каждый известный каталогу сеттер, с редактором по типу: чекбокс для булевых, счётчик для чисел, выпадающий список для перечислений, ряд чекбоксов для наборов флагов вроде Anchor. |
| Align | Появляется при выделении больше одного контрола: выровнять по левому, правому, верхнему, нижнему краю, по центру по горизонтали или вертикали, одинаковая ширина, одинаковая высота. Всё выравнивается по последнему кликнутому контролу — как в WinForms. |
Панель событий
Каждый контрол сначала перечисляет свои события, затем унаследованные от
Control, с разделителем между ними. Введите имя
или примите предложенное <id>_<Event>,
нажмите Wire — и произойдут сразу три вещи:
-
В парном рукописном файле создаётся заглушка с правильным типом
аргумента —
e goforms.MouseEventArgsдляClick,e goforms.KeyEventArgsдляKeyDown— с полным квалификатором, и импортgoformsдобавляется, если его в файле не было. -
Вызов
Handleпишется вinitializeComponent. Повторная привязка переписывает эту строку, а не добавляет вторую:Event.Handle— multicast, поэтому дописанный вызов оставил бы работать оба обработчика. - Курсор ставится внутрь новой заглушки.
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-поле на каждый цвет, числа для метрик и превью рядом.
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()) // до того, как появится хоть одна форма goforms.Run(mainform.NewMainForm().Form)
Незаданное — это выбор, а не пустота
У каждой строки есть кнопка сброса к дефолту Fyne, а незаданный цвет всё равно рисуется в превью тем значением, к которому проваливается, — так что превью показывает, как тема будет выглядеть на самом деле, а не только то, что она задаёт. Поле, содержащее выражение вместо литерала — например, цвет из вашей константы, — показывается только для чтения: редактор не станет схлопывать решение, причины которого не видит.
У <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 может менять только то, что ему нужно.
Размер, жирность и курсив работают везде. Семейство применяется только там, где контрол рисует свой текст сам; встроенные виджеты Fyne рисуют шрифтом темы и не дают задать семейство поштучно. Это ограничение тулкита, а не упущение здесь.
Что убирает очистка
Designer-файл управляется машиной, и долгая сессия оставляет в нём операторы,
которые уже ничего не значат: сеттер, записанный на каждое перетаскивание,
событие, перепривязанное в стопку вызовов Handle,
контрол, добавленный в два контейнера. В дизайнере этого не видно — модель
отражает только последнее значение каждого — поэтому мусор копится незаметно.
Поэтому каждая правка заканчивается проходом очистки. Он убирает:
- вызовы сеттеров, которые уже перекрыты более поздним вызовом;
- все привязки события, кроме последней;
- повторные
AddControlодного и того же контрола; - дублирующиеся объявления полей структуры;
- строки целиком, состоящие из закомментированного сгенерированного оператора.
За всеми пятью стоит одно правило: два оператора, утверждающие один и тот же факт, — это два ответа на один вопрос, и наблюдаемым может быть только последний. Поэтому последний остаётся, а ранние уходят.
Что дизайнер не тронет
Этой части стоит доверять — именно она делает безопасной ручную правку designer-файла.
Переписываются только операторы, которые описаны в каталоге.
Timer, MenuStrip,
цикл, вызов вашего кода, вспомогательная функция — всё, что инструмент не
распознаёт, остаётся ровно на месте, и при правке, и при очистке.
Конкретно:
-
Вызовы добавления элементов не схлопываются никогда.
AddTab("Page")дважды — это две вкладки с одинаковым заголовком, а не одна строка, написанная дважды. -
Индексные сеттеры не схлопываются никогда.
SetColumnStyle(0, …)иSetColumnStyle(1, …)говорят разное. - Заголовок формы, не являющийся строковым литералом, отвергается. Если у вас он приходит из константы или вызова функции, дизайнер сообщит об этом, а не заменит выражение литералом.
- Нераспознанный тип контрола показывается только для чтения. Он появляется на холсте и в панели свойств, чтобы вы его видели, но ничего в нём не переписывается.
- Каждая правка атомарна. Если хоть одна часть пакета не применилась, файл возвращается ровно к тому, чем был.
- Очистка, после которой файл перестал бы разбираться, отменяется, а не записывается.
Designer-файл можно свободно править руками. Ожидайте, что его прогонят через gofmt и что всё действительно лишнее уберут при следующем сохранении из дизайнера.
C# и Go рядом
| WinForms | GoForms |
|---|---|
class MainForm : Form | type MainForm struct { *goforms.Form } |
this | mf.Form, где ожидается *Form |
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() — из горутины |
DialogResult = OK; Close() | CloseWithResult(goforms.DialogOK) |
MessageBox.Show(...) возвращает значение | ShowMessageBox(..., func(r DialogResult)) вызывает колбэк |
e.Cancel = true в FormClosing | то же самое, на *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) |
Грабли, о которых стоит знать
ShowDialog из обработчика даёт дедлок
Самая дорогая по времени, потому что ошибки нет — окно просто встаёт. См. Правило UI-потока. Если форма замирает на клике, сначала читайте обработчик этого клика.
.Handle добавляет, но никогда не заменяет
Привязали тот же обработчик дважды — он выполнится дважды, а
-= не существует. В designer-файле об этом
позаботились: повторная привязка переписывает строку. Но код, который
привязывает события в цикле или в методе, способном выполниться не один раз,
будет накапливать обработчики.
Работайте в Load, а не в конструкторе
NewMainForm() возвращается до того, как окно
появится. Заполнение списков, измерения и вообще всё, что трогает окно, —
в обработчик Load.
Пристыкованный контрол игнорирует свои границы
SetBounds на контроле, у которого
Dock отличен от DockNone,
не меняет ничего видимого — это правильное поведение, такое же, как в
WinForms. Если контрол не двигается, проверьте его Dock.
Порядок докинга — это порядок добавления
Два пристыкованных соседа спорят за угол, и выигрывает добавленный первым.
Поменять победителя можно, переставив вызовы
AddControl.
Координаты ребёнка отсчитываются от родителя
Контрол в (0, 0) на Panel сидит в левом верхнем
углу панели, а не формы. Перенос контрола между контейнерами в дизайнере
сохраняет его позицию внутри нового родителя — обычно это то, чего вы хотите,
и изредка неожиданно.
Сгенерированный designer-файл — не место для логики
Он перегенерируется. Всё, что не описано в каталоге, выживает, но класть туда бизнес-логику — значит бороться с инструментом: второй файл существует ровно для того, чтобы этого не приходилось делать.
Останавливайте таймеры в Closed
Таймер переживает форму, которая его создала, и продолжает бить по контролам, которых уже нет.
Читайте результат диалога до перехода между горутинами
Скопируйте нужное из диалога в той горутине, которой он принадлежит, и
передавайте копию в fyne.Do — см.
Передача данных в обе стороны.
Куда дальше
- GoFormsShowcase — по форме на тему с живым логом событий. Любое утверждение с этой страницы можно проверить запуском.
- Справочная документация — контролы, раскладка, события и дизайнер, подробнее по каждой теме.
- Issues — если что-то здесь окажется неверным.