---
title: "Grid и формы"
description: "Сетки fyne: NewGridWithColumns, GridWrap и адаптивный грид делят площадь на равные клетки; widget.Form — форма с валидацией полей и кнопками Submit, Cancel."
course: "Fyne: десктопные и мобильные GUI на Go"
url: "https://golangmaster.ru/courses/fyne/lessons/05-grid-form/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Grid и формы

*Курс «Fyne: десктопные и мобильные GUI на Go», урок 5 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/fyne/lessons/05-grid-form/*

Box из четвёртого урока складывает виджеты в ряд, но панель калькулятора, галерея плиток и окно входа требуют другого: равных ячеек и готовой формы. Урок про fyne grid и fyne form: NewGridWithColumns и GridWrap, адаптивная сетка для мобильных, затем widget.Form с подписями, полями и валидацией Entry. Примеры, как всегда, запускаются на твоей машине командой go run . — каркас app.New → окно → SetContent тебе уже знаком.

Прошлый урок научил тебя складывать виджеты в колонки и строки контейнерами Box, и ты уже знаешь, что VBox и HBox растягивают элементы по одной оси, оставляя соседям их минимальный размер. Но для половины интерфейсов Box не подходит: панель калькулятора — это ровные клетки, галерея миниатюр — плитки одинакового размера, а окно входа — подписи слева, поля справа и две кнопки снизу. Для всего этого у Fyne есть второй этаж компоновки: сетки Grid и виджет Form. Если ты пришёл по запросу «fyne grid» или «fyne form» — это тот самый урок. Сначала разберём четыре сетки пакета container и поймём, как каждая делит свободное место, затем соберём форму с валидацией полей и посмотрим, где ошибаются чаще всего. Все примеры курса живут на твоей машине: собери модуль по второму уроку и запускай каждый листинг командой go run . из каталога проекта — окно откроется на твоём экране, и с ним можно будет покликать.

### NewGridWithColumns: равные ячейки

container.NewGridWithColumns(число, объекты...) — самый ходовой грид. Он делит ширину контейнера на равные колонки, высоту — на равные ряды и растягивает каждый объект на всю его ячейку. Проговорим три следствия. Первое: ячейки по-настоящему равные — ширина каждой колонки равна доступной ширине, поделённой на число колонок, поэтому кнопки калькулятора получаются идеально ровными без единого ручного измерения. Второе: лишнее место не остаётся внизу — если контейнер выше минимума, ряды растягиваются; грид не прижимает содержимое к верху, а заполняет отведённое пространство целиком. Третье: ряды считаются автоматически — объектов больше, чем колонок, лишние переносятся на следующий ряд, а число рядов равно округлённому вверх частному от деления. Минимальный размер грида считается от самого крупного ребёнка: каждый элемент просит свой MinSize (четвёртый урок о MinSize помнит), грид берёт максимум и умножает на число колонок и рядов. Отсюда практический вывод: один огромный виджет растянет все ячейки разом — крупные элементы лучше выносить в соседние зоны Border или в прокрутку, а грид оставлять элементам примерно равного калибра.

### Панель калькулятора в три колонки

```go
package main

import (
        "fyne.io/fyne/v2"
        "fyne.io/fyne/v2/app"
        "fyne.io/fyne/v2/container"
        "fyne.io/fyne/v2/widget"
)

func main() {
        myApp := app.New()
        window := myApp.NewWindow("Клавиатура")

        keys := []string{"7", "8", "9", "4", "5", "6", "1", "2", "3", "0"}
        buttons := make([]fyne.CanvasObject, 0, len(keys))
        for _, k := range keys {
                key := k
                buttons = append(buttons, widget.NewButton(key, func() {
                        println("нажата", key)
                }))
        }

        window.SetContent(container.NewGridWithColumns(3, buttons...))
        window.ShowAndRun()
}
```

Десять кнопок ложатся в четыре ряда по три клетки: число рядов Fyne считает сам. Здесь работают два приёма: копия key := k нужна, чтобы каждая кнопка запомнила своё значение — иначе все замыкания увидят последний элемент среза; а готовые кнопки передаются в контейнер распаковкой buttons... — NewGridWithColumns принимает любой список CanvasObject. Панель занимает всё окно: на любой ширине и высоте клетки остаются равными.

### Пустые ячейки и спейсеры

Что делать, если в последнем ряду не хватает элементов? Грид молча оставит ячейки пустыми — визуально это дырки, в которых просвечивает фон окна. Если пустоты не нужны, есть два приёма. Первый — layout.NewSpacer(): добавь его в список объектов как обычный элемент, он займёт свою долю места и исчезнет визуально, потому что спейсер — это CanvasObject без рисунка. Второй — вообще не поручать такой ряд гриду: кнопки согласия и отказа часто кладут в отдельный HBox и размещают под сеткой в зоне Border. Отдельно запомни, что layout.NewSpacer ведёт себя по-разному у разных хозяев: в VBox он раздвигает соседей, а в грид просто съедает ячейку — это один и тот же объект, поведение определяется компоновкой-владельцем. И ещё одна честная оговорка: грид Fyne не умеет объединять ячейки, аналогов colspan и rowspan из HTML-таблиц здесь нет. Сложную раскладку собирают вложенными контейнерами: грид внутри ячейки Border, Box внутри ячейки грида, Stack поверх обоих. Вкладывать контейнеры друг в друга — не костыль, а нормальная практика: дерево содержимого в Fyne так и растёт вниз.

**Вопрос:** В container.NewGridWithColumns(3) передали 7 кнопок. Как ляжет сетка?

- Три ряда по три кнопки, лишняя кнопка обрезана
- Два ряда: три кнопки и четыре кнопки
- ✅ Три ряда: 3 + 3 + 1, последние две ячейки третьего ряда пустые
- Кнопки станут разной ширины, чтобы занять весь ряд

Число рядов равно ceil(7 / 3) = 3: кнопки раскладываются по порядку, слева направо и сверху вниз, а последние две ячейки третьего ряда остаются пустыми. Залить их можно layout.NewSpacer() или ещё одним элементом.

### Как лягут четыре кнопки?

```go
a := widget.NewButton("А", nil)
b := widget.NewButton("Б", nil)
c := widget.NewButton("В", nil)
d := widget.NewButton("Г", nil)
window.SetContent(container.NewGridWithColumns(2, a, b, c, d))
```

**Что выведет программа?**

- Все четыре кнопки в одном ряду
- ✅ Два ряда: А и Б сверху, В и Г снизу
- Одна колонка из четырёх кнопок
- А и Г сверху, Б и В снизу

Грид заполняет клетки построчно: первые объекты попадают в первый ряд слева направо, следующие — во второй. Направление всегда одно: строки сначала, колонки потом; перевернуть порядок нельзя, для этого есть другие компоновки.

### NewGridWithRows и NewAdaptiveGrid: мобильный поворот

container.NewGridWithRows(число, объекты...) — зеркальный вариант: задаёшь число рядов, а колонки считаются сами. Это удобно для панелей, которые делят окно по высоте: два ряда — зона ввода и зона истории переписки. container.NewAdaptiveGrid(n, объекты...) — грид-хамелеон для кроссплатформенности: если контейнер шире, чем выше, объекты встают в один ряд из n колонок; если контейнер уже — в одну колонку из n рядов. Это ровно тот приём, которым делают панель инструментов, одинаково живущую на десктопе и на телефоне: на широком экране четыре кнопки идут линией, на узком — столбиком. Быстрый способ увидеть обе ветки — мобильная симуляция из второго урока: go run -tags mobile, а затем просто сожми окно и посмотри, как панель перестроилась. Помни и про ограничение: AdaptiveGrid смотрит на пропорции контейнера, а не на ориентацию всего окна. Если панель живёт в узкой колонке Border, она останется вертикальной даже на широком мониторе. Иногда это именно то, что нужно; иногда — повод выбрать другую сетку или переставить зоны Border.

### NewGridWrap: плитки фиксированного размера

container.NewGridWrap(размер, объекты...) — вторая большая сетка. Каждому объекту выдаётся строго заданный размер, а укладываются они рядами: заполняют строку по ширине и переносятся на следующую — как плитки в файловом менеджере или миниатюры галереи. Разница с GridWithColumns принципиальная. Там размер ячейки определяет контейнер: растянул окно — клетки выросли вместе с ним. В GridWrap размер задаёшь ты, и он не меняется: растянул окно — плитки остались 120 на 90, но их в ряду стало больше. Именно поэтому GridWrap — выбор для галерей, списков превью и наборов файлов: предсказуемый размер каждого элемента важнее равного деления площади. Второе отличие — вертикальное поведение: строки прижимаются к верху, и если контейнер выше, лишнее место остаётся пустым снизу — грид-wrap, в отличие от обычного, ряды не растягивает. Хочешь прокрутку — оберни сетку в container.NewScroll: сетка посчитает свой размер под текущую ширину, а прокрутка добавит недостающую высоту. В этом же прячется ловушка новичка: задал размер плитки больше ширины окна — получишь по одной плитке в ряду. Тулкит не будет ужимать твои 200 пикселей до 150: размер в GridWrap — закон.

### Галерея цветных плиток

```go
package main

import (
        "image/color"

        "fyne.io/fyne/v2"
        "fyne.io/fyne/v2/app"
        "fyne.io/fyne/v2/canvas"
        "fyne.io/fyne/v2/container"
)

func main() {
        myApp := app.New()
        window := myApp.NewWindow("Палитра")

        paints := []color.NRGBA{
                {R: 231, G: 76, B: 60, A: 255},
                {R: 46, G: 204, B: 113, A: 255},
                {R: 52, G: 152, B: 219, A: 255},
                {R: 241, G: 196, B: 15, A: 255},
                {R: 155, G: 89, B: 182, A: 255},
                {R: 26, G: 188, B: 156, A: 255},
        }
        tiles := make([]fyne.CanvasObject, 0, len(paints))
        for _, p := range paints {
                tile := canvas.NewRectangle(p)
                tiles = append(tiles, tile)
        }

        window.SetContent(container.NewScroll(
                container.NewGridWrap(fyne.NewSize(90, 90), tiles...),
        ))
        window.ShowAndRun()
}
```

Шесть цветных плиток строго 90 на 90: уменьши окно — в ряду станет их меньше, увеличь — больше, но размер каждой плитки неизменен, а лишняя высота уходит под прокрутку. canvas.NewRectangle — примитив из седьмого урока, здесь он просто даёт плиткам цвет. fyne.NewSize собирает размер из ширины и высоты — этой функцией ты будешь пользоваться постоянно.

### Каталог сеток пакета container

| Конструктор | Размер ячеек | Когда брать |
| --- | --- | --- |
| NewGridWithColumns(n, …) | равные, делят всю площадь | панели кнопок, калькуляторы, шахматки |
| NewGridWithRows(n, …) | равные, n рядов | деление по высоте: ввод и история |
| NewAdaptiveGrid(n, …) | ряд на широком, колонка на узком | тулбары под десктоп и мобильные |
| NewGridWrap(size, …) | строго заданный size | галереи, превью, файловые плитки |
| layout.NewGridLayout(n) | как GridWithColumns, но через пакет layout | когда собираешь компоновку вручную |
| layout.NewSpacer() | занимает ячейку, невидим | залить пустую ячейку или раздвинуть соседей |

**Вопрос:** Окно с GridWrap(120×90) растянули вдвое. Что изменится?

- Плитки вырастут вдвое вместе с окном
- ✅ Плитки сохранят размер, но в ряду их станет больше
- Плитки перестроятся в один вертикальный столбец
- Ничего не изменится: GridWrap игнорирует размер окна

В GridWrap размер плитки задаёт программист, а не контейнер: растяжение окна меняет лишь количество плиток в ряду. Растягиваться на ячейку грида объекты умеют в GridWithColumns — там размер клетки диктует контейнер.

### widget.Form: форма как один виджет

Форма входа — самая частая сборка в GUI, и Fyne даёт для неё готовый виджет: widget.Form. Ты передаёшь список пар «подпись — поле», а Form сам раскладывает их столбцом, рисует подписи, выравнивает поля и добавляет снизу две кнопки — Submit и Cancel. Тебе остаётся заполнить поля OnSubmit и OnCancel, и обработчики сразу живут на своих местах. Значения полей читаются обычным способом: entry.Text внутри OnSubmit. Дефолтные подписи кнопок английские — свойства SubmitText и CancelText меняют их на твои. Собирается форма литералом структуры &widget.Form{...}: так все обработчики видны одним взглядом. Элемент списка — widget.NewFormItem(label, field), он возвращает готовую FormItem. Есть и ручной способ: form.Append("Имя", entry) добавляет пару в живую форму — им удобно дорисовывать поля по ходу работы, например когда состав полей зависит от данных. Форма — полноценный CanvasObject: её кладут в SetContent, в зоны Border, в прокрутку, как и любой другой виджет. Валидация подключается к форме одной строкой на поле — этим займёмся прямо сейчас. Под капотом подписи и поля раскладывает компоновка Form из пакета layout — если когда-нибудь понадобится та же раскладка без кнопок, возьми именно её.

### Форма входа целиком

```go
package main

import (
        "fmt"

        "fyne.io/fyne/v2/app"
        "fyne.io/fyne/v2/widget"
)

func main() {
        myApp := app.New()
        window := myApp.NewWindow("Вход")

        login := widget.NewEntry()
        login.SetPlaceHolder("логин")
        password := widget.NewPasswordEntry()
        password.SetPlaceHolder("пароль")

        form := &widget.Form{
                Items: []*widget.FormItem{
                        widget.NewFormItem("Логин", login),
                        widget.NewFormItem("Пароль", password),
                },
                SubmitText: "Войти",
                CancelText: "Сбросить",
                OnSubmit: func() {
                        fmt.Println("вход:", login.Text)
                },
                OnCancel: func() {
                        login.SetText("")
                        password.SetText("")
                },
        }

        window.SetContent(form)
        window.ShowAndRun()
}
```

Form выравнивает подписи и поля, снизу — две кнопки с твоими подписями. OnSubmit читает значения прямо из entry.Text: замыкание помнит login и password. Cancel здесь делает то, что принято у формы входа: чистит поля через SetText — та же кнопка в другом приложении могла бы закрывать окно. widget.NewPasswordEntry — тот же Entry, но символы замаскированы.

### Валидация полей: SetValidator и честный Submit

Красота Form раскрывается с валидацией. Каждому полю назначается проверка: entry.SetValidator(func(s string) error). Вернул nil — поле в порядке; вернул ошибку — Fyne рисует её красным прямо под полем, с тем текстом, который ты написал в errors.New. Проверка срабатывает по мере ввода — это событие OnChanged поля, о нём подробнее в шестом уроке, — и при потере фокуса, а все поля разом — при нажатии Submit. И вот главное правило формы: если хотя бы одно поле невалидно, OnSubmit не вызывается вовсе — кнопка как будто не работает. Это сознательная защита: обработчик отправки гарантированно получает данные, прошедшие проверку. Отсюда два практических вывода. Первый: не дублируй проверки в OnSubmit — там место только для того, чего валидатор знать не может, например занят ли логин на сервере. Второй: если заполняешь поля кодом, а не руками, проверь их сам — у Entry есть метод Validate(), который запускает валидатор и рисует ошибку. Для нестандартных правил описывай обычную func(string) error: хоть с regexp для почты, хоть со strconv для чисел. Это и есть валидация формы в чистом виде: одна функция на поле и ноль проверок в обработчике отправки. Полный разбор пакетных валидаторов — тема восемнадцатого урока, здесь нам хватит функций.

### Два валидатора на форме регистрации

```go
package main

import (
        "errors"
        "strings"

        "fyne.io/fyne/v2/app"
        "fyne.io/fyne/v2/widget"
)

func main() {
        myApp := app.New()
        window := myApp.NewWindow("Регистрация")

        email := widget.NewEntry()
        email.SetValidator(func(s string) error {
                if !strings.Contains(s, "@") {
                        return errors.New("нужен адрес почты со знаком @")
                }
                return nil
        })

        password := widget.NewPasswordEntry()
        password.SetValidator(func(s string) error {
                if len(s) < 8 {
                        return errors.New("пароль короче 8 символов")
                }
                return nil
        })

        form := &widget.Form{
                Items: []*widget.FormItem{
                        widget.NewFormItem("Почта", email),
                        widget.NewFormItem("Пароль", password),
                },
                OnSubmit: func() { println("принято:", email.Text) },
        }

        window.SetContent(form)
        window.ShowAndRun()
}
```

Валидатор — обычная функция: вернул ошибку — поле краснеет, Submit отказывается работать. Пиши тексты ошибок по-человечески: эту строку пользователь прочитает под полем. Заметь: OnSubmit не проверяет почту ещё раз — к моменту вызова валидаторы уже отработали, и данные заведомо чистые.

**Вопрос:** Пользователь нажал Submit, а поле почты невалидно. Что произойдёт?

- OnSubmit вызовется с текущим значением поля
- ✅ OnSubmit не вызовется, под полем останется красная ошибка
- Форма закроется, данные потеряются
- Программа упадёт с паникой

Submit валидирует все поля и при любой ошибке не вызывает OnSubmit — это контракт Form. Пользователь увидит ошибку под полем и сможет исправить ввод; форма останется открытой.

### Сработает ли Submit на пустой форме?

```go
name := widget.NewEntry()
name.SetValidator(func(s string) error {
        if s == "" {
                return errors.New("заполните поле")
        }
        return nil
})
form := &widget.Form{
        Items: []*widget.FormItem{widget.NewFormItem("Имя", name)},
        OnSubmit: func() { println("Отправлено") },
}
// пользователь не трогает поле и сразу жмёт Submit
```

**Что выведет программа?**

- «Отправлено» напечатается сразу: поле никто не менял
- ✅ Ничего не напечатается: валидатор вернул ошибку, OnSubmit не вызван
- Валидатор не запустится, пока пользователь не введёт текст
- Программа завершится с ошибкой валидации

Submit валидирует все поля, включая нетронутые: валидатор получает пустую строку, возвращает ошибку — и OnSubmit блокируется. Именно поэтому формы с обязательными полями нельзя отправить наполовину пустыми, даже если пользователь ни разу не нажимал клавишу.

### Пять правил формы

1. Собирай форму литералом &widget.Form с Items и двумя обработчиками, а не стеком VBox с ручными подписями.
2. Подписи кнопок по умолчанию английские — SubmitText и CancelText переводят их на свои.
3. Валидатор поля — это func(string) error: nil означает «порядок», ошибка рисуется под полем.
4. OnSubmit срабатывает только когда все поля прошли валидацию — не дублируй проверки.
5. Значения читай из entry.Text внутри OnSubmit, а form.Append дорисовывает поля в живую форму.

### Собери форму профиля

Восстанови четыре пропуска: тип формы, конструктор элемента, чтение значения и запуск цикла.

```go
package main

import (
        "fyne.io/fyne/v2/app"
        "fyne.io/fyne/v2/widget"
)

func main() {
        myApp := app.New()
        window := myApp.NewWindow("Профиль")

        name := widget.NewEntry()
        form := &widget.___{
                Items: []*widget.FormItem{
                        widget.___("Имя", name),
                },
                OnSubmit: func() {
                        println("имя:", name.___)
                },
        }

        window.SetContent(form)
        window.___()
}
```

Подсказка: Форма — литерал структуры widget.Form, элемент — NewFormItem, значение поля живёт в поле Text, а каркас завершает ShowAndRun.

Правильные ответы в пропусках: `Form`, `NewFormItem`, `Text`, `ShowAndRun`

### Путь нажатия Submit

Расставь по порядку, что происходит после нажатия кнопки Submit формы.

Правильный порядок строк:

1. `пользователь нажимает кнопку Submit формы`
2. `Form проверяет валидность каждого поля из Items`
3. `хотя бы один валидатор вернул ошибку — путь обрывается: OnSubmit не зовётся`
4. `все поля чисты: Form вызывает обработчик OnSubmit`
5. `OnSubmit читает значения полей через entry.Text`
6. `обработчик обновляет данные и просит у интерфейса Refresh`

> **Честно о запуске кода.** Окна из этого урока откроются только на твоей машине: браузерная песочница курса не умеет запускать графические приложения. Собери модуль по второму уроку, вставь пример в main.go и запусти go run . — сетки и форма появятся на экране, и с ними можно будет покликать.

### Что дальше: события и собственные виджеты

Окно с формой уже живое, но пока всё в нём делает только кнопка Submit. А что вообще происходит, когда ты нажимаешь любую кнопку? Как Fyne решает, кому отдать клик, и можно ли повесить действие на правую кнопку мыши или на Ctrl+N? Шестой урок — [события и обработчики](/courses/fyne/lessons/06-sobytiya/) — разбирает всю вертикаль ввода от OnTapped до горячих клавиш. Если хочется освежить виджеты, из которых собраны поля формы, — вернись к [каталогу базовых виджетов](/courses/fyne/lessons/03-vidzhety/); а про то, [как Box делит место](/courses/fyne/lessons/04-box/) и зачем нужен Spacer, напомнит четвёртый урок. Сетки сегодня встретились с примитивом canvas.NewRectangle — по-настоящему рисовать им научит седьмой урок. Полный план из двадцати пяти уроков лежит на [странице курса Fyne](/courses/fyne/). Домашнее задание ниже соберёт из этого урока маленький, но настоящий инструмент — форма с плитками настроек и прокруткой.

### Термины урока

- **NewGridWithColumns** — Сетка с равными колонками: ширина и высота делятся поровну, объекты растягиваются на ячейку, лишние элементы переносятся в следующий ряд автоматически.
- **NewGridWrap** — Сетка фиксированных плиток: каждому объекту выдаётся заданный размер, ряды переносятся по ширине и не растягиваются по вертикали. Выбор для галерей и превью.
- **NewAdaptiveGrid** — Грид-хамелеон: на широком контейнере кладёт объекты в один ряд, на узком — в одну колонку. Приём для панелей, живущих и на десктопе, и на мобильных.
- **layout.NewSpacer()** — Невидимый объект-заполнитель: в гриде съедает пустую ячейку, в VBox раздвигает соседей. Поведение определяет компоновка-владелец.
- **widget.Form** — Готовая форма: пары «подпись — поле» из []*widget.FormItem, кнопки Submit и Cancel с полями OnSubmit и OnCancel; SubmitText и CancelText меняют подписи.
- **SetValidator** — Валидатор поля — func(string) error: nil — поле в порядке, ошибка рисуется под полем красным. Если хоть одно поле невалидно, OnSubmit не вызывается.

## Коротко: что запомнить

- Grid с равными колонками — container.NewGridWithColumns: делит всю площадь поровну и растягивает объекты на ячейки; ряды считаются автоматически.
- GridWrap задаёт плиткам фиксированный размер и переносит их по ширине контейнера — это сетка галерей и превью, не растягивающая ряды по высоте.
- NewAdaptiveGrid переключается между рядом и колонкой по пропорциям контейнера — база адаптивных тулбаров под десктоп и мобильные.
- Пустые ячейки заливаются layout.NewSpacer(), объединения ячеек нет — сложные раскладки собираются вложенными контейнерами.
- widget.Form собирает пары «подпись — поле» через []*widget.FormItem и NewFormItem, рисует кнопки Submit и Cancel с настраиваемыми подписями.
- Валидация поля — entry.SetValidator(func(string) error): ошибка рисуется под полем, а Submit не вызывает OnSubmit, пока хоть одно поле невалидно.
- Значения полей читаются через entry.Text в OnSubmit; form.Append добавляет пары в живую форму.

## Домашнее задание

Собери окно «Профиль игрока»: слева — форма из трёх полей (никнейм не пустой, почта содержит знак @, возраст — число от 1 до 99, проверяемое через strconv.Atoi с понятным текстом ошибки), справа — грид 2 на 2 из цветных плиток canvas.NewRectangle. Кнопка Submit печатает значения в консоль и не срабатывает, пока валидаторы недовольны; Cancel чистит поля через SetText. Затем замени правый грид на NewAdaptiveGrid и сожми окно до узкого состояния — убедись, что плитки перестроились в столбец. В конце собери третью версию панели на NewGridWrap с плитками 120 на 90, оберни её в container.NewScroll и добейся прокрутки при маленьком окне. Три версии одной панели — лучшая иллюстрация того, какую сетку когда брать.

## Вопросы и ответы по уроку

### Грид портится, когда окно слишком маленькое?

Ячейки ужимаются до нуля без жалоб: грид делит всю площадь, какой бы крошечной она ни была. Если содержимое должно оставаться читаемым, оберни сетку в container.NewScroll или отдай ей центр Border, а соседние зоны сделай фиксированными — узкое окно тогда даст прокрутку, а не кашу.

### Можно объединять ячейки, как colspan и rowspan в HTML?

Нет, объединения ячеек в гриде Fyne не предусмотрено. Сложную раскладку собирают вложенными контейнерами: грид в ячейке Border, Box внутри ячейки грида, Stack поверх. Дерево контейнеров полностью заменяет объединение ячеек и читается лучше табличных трюков.

### Form работает без FormItem — просто со списком виджетов?

Нет: Form строится из []*widget.FormItem — пар «подпись — поле». Поле без подписи кладётся в FormItem с пустым Text. Для столбца полей без подписей логичнее вообще не Form, а VBox или грид: Form ценен именно подписями и кнопками.
