---
title: "Кастомные виджеты"
description: "Свой виджет в Fyne: widget.BaseWidget, CreateRenderer и fyne.WidgetRenderer, Layout, MinSize и Refresh рендерера, Tappable-клики — кастомный контрол."
course: "Fyne: десктопные и мобильные GUI на Go"
url: "https://golangmaster.ru/courses/fyne/lessons/08-kastomnye-vidzhety/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 24
---

# Кастомные виджеты

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

Седьмой урок дал тебе материал: примитивы canvas, из которых нарисован каждый виджет тулкита. Сегодня этот материал станет твоим по праву — запрос «fyne кастомный виджет» ведёт к связке widget.BaseWidget и fyne.WidgetRenderer: встроишь каркас в структуру, опишешь CreateRenderer, соберёшь внешний вид из canvas-объектов и получишь контрол, который кладут в VBox, грид и форму наравне с Button и Label. В англоязычной документации это называется custom widget, и по механике это вершина «сборного» пути курса: рисование седьмого урока плюс события шестого. Разберём минимальный виджет на готовом рендерере, полный рендерер с Layout и MinSize, поведение через Tappable — и гигиену, которая отличает надёжный контрол от хрупкого.

Кнопки, формы и списки из первых уроков закрывают девять задач из десяти — но время от времени нужна деталь, которой в тулките нет: цветная плашка статуса, индикатор уровня, карточка со своим поведением, шкала с делениями. Именно за этим ты сегодня спустишься ещё на один уровень ниже седьмого: не просто рисовать примитивами, а упаковать рисунок в настоящий виджет — с минимальным размером, компоновкой, перерисовкой и кликами. Запрос «fyne кастомный виджет» в документации ведёт к паре widget.BaseWidget и fyne.WidgetRenderer: первый даёт каркас поведения, второй — контракт рисования. Механика везде одна: встраиваешь BaseWidget в структуру, описываешь CreateRenderer, наполняешь рендерер canvas-примитивами — и твой контрол без отличий участвует в любой компоновке четвёртого урока. В англоязычных источниках это называют custom widget, и готовые виджеты из третьего урока устроены ровно так же — после этого урока ты сможешь заглянуть в их исходники и прочитать их как родные.

### widget.BaseWidget: каркас поведения

Кастомный виджет — обычная структура со встроенным widget.BaseWidget: type Swatch struct { widget.BaseWidget; Color color.Color }. Встраивание даёт готовую реализацию fyne.CanvasObject: Resize, Move, MinSize, Show, Hide и Refresh уже работают — BaseWidget хранит размер, позицию и ссылку на рендерер и маршрутизирует все операции через них. Первое обязательное движение — самоссылка: в конструкторе зовут s.ExtendBaseWidget(s). Без неё каркас не знает адрес конкретной структуры, и переопределённые методы — тот же CreateRenderer или Tapped — не вызываются: базовые реализации работают вслепую, события не находят адресата, Refresh обновляет не то, что ты менял. Правило простое: любой конструктор кастомного виджета начинается с двух строк — создать структуру и отдать её саму себе в ExtendBaseWidget. Второе правило — про состояние: данные виджета (Color, Value, Text) живёт в экспортируемых полях, их читает и меняет пользователь твоего контрола; внутренние ссылки на canvas-объекты рендерера прячь в непубличные поля и наполняй их в CreateRenderer, когда тулкит впервые попросит отрисовку.

### Swatch: минимальный виджет

```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"
        "fyne.io/fyne/v2/widget"
)

// Swatch — цветная плашка: минимальный кастомный виджет.
type Swatch struct {
        widget.BaseWidget
        Color color.Color
        rect  *canvas.Rectangle
}

// NewSwatch — конструктор: создать структуру и отдать её каркасу.
func NewSwatch(c color.Color) *Swatch {
        s := &Swatch{Color: c}
        s.ExtendBaseWidget(s)
        return s
}

// CreateRenderer описывает, из чего виджет рисуется.
func (s *Swatch) CreateRenderer() fyne.WidgetRenderer {
        s.rect = canvas.NewRectangle(s.Color)
        return widget.NewSimpleRenderer(s.rect)
}

// SetColor меняет состояние и просит перерисовку.
func (s *Swatch) SetColor(c color.Color) {
        s.Color = c
        if s.rect != nil {
                s.rect.FillColor = c
        }
        s.Refresh()
}

func main() {
        myApp := app.New()
        window := myApp.NewWindow("Swatch")

        swatch := NewSwatch(color.NRGBA{R: 46, G: 204, B: 113, A: 255})

        pick := widget.NewSelect([]string{"Зелёный", "Синий", "Красный"}, func(v string) {
                switch v {
                case "Зелёный":
                        swatch.SetColor(color.NRGBA{R: 46, G: 204, B: 113, A: 255})
                case "Синий":
                        swatch.SetColor(color.NRGBA{R: 52, G: 152, B: 219, A: 255})
                case "Красный":
                        swatch.SetColor(color.NRGBA{R: 231, G: 76, B: 60, A: 255})
                }
        })

        window.SetContent(container.NewVBox(
                container.NewGridWrap(fyne.NewSize(200, 64), swatch),
                pick,
        ))
        window.ShowAndRun()
}
```

Классическая схема в одиннадцати строках: структура встраивает widget.BaseWidget, конструктор зовёт ExtendBaseWidget(s) — без этой самоссылки базовые методы не дошли бы до твоих переопределений. CreateRenderer возвращает widget.NewSimpleRenderer над canvas.Rectangle из седьмого урока: готовый рендерер сам растягивает объект на весь виджет и берёт его MinSize. Guard в SetColor страхует вызов до первой отрисовки — поле rect наполняется только в CreateRenderer. Select из третьего урока меняет цвет одним вызовом SetColor: пользователь виджета не знает про Refresh и FillColor — и не должен.

**Вопрос:** Зачем в конструкторе вызывать s.ExtendBaseWidget(s)?

- ✅ Чтобы BaseWidget знал адрес своей структуры: без самоссылки Refresh, Resize и события не дойдут до твоих переопределений
- Это обязательная регистрация виджета в app.New
- Так виджету выдаётся тема по умолчанию
- Это ручной вызов CreateRenderer при старте

ExtendBaseWidget связывает встроенный каркас с конкретной структурой: переопределённые методы (CreateRenderer, Tapped и другие) вызываются только через эту самоссылку. Без неё базовые реализации работают вслепую — рендерер не строится, события не маршрутизируются.

### fyne.WidgetRenderer: контракт рендерера

Интерфейс fyne.WidgetRenderer — пять методов, и каждый отвечает за свою стадию жизни виджета. Objects() возвращает список canvas-объектов, которые painter рисует на слое виджета: эти же объекты принимают события и участвуют в отрисовке каждого кадра, поэтому возвращай одни и те же экземпляры при каждом вызове — свежесозданный слайс ломает и картинку, и события. Layout(size) — компоновка: тулкит сообщает финальный размер виджета, а ты раскладываешь детей вызовами Resize и Move; вызывается при каждом изменении размеров — окно тянут, контейнер пересчитался, тема сменила масштаб. MinSize() — твоё минимальное требование: родительская компоновка спросит его, строя строку VBox или ячейку грида; вернёшь ноль — виджет сожмут невидимо, ровно как примитивы в седьмом уроке, поэтому честный минимум считается от детей. Refresh() — перерисовка по новым данным: Refresh самого виджета проксирует сюда запрос. Destroy() — последняя воля рендерера: тулкит кеширует рендереры и освобождает их, когда виджет покидает дерево, — внешние ресурсы (буферы, подписки) освобождаются именно там.

### Контракт fyne.WidgetRenderer

| Метод | Назначение | Когда вызывается |
| --- | --- | --- |
| Objects() []fyne.CanvasObject | стабильный список объектов для отрисовки и событий | каждый кадр отрисовки |
| Layout(size fyne.Size) | раскладка и Resize детей под новый размер | при каждом изменении размеров виджета |
| MinSize() fyne.Size | минимальный размер, который требует виджет | при компоновке родительского контейнера |
| Refresh() | привести объекты к актуальным данным | после смены состояния виджета |
| Destroy() | освобождение внешних ресурсов рендерера | когда рендерер выкидывается из кеша |
| widget.NewSimpleRenderer(obj) | готовый рендерер для виджета из одного объекта | в CreateRenderer простых виджетов |

**Вопрос:** Рендерер твоего виджета возвращает MinSize fyne.NewSize(0, 0). Что увидишь в VBox?

- Виджет растянется на всю ширину и высоту окна
- ✅ Строка получит нулевую высоту — виджет исчезнет, как примитив из седьмого урока
- VBox вызовет панику
- Виджет станет квадратным по размеру темы

VBox раскладывает детей по их MinSize, а рендерер отчитался нулём — строка схлопнется. Разница с примитивом только в том, кто отвечает за минимум: у виджета это твой MinSize, и его честно считают от детей — label.MinSize() плюс отступы.

### Полный рендерер: индикатор Gauge

NewSimpleRenderer покрывает случаи «один объект на весь виджет», но настоящий рендерер собирает композицию: фон, заполнение, подпись. Схема такая: в CreateRenderer создаёшь canvas-объекты, складываешь их в поля структуры и возвращаешь собственный тип, реализующий все пять методов. Layout делает математику: ширина полосы заполнения — доля от ширины фона, умноженная на Value; подпись накрывает весь виджет и центрируется выравниванием из седьмого урока. Refresh — самое ответственное место: он вызывается после каждого SetValue и обязан привести объекты к данным заново — переписать текст, пересчитать размер заполнения и позвать Refresh у изменившихся объектов. Обрати внимание на разделение труда: SetValue меняет данные и зовёт Refresh у виджета; виджет проксирует запрос в рендерер; рендерер правит свои canvas-объекты. Ничего из этого не происходит само — за каждый кадр отвечает твоя цепочка из трёх звеньев, и пропустишь одно — на экране останется устаревшая картинка, как если бы ты забыл Refresh у примитива.

### Gauge: свой рендерер целиком

```go
package main

import (
        "fmt"
        "image/color"

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

// Gauge — индикатор значения от 0 до 1.
type Gauge struct {
        widget.BaseWidget
        Value float64

        bg    *canvas.Rectangle
        fill  *canvas.Rectangle
        label *canvas.Text
}

func NewGauge() *Gauge {
        g := &Gauge{}
        g.ExtendBaseWidget(g)
        return g
}

// SetValue — данные меняются, экран просит кадр.
func (g *Gauge) SetValue(v float64) {
        if v < 0 {
                v = 0
        }
        if v > 1 {
                v = 1
        }
        g.Value = v
        g.Refresh()
}

func (g *Gauge) CreateRenderer() fyne.WidgetRenderer {
        g.bg = canvas.NewRectangle(color.NRGBA{R: 230, G: 230, B: 230, A: 255})
        g.fill = canvas.NewRectangle(color.NRGBA{R: 41, G: 128, B: 185, A: 255})
        g.label = canvas.NewText("", theme.ForegroundColor())
        g.label.Alignment = fyne.TextAlignCenter
        return &gaugeRenderer{g: g}
}

// gaugeRenderer — полный рендерер: пять методов контракта.
type gaugeRenderer struct {
        g *Gauge
}

func (r *gaugeRenderer) Objects() []fyne.CanvasObject {
        return []fyne.CanvasObject{r.g.bg, r.g.fill, r.g.label}
}

func (r *gaugeRenderer) MinSize() fyne.Size {
        return fyne.NewSize(180, 28)
}

func (r *gaugeRenderer) Layout(size fyne.Size) {
        r.g.bg.Resize(size)
        r.g.fill.Resize(fyne.NewSize(size.Width*float32(r.g.Value), size.Height))
        r.g.label.Resize(size)
}

func (r *gaugeRenderer) Refresh() {
        w := r.g.bg.Size().Width * float32(r.g.Value)
        r.g.fill.Resize(fyne.NewSize(w, r.g.bg.Size().Height))
        r.g.label.Text = fmt.Sprintf("%.0f%%", r.g.Value*100)
        r.g.label.Color = theme.ForegroundColor()
        r.g.bg.Refresh()
        r.g.fill.Refresh()
        r.g.label.Refresh()
}

func (r *gaugeRenderer) Destroy() {}

func main() {
        myApp := app.New()
        window := myApp.NewWindow("Gauge")

        gauge := NewGauge()
        slider := widget.NewSlider(0, 1)
        slider.Step = 0.05
        slider.OnChanged = func(v float64) {
                gauge.SetValue(v)
        }

        window.SetContent(container.NewVBox(
                container.NewGridWrap(fyne.NewSize(180, 28), gauge),
                slider,
        ))
        window.ShowAndRun()
}
```

Полный контракт: gaugeRenderer возвращает из Objects() одни и те же три объекта, честный MinSize 180 на 28, Layout раскладывает детей по переданному размеру, а Refresh после каждого SetValue переписывает проценты и перерисовывает изменившиеся объекты. theme.ForegroundColor() берёт цвет текста из активной темы — как для widget.Label из третьего урока. Slider из каталога виджетов гонит значение через SetValue: пользователь слайдера и не подозревает, что правее — твой собственный контрол.

### Gauge в VBox

```go
gauge := NewGauge() // рендерер возвращает MinSize 180 x 28
window.SetContent(container.NewVBox(
        gauge,
        widget.NewButton("Плюс", nil),
))
```

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

- Gauge невидим: его высота нулевая, как у примитива
- ✅ Gauge займёт полосу 180 на 28: виджет, в отличие от примитива, умеет свой минимальный размер
- Gauge закроет всё окно и вытеснит кнопку
- Программа запаникует: кастомный виджет нельзя класть в VBox

Ключевая разница между виджетом и примитивом: компоновка спрашивает MinSize у рендерера, и твой контракт вернул 180 на 28 — VBox построит строку этой высоты. Примитив из седьмого урока молчал (нулевой MinSize) и сжимался; у виджета есть слово в споре с компоновкой.

### Поведение: клики, фокус и колбэки

Рисование — половина дела; поведение приходит из интерфейсов событий шестого урока. Реализуй на структуре метод Tapped(*fyne.PointEvent) — виджет становится fyne.Tappable, и клики мыши и тапы приходят сами; метод TappedSecondary добавляет fyne.SecondaryTappable: правую кнопку мыши и долгое нажатие на мобильных. Дальше вопрос дизайна API: наружу торчит не метод, а поле-колбэк OnTapped func() — пользователь твоего контрола подписывается на него, как на Button. Так устроен весь тулкит: Check с OnChanged, Select с OnChanged, List с OnSelected — и твой виджет должен говорить тем же языком, чтобы в чужом коде ничем не отличаться от штатного. Нужен ввод с клавиатуры — реализуй fyne.Focusable с методами FocusGained, FocusLost и TypedRune и получай фокус через canvas.Focus. Перетаскивание — интерфейс fyne.Draggable с Dragged и DragEnd. Каждый интерфейс — несколько строк на структуре, и контрол наращивает поведение слоями, оставаясь при этом обычным участником любой компоновки.

### Card: кликабельная карточка

```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"
        "fyne.io/fyne/v2/widget"
)

// Card — кликабельная карточка с заголовком.
type Card struct {
        widget.BaseWidget
        Title             string
        OnTapped          func()
        OnTappedSecondary func()

        bg   *canvas.Rectangle
        text *canvas.Text
}

func NewCard(title string) *Card {
        c := &Card{Title: title}
        c.ExtendBaseWidget(c)
        return c
}

// Tapped реализует fyne.Tappable — левый клик и тап.
func (c *Card) Tapped(*fyne.PointEvent) {
        if c.OnTapped != nil {
                c.OnTapped()
        }
}

// TappedSecondary реализует fyne.SecondaryTappable — правый клик.
func (c *Card) TappedSecondary(*fyne.PointEvent) {
        if c.OnTappedSecondary != nil {
                c.OnTappedSecondary()
        }
}

func (c *Card) CreateRenderer() fyne.WidgetRenderer {
        c.bg = canvas.NewRectangle(color.NRGBA{R: 245, G: 245, B: 245, A: 255})
        c.text = canvas.NewText(c.Title, color.NRGBA{R: 30, G: 30, B: 30, A: 255})
        c.text.Alignment = fyne.TextAlignCenter
        c.text.TextStyle = fyne.TextStyle{Bold: true}
        return widget.NewSimpleRenderer(container.NewStack(c.bg, c.text))
}

func main() {
        myApp := app.New()
        window := myApp.NewWindow("Карточки")

        status := widget.NewLabel("Кликни карточку")

        card := NewCard("Открыть отчёт")
        card.OnTapped = func() {
                status.SetText("Отчёт открыт")
        }

        second := NewCard("Настройки")
        second.OnTapped = func() {
                status.SetText("Настройки открыты")
        }
        second.OnTappedSecondary = func() {
                status.SetText("Контекстное меню карточки")
        }

        window.SetContent(container.NewVBox(
                container.NewGridWrap(fyne.NewSize(200, 64), card, second),
                status,
        ))
        window.ShowAndRun()
}
```

Два метода — Tapped и TappedSecondary — делают Card реализацией fyne.Tappable и fyne.SecondaryTappable из шестого урока: события приходят сами, лишь бы структура была честным виджетом с BaseWidget. Наружу торчат поля OnTapped и OnTappedSecondary — так пользователь подписывается, не зная про интерфейсы. Внутри рендерера Stack из четвёртого урока: фон-Rectangle и подпись живут одним объектом, и NewSimpleRenderer растягивает стек целиком.

**Вопрос:** Почему Objects() должен возвращать одни и те же объекты при каждом вызове?

- Интерфейс запрещает менять длину возвращаемого слайса
- ✅ Painter и кеш работают с уже созданными объектами: новые экземпляры при каждом кадре ломают отрисовку и события
- Это просто оптимизация компилятора, ничего не ломается
- Потому что Layout вызывается только один раз

Objects() — не конструктор, а инвентарь: painter рисует перечисленные объекты каждый кадр, а события привязаны к ним же. Создавать canvas-объекты положено один раз в CreateRenderer; Objects лишь стабильно перечисляет созданное.

### Гигиена кастомных виджетов

Несколько привычек отличают надёжный контрол от хрупкого. Первая: не создавай canvas-объекты в Refresh — только в CreateRenderer; Refresh обязан обновлять существующие объекты, иначе получишь утечки и мерцание. Вторая: рендерер один на виджет — не вызывай CreateRenderer руками, тулкит сам решает, когда отрисовать и когда отдать рендерер под Destroy. Третья: MinSize считай от детей — label.MinSize() плюс отступы, а не из головы: с другим шрифтом или масштабом интерфейса жёсткие числа поломают раскладку; у Gauge фиксированный размер оставлен ради простоты примера. Четвёртая: темы. Цвета из theme-пакета запоминаются на момент создания и при смене темы сами не обновятся — та же ловушка примитивов из седьмого урока; либо фиксируй цвета сознательно, либо обновляй их в Refresh, как делает gaugeRenderer с theme.ForegroundColor(). Пятая: тяжёлые вычисления кешируй — Refresh может прилетать часто, и строить картинку заново на каждый чих не оплатит ни один пользователь. И шестая: не храни в виджете ссылок на чужие виджеты — общение только через колбэки, иначе свяжешь компоненты узлами, которые потом не развязать.

### Чек-лист перед публикацией виджета

- конструктор создаёт структуру и зовёт ExtendBaseWidget с ней же
- CreateRenderer создаёт canvas-объекты один раз и возвращает рендерер
- MinSize считается от детей, а не жёсткими числами
- Refresh обновляет существующие объекты и ничего не создаёт
- колбэки наружу — полями вида OnTapped, как у штатных виджетов тулкита

### Собери конструктор Badge

Восстанови четыре пропуска: встраивание каркаса, самоссылка, готовый рендерер и команда перерисовки.

```go
type Badge struct {
        ___
        Text string
}

func NewBadge() *Badge {
        b := &Badge{}
        b.___(b)
        return b
}

func (b *Badge) CreateRenderer() fyne.WidgetRenderer {
        label := canvas.NewText(b.Text, theme.ForegroundColor())
        return widget.___(label)
}

func (b *Badge) SetText(s string) {
        b.Text = s
        b.___() // просим перерисовку
}
```

Подсказка: Каркас встраивается первой строкой структуры, конструктор отдаёт структуру саму себе, простому виджету хватит NewSimpleRenderer, а после смены данных нужен Refresh.

Правильные ответы в пропусках: `widget.BaseWidget`, `ExtendBaseWidget`, `NewSimpleRenderer`, `Refresh`

### Путь кастомного виджета на экран

Расставь по порядку шаги от объявления структуры до перерисовки.

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

1. `структура встраивает widget.BaseWidget и хранит данные виджета`
2. `конструктор создаёт структуру и зовёт ExtendBaseWidget`
3. `тулкит спрашивает у виджета CreateRenderer и получает рендерер`
4. `painter берёт Objects() рендерера и рисует их на слое виджета`
5. `компоновка вызывает Layout с новым размером, MinSize ограничивает сжатие`
6. `код меняет данные и зовёт Refresh — рендерер обновляет свои объекты`

> **Честно о запуске кода.** Кастомные виджеты из этого урока не показать в браузерной песочнице курса: им нужен настоящий экран и графический рендер. Собери примеры локально по второму уроку — меняй MinSize, цвета и Layout прямо в коде и смотри, как реагирует компоновка после каждой правки.

### Что дальше: свои контролы в большом приложении

У тебя в руках полный цикл: примитивы седьмого урока — материал, события шестого — поведение, а контракт рендерера — упаковка. Прежде чем писать своё, перечитай [каталог базовых виджетов](/courses/fyne/lessons/03-vidzhety/) — половина «кастомных» потребностей закрывается комбинацией контейнеров из [четвёртого урока](/courses/fyne/lessons/04-box/) и сеток [пятого](/courses/fyne/lessons/05-grid-form/); своё рисование оправдано, когда готовых деталей и правда нет. Ввод с клавиатуры и фокус из [шестого урока](/courses/fyne/lessons/06-sobytiya/) превращают рисунок в настоящий контрол, а два следующих урока кормят контролы содержанием: тонкости ввода текста — в [девятом](/courses/fyne/lessons/09-text-entry/), длинные перечни с виртуализацией — в [десятом](/courses/fyne/lessons/10-spiski/). Полный маршрут из двадцати пяти уроков — [на странице курса Fyne](/courses/fyne/). Домашнее задание соберёт кольцевой индикатор — и это будет твой первый контрол, который нельзя склеить из готовых виджетов.

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

- **widget.BaseWidget** — Каркас кастомного виджета: даёт Resize, Move, MinSize, Show, Hide и Refresh. Встраивается первой строкой структуры; ExtendBaseWidget(s) в конструкторе связывает каркас с твоей структурой.
- **fyne.WidgetRenderer** — Контракт рисования: Objects() — стабильный список детей, Layout(size) — раскладка по размеру, MinSize() — минимальные требования, Refresh() — перерисовка, Destroy() — освобождение ресурсов.
- **widget.NewSimpleRenderer** — Готовый рендерер из одного объекта: Layout растягивает его на весь виджет, MinSize берётся у объекта. Достаточно для простых обёрток над примитивом или контейнером.
- **Правило Refresh** — Меняешь данные виджета — зови Refresh(): запрос проходит в рендерер, а тот приводит свои canvas-объекты к данным. Ноль Refresh — ноль изменений на экране.
- **MinSize рендерера** — Твоё слово в споре с компоновкой: VBox строит строку по этому значению. Ноль — виджет сожмётся невидимо; честный минимум считается от детей через их MinSize.
- **Tapped и TappedSecondary** — Реализуешь методы на структуре — виджет становится fyne.Tappable и fyne.SecondaryTappable: левый и правый клик приходят с fyne.PointEvent. Наружу отдавай поля-колбэки вида OnTapped.

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

- Кастомный виджет — структура со встроенным widget.BaseWidget: каркас даёт Resize, Move, MinSize, Show, Hide и Refresh из коробки.
- Конструктор обязан звать ExtendBaseWidget с самой структурой: без самоссылки переопределённые методы и события не работают.
- fyne.WidgetRenderer — пять методов: Objects, Layout, MinSize, Refresh, Destroy; объекты создаются один раз в CreateRenderer.
- widget.NewSimpleRenderer закрывает простые случаи: один объект, растянутый на весь виджет, с его MinSize.
- MinSize рендерера — минимум виджета для компоновки: ноль схлопывает контрол, честное значение считается от детей.
- Поведение наращивается интерфейсами шестого урока: Tapped, TappedSecondary, Focusable, Draggable; наружу — колбэки-поля.
- Гигиена: не создавать объекты в Refresh, считать MinSize от детей, кешировать тяжёлое, обновлять цвета темы руками.

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

Собери кольцевой индикатор уровня заряда: кастомный виджет Ring с полем Value от 0 до 1, рендерер — тёмный canvas.Circle-фон, дуга из двенадцати коротких canvas.Line, координаты концов посчитай через math.Sin и math.Cos, по центру — canvas.Text с процентами. Реализуй полный контракт рендерера: Layout масштабирует дугу под переданный размер, MinSize возвращает разумный минимум от подписи, Refresh пересчитывает длину заполненной дуги и перекрашивает сегменты — заполненные цветом темы Primary, пустые серым. Добавь метод SetValue с зажимом диапазона и кнопки «Плюс 10» и «Минус 10» через слайдер из примера. Затем сделай виджет кликабельным: Tapped ставит значение по позиции клика из fyne.PointEvent, TappedSecondary сбрасывает в ноль. В конце оберни Ring в GridWrap и в Border и проверь, что Layout честно отрабатывает оба случая, а MinSize не даёт кольцу схлопнуться.

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

### Виджет не реагирует на клики — что проверить сначала?

Первым делом самоссылку: ExtendBaseWidget(s) в конструкторе. Без неё события не маршрутизируются в твою структуру. Дальше — что метод Tapped реализован на самой структуре виджета, а не на рендерере, и что виджет не заблокирован Disable.

### Можно ли унаследоваться от готового виджета вместо BaseWidget?

Да: встраивание widget.Button или widget.Entry даёт весь их рендер и поведение, а ты переопределяешь только нужное — например, перехватываешь Tapped. Это другой стиль расширения: не «рисую с нуля», а «дорисовываю поверх»; выбирай по объёму кастомизации.

### Почему после смены темы мои цвета остались старыми?

Ты запомнил значение цвета на момент создания — примитив хранит цвет, а не подписку на тему. Бери цвета в Refresh через theme-пакет, как делает gaugeRenderer, тогда каждая перерисовка возьмёт актуальную палитру; системная тематизация всего приложения — пятнадцатый урок.
