Содержание урока · 11 разделов
- Один тулкит, разные столы
- Геометрия: размер, центр, полноэкранный режим
- Системный трей
- Размеры экрана и HiDPI
- Буфер обмена, drag-and-drop и нативные меню
- Каталог десктопных API
- Практика: окно, которое ведёт себя по-взрослому
- Что дальше: тесты без монитора
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Один тулкит, разные столы#
Fyne пишет один код для пяти платформ, но десктоп — старший брат: у него есть окна с рамками, системный трей, буфер обмена, перетаскивание файлов и меню в строке ОС. Всё, что существует только на настольных системах, Fyne выносит в отдельный пакет fyne.io/fyne/v2/driver/desktop, а точка входа в эти возможности — приведение типа приложения: myApp.(desktop.App) даёт расширенный интерфейс поверх обычного fyne.App. Приведение честное: на мобильных платформах из двадцать второго урока проверка ok вернёт false, и ветка просто не выполнится — это штатный способ писать код, который включается только там, где есть смысл. Запрос «fyne десктоп» почти всегда приводит к этому пакету. Вторая особенность — окна: на мобильном приложение живёт в одном экране, заданном системой, а на столе ты сам создаёшь окна через NewWindow, задаёшь размеры и решаешь, какое из них главное. Третья — рендер и масштаб: на десктопе встречаются экраны с разной плотностью пикселей, и Fyne прячет разницу за логическими координатами, о которых ниже отдельный раздел. Наконец, поведение: десктопный пользователь ожидает, что крестик спросит про несохранённые данные, что свернутое приложение найдётся в трее и что файл можно бросить прямо в окно. Всё это — обычные вызовы API, без единой платформенной ветки по операционной системе в твоём коде: различия Fyne берёт на себя. Сначала разберём самый драматичный момент жизни окна — его закрытие.
Окно, которое спрашивает перед выходом
GOpackage main
import (
"fmt"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
)
func main() {
myApp := app.NewWithID("io.gomaster.desk")
window := myApp.NewWindow("Редактор")
window.SetCloseIntercept(func() {
dialog.ShowConfirm("Выйти?", "Несохранённые изменения будут потеряны.", func(ok bool) {
if ok {
window.Close()
}
}, window)
})
window.SetOnClosed(func() {
fmt.Println("окно закрыто — черновик сохранён")
})
window.SetContent(widget.NewLabel("Текст..."))
window.ShowAndRun()
}SetCloseIntercept перехватывает запрос на закрытие: вместо немедленного выхода показывается диалог из тринадцатого урока, и окно закрывается только явным window.Close() внутри обработчика. SetOnClosed срабатывает уже после фактического закрытия — там место финальному сохранению.
Окно открыто с SetCloseIntercept. Что нужно, чтобы пользователь всё-таки смог выйти?
Геометрия: размер, центр, полноэкранный режим#
Окно создаётся вызовом myApp.NewWindow(title) — первое созданное окно становится мастер-окном: его закрытие завершает приложение, если не сказано иное. Размер задаётся window.Resize(fyne.NewSize(w, h)) в логических пикселях, позиция — window.CenterOnScreen(), который ставит окно в центр того монитора, на котором оно сейчас. Дальше три переключателя поведения: SetFixedSize(true) запрещает пользователю тянуть рамку — режим для палитр и маленьких утилит с жёсткой сеткой; SetFullScreen(true) разворачивает окно без рамок на весь экран — просмотрщики и киоски; SetPadded(false) убирает внутренний отступ между содержимым и краями окна, что полезно для графики из седьмого урока, прижатой к границам. Важно понимать, что Resize — это запрос: оконный менеджер вправе скорректировать размер по своим минимумам, поэтому не строй логику на точном равенстве, а фактический размер содержимого читай из window.Canvas().Size(). Ещё два полезных жеста: window.RequestFocus() поднимает окно и передаёт ему фокус — вызывай его, когда приложение нашло что-то по поиску или получило внешнее событие, но помни, что без причины крадущий фокус раздражает. И window.SetIcon(fyne.Resource) — иконка окна и панели задач, ресурсом из шестнадцатого урока. Все эти вызовы — hints, подсказки системе, а не приказы: десктоп уважает волю пользователя сильнее желания программы.
Главное окно и вспомогательная палитра
GOpackage main
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/widget"
)
func main() {
myApp := app.New()
main := myApp.NewWindow("Главное")
main.Resize(fyne.NewSize(600, 400))
main.CenterOnScreen()
palette := myApp.NewWindow("Палитра")
palette.Resize(fyne.NewSize(220, 300))
palette.SetFixedSize(true)
main.SetContent(widget.NewLabel("Основное окно"))
palette.SetContent(widget.NewLabel("Инструменты"))
palette.Show()
main.ShowAndRun()
}Два окна одного приложения: главное создаётся первым и потому мастер-окно — его закрытие завершит программу, даже если палитра ещё открыта. Палитре назначен фиксированный размер: тянуть её рамку бессмысленно. ShowAndRun из каркаса второго урока остаётся последней строкой.
Какое окно закрывает приложение
Прочитайте код и выберите, что он выведет:
main := myApp.NewWindow("Главное")
palette := myApp.NewWindow("Палитра")
main.Show()
palette.Show()
// пользователь сначала закрывает палитру,
// потом закрывает главное — что происходит?Системный трей#
Трей — маленькая иконка в системной панели рядом с часами, по клику показывающая меню. Для свернутых в фон утилит это второй дом: окно можно закрыть, а приложение продолжит жить и примет команду из трея. В Fyne трей включается через расширенный интерфейс приложения: приведение myApp.(desktop.App) даёт два метода — SetSystemTrayMenu(menu *fyne.Menu) и SetSystemTrayIcon(icon fyne.Resource). Меню строится знакомым по четырнадцатому уроку конструктором: fyne.NewMenu(«имя», элементы...) и fyne.NewMenuItem(«подпись», функция). Типичный состав — «Показать окно» с window.Show() и RequestFocus, переключатель действия и «Выход» с myApp.Quit(). Есть тонкость жизненного цикла: если окно закрыто, но назначено меню трея, приложение не завершится при закрытии последнего окна — выйти из него можно через Quit из меню трея; без трея закрытие единственного окна означает конец. Иконка трея назначается ресурсом из шестнадцатого урока: если её не ставить, платформа покажет значок приложения по умолчанию. Два правила гигиены: держи в трей-меню три-пять пунктов, не превращай его в второе главное меню, и всегда оставляй путь к выходу — пользователь должен иметь возможность завершить процесс, не открывая диспетчер задач. Для случаев, когда трей нужен глубже — всплывающие уведомления из иконки, динамическая иконка-индикатор, — есть отдельный пакет github.com/fyne-io/systray, но в девяти случаях из десяти хватает встроенных двух методов.
Приложение, живущее в трее
GOpackage main
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/widget"
)
func main() {
myApp := app.NewWithID("io.gomaster.tray")
window := myApp.NewWindow("Агент")
if d, ok := myApp.(desktop.App); ok {
menu := fyne.NewMenu("Агент",
fyne.NewMenuItem("Показать окно", func() {
window.Show()
window.RequestFocus()
}),
fyne.NewMenuItem("Выход", func() {
myApp.Quit()
}),
)
d.SetSystemTrayMenu(menu)
}
window.SetContent(widget.NewLabel("Закрой окно — агент останется в трее"))
window.ShowAndRun()
}Приведение к desktop.App с проверкой ok безопасно на любой платформе: где трея нет, ветка пропустится. Пункт «Показать окно» поднимает закрытое окно и забирает фокус, «Выход» завершает процесс. После назначения меню закрытие окна приложение уже не убьёт.
Как правильно получить интерфейс с SetSystemTrayMenu?
Размеры экрана и HiDPI#
На десктопе экраны бывают 96 точек на дюйм, а бывают и 200 с лишним, и Fyne прячет эту разницу за двумя системами координат. Логические пиксели — то, чем ты оперируешь везде: в Resize, в NewSize, в координатах canvas из седьмого урока. Физические пиксели — то, что реально светится на матрице. Связывает их коэффициент window.Canvas().Scale(): при Scale 2.0 логическое окно 300 на 200 занимает 600 на 400 физических точек. Шрифты и виджеты, построенные на темах из пятнадцатого урока, масштабируются сами — тебе же коэффициент нужен, когда дело доходит до растровых картинок: изображение, нарисованное в один логический пиксель, на плотном экране замылится, так что бери исходники в двойном размере и храни в ресурсах. Практические правила: не хардкодь предположений о размере монитора — окно 1200 на 800 не влезет на маленький ноутбук, поэтому стартуй со скромного Resize и дай пользователю растянуть; фактический размер контента читай из Canvas().Size(), а не из того, что просил; геометрию при выходе запоминай в preferences из девятнадцатого урока и восстанавливай при старте — это один из самых ценимых мелких сервисов. Позицию и размер каждого монитора сам тулкит не раскрывает: если нужны перечисления дисплеев, это территория внешних пакетов, но для 95 процентов приложений достаточно центрирования и сохранённой геометрии. Помни и про минимальные размеры: слишком узкое окно ломает раскладки из четвёртого урока — задай нижнюю границу в SetFixedSize либо проверяй при Resize.
Сколько физических пикселей
Прочитайте код и выберите, что он выведет:
window.Resize(fyne.NewSize(300, 200))
fmt.Println(window.Canvas().Size()) // 300 x 200 (логические)
fmt.Println(window.Canvas().Scale()) // 2.0
// сколько физических пикселей занимает окно?Буфер обмена, drag-and-drop и нативные меню#
Три механизма делают приложение своим для операционной системы. Буфер обмена: с версии 2.6 он живёт на приложении — myApp.Clipboard() возвращает объект с Content() для чтения и SetContent(string) для записи; одноимённый метод окна объявлен устаревшим, так что новый код пиши через приложение. Вставка картинки в редактор, копирование ссылки, обмен задачами между окнами — всё это одна строка, без внешних пакетов. Drag-and-drop: window.SetOnDropped(func(pos fyne.Position, uris []fyne.URI)) — пользователь бросает файлы из проводника прямо в окно, колбэк получает позицию падения и срез URI; имя файла достаётся uris[i].Name(), а читается содержимое хранилищем из двадцать первого урока. Это самый короткий путь «открыть документ»: поддержка перетаскивания экономит пользователю обход диалога выбора. Нативные меню: SetMainMenu из четырнадцатого урока на разных столах рендерится по-разному — на Windows и Linux это строка внутри окна, на macOS пункты уходят в системную строку сверху экрана, как у родных приложений; твой код при этом не меняется ни на символ. Отдельная десктопная вежливость — горячие клавиши: пользователь стола ждёт Ctrl+C и Ctrl+V везде, где есть текст, и Ctrl+S в редакторе; базовые привязки Fyne делает сам, а свои добавляются через AddShortcut из шестого урока с desktop.CustomShortcut. Собирая всё вместе, ты получаешь приложение, которое ОС считает своим: оно в трее, в буфере обмена, в строке меню и в системе перетаскивания.
Приём файлов перетаскиванием
GOpackage main
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/widget"
)
func main() {
myApp := app.New()
window := myApp.NewWindow("Приём файлов")
label := widget.NewLabel("Перетащи файл сюда")
window.SetOnDropped(func(pos fyne.Position, uris []fyne.URI) {
if len(uris) > 0 {
label.SetText("Получен: " + uris[0].Name())
}
})
window.SetContent(label)
window.Resize(fyne.NewSize(400, 160))
window.ShowAndRun()
}Один колбэк — и окно принимает файлы из проводника: приходят позиция падения и срез URI. Имя достаётся методом Name() URI, а содержимое потом читается через хранилище файлов из двадцать первого урока. Никаких платформенных веток — механику перетаскивания ведёт тулкит.
Где с версии 2.6 правильно читать и писать системный буфер обмена?
Каталог десктопных API#
| API | Что делает | Когда звать |
|---|---|---|
| myApp.NewWindow(title) | создать окно приложения | каждое окно; первое становится мастер-окном |
| window.SetMaster() | закрытие окна завершает приложение | когда главное окно создано не первым |
| window.SetCloseIntercept(fn) | перехватить запрос на закрытие | подтверждение выхода, сохранение черновика |
| window.SetOnClosed(fn) | действие после фактического закрытия | финальное сохранение, логирование |
| window.Resize(fyne.NewSize(w, h)) | запросить размер в логических пикселях | стартовая и восстановленная геометрия |
| window.CenterOnScreen() | центр текущего монитора | старт приложения, окна-инструменты |
| window.SetFixedSize(true) | запрет изменения размера | палитры, утилиты с фиксированной сеткой |
| window.SetFullScreen(true) | развернуть без рамок | просмотрщики, киоски, слайды |
| window.SetPadded(false) | убрать внутренний отступ окна | графика и полноэкранный режим |
| window.RequestFocus() | поднять окно и отдать фокус | результат поиска, внешнее событие |
| myApp.(desktop.App).SetSystemTrayMenu(menu) | меню системного трея | приложение, живущее свернутым |
| myApp.(desktop.App).SetSystemTrayIcon(res) | иконка трея | узнаваемость в системной панели |
| myApp.Driver().AllWindows() | список всех окон приложения | циклы «закрыть все», проверки состояния |
| window.SetOnDropped(fn) | приём перетащенных файлов | открытие документов броском |
| myApp.Clipboard() | буфер обмена: Content и SetContent | копирование и вставка по своей логике |
Собери окно с памятью и вежливым выходом
Восстанови пять пропусков: чтение геометрии с запасными значениями, применение размера, центрирование и честное закрытие из перехватчика.
Путь нажатия на крестик
Расставь по порядку события от клика по крестику до завершения приложения.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Десктопным примерам нужен настоящий оконный менеджер — в браузерной песочнице курса GUI не исполняется. Собери примеры по каркасу второго урока на своей машине и обязательно закрой окно крестиком: перехватчик, диалог и сохранение геометрии проверяются только живой рукой пользователя.
Иконка трея рисуется в 16-24 логических пикселях и часто монохромна: детализированный логотип утонет, бери упрощённую версию из ресурсов. И помни про жизненный цикл: с назначенным трей-меню закрытие окна не завершает процесс — забыл пункт «Выход», и приложение останется висеть в панели вечно.
Практика: окно, которое ведёт себя по-взрослому#
Соберём чек-лист десктопного приличия из уже пройденного. Геометрия: при закрытии — в перехватчике или SetOnClosed — пишешь ширину, высоту и позицию в preferences девятнадцатого урока; при старте читаешь с WithFallback и применяешь до ShowAndRun, чтобы первый кадр уже был правильным, без прыжка «сначала центр, потом куда-то влево». Выход: если в данных есть несохранённое — флаг dirty, который ставят обработчики из шестого урока, — перехватчик показывает диалог из тринадцатого урока с тремя исходами: сохранить и выйти, выйти без сохранения, остаться. Трей: сворачиваемая в панель утилита получает меню из двух-трёх пунктов и иконку-упрощение; окно при этом можно закрыть совсем — агент живёт в трее. Файлы: SetOnDropped открывает документ броском, а буфер обмена через myApp.Clipboard() даёт обмен текстом с другими программами. Горячие клавиши: Ctrl+S на сохранение и Ctrl+Q на выход через AddShortcut — десктопный пользователь проверит их в первую минуту. Каждая строка этого чек-листа — два-пятнадцать строк кода, но именно они отличают инструмент, которым пользуются год, от поделки, которую закрывают в первый день. Прогони свой проект из двадцать пятого урока по списку — и он будет выглядеть родным на любом столе.
Что дальше: тесты без монитора#
Теперь приложение ведёт себя как нативное для стола: спрашивает перед выходом, помнит геометрию, живёт в трее, принимает файлы перетаскиванием и разговаривает с буфером обмена. Опора урока — второй урок с каркасом запуска, тринадцатый с диалогом подтверждения, четырнадцатый с меню и тулбаром, девятнадцатый с хранением геометрии и шестнадцатый с иконками. Следующая тема — тестирование: как проверить, что кнопка действительно меняет состояние и что виджет рендерится так, как задуман, не открывая ни одного окна, и почему после этого урока регрессии в интерфейсе перестанут быть сюрпризом. Полный маршрут — на странице курса Fyne.
Термины урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный экран
HTML-модель виджет-дерева fyneОсобенности десктопа
Меню ОС, трей, размеры
fyne.App().SetMainMenu и SetIcon работают только тут
Экран собран: «Десктоп»
Коротко: что запомнить
- Десктопные возможности живут в пакете driver/desktop и включаются приведением myApp.(desktop.App) с проверкой ok — на мобильных ветка просто пропускается.
- SetCloseIntercept заменяет закрытие собой: подтверждай и вызывай window.Close() явно; SetOnClosed срабатывает уже после закрытия.
- Первое окно — мастер-окно: его закрытие завершает приложение; SetMaster переносит эту роль, а AllWindows() перечисляет окна.
- Resize, CenterOnScreen, SetFixedSize, SetFullScreen и SetPadded — подсказки оконному менеджеру; фактический размер читай из Canvas().Size().
- Трей собирается из fyne.NewMenu: Show с RequestFocus и Quit обязательны; с назначенным меню закрытие окна не гасит процесс.
- Canvas().Scale() связывает логические пиксели кода с физическими точками экрана — растровые ресурсы бери в двойном размере.
- Буфер обмена с 2.6 живёт на приложении, файлы принимаются SetOnDropped, а SetMainMenu на macOS уходит в системную строку меню.
Домашнее задание
Собери «вежливое» десктопное окно текстового редактора. Во-первых, геометрия: при закрытии сохрани ширину и высоту окна в preferences через SetFloat, при старте прочитай FloatWithFallback и примени Resize с CenterOnScreen до ShowAndRun — первый кадр должен открываться в сохранённом размере. Во-вторых, выход: поставь SetCloseIntercept с dialog.ShowConfirm из тринадцатого урока; держи флаг dirty, который ставит OnChanged поля ввода, и показывай вопрос о выходе только при несохранённых изменениях, а в SetOnClosed напиши в лог факт закрытия. В-третьих, трей: приведи приложение к desktop.App, назначь меню из пунктов «Показать окно» с Show и RequestFocus и «Выход» с myApp.Quit, и убедись, что после назначения меню закрытие окна оставляет процесс живым, а выход работает из трея. В-четвёртых, приём файлов: SetOnDropped выводит имя первого брошенного файла в метку. Проверь на своей машине три сценария: выход с несохранёнными данными спрашивает подтверждение, перезапуск восстанавливает размер окна, закрытие окна при активном трее не убивает приложение. Ловушка для самопроверки: попробуй вызвать window.Close() из SetOnClosed и подумай, почему это ошибка.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему приложение не закрывается после закрытия окна?
Если назначено меню системного трея через SetSystemTrayMenu, закрытие окна не завершает процесс — приложение живёт в трее по дизайну. Добавь в меню пункт «Выход» с myApp.Quit() или убери назначение меню, если трей не нужен.
Как узнать размер монитора пользователя?
Прямого API для перечисления мониторов у Fyne нет. Центрируй окно через CenterOnScreen, задавай скромный стартовый размер и полагайся на фактический размер содержимого из window.Canvas().Size() — оконный менеджер сам не даст окну выйти за пределы экрана.
Работает ли системный трей на мобильных платформах?
Нет: трей — возможность десктопа. Приведение myApp.(desktop.App) на мобильной сборке вернёт ok равным false, и ветка с SetSystemTrayMenu просто не выполнится — код остаётся единым без платформенных if-ов.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Cobra: CLI-приложения на Go
Интерактивный курс Cobra — фреймворка для CLI на Go: команды, флаги, конфиги Viper, автодополнение. 25 уроков с примерами кода, тестами и итоговым приложением.
- Создавать CLI-приложения с деревом команд на Cobra
- Работать с аргументами, флагами и их привязкой к конфигу
- Управлять конфигурацией через Viper: файлы, env, приоритеты
- Писать хуки PreRun/PostRun и корректно обрабатывать ошибки