gi-gtk-declarative: Полностью декларативные GTK-программы на Haskell
Императивный GTK⌗
Программирование графических (GUI) приложений популярным императивным способом возможно и на Haskell: при таком подходе
мы обычно работаем в монаде IO
(или в параметризованном IO
трансформере, например StateT
) и логика программы
частенько полностью идентична императивным языкам. Например, программа ниже использует библиотеку GTK3 в императивном стиле (низкоуровневые биндинги к GTK3 пакетом gi-gtk
):
import qualified GI.Gtk as Gtk
import Data.GI.Base
main :: IO ()
main = do
Gtk.init Nothing
win <- new Gtk.Window [ #title := "Hi there" ]
on win #destroy Gtk.mainQuit
button <- new Gtk.Button [ #label := "Click me" ]
on button #clicked (set button [ #sensitive := False,
#label := "Thanks for clicking me" ])
#add win button
#showAll win
Gtk.main
Программирование GUI в подобном императивном стиле – скучное и неблагодарное дело, полное ошибок и уродливых идиом. Кроме того, работая с Haskell, мы привыкли брезгливо относится вообще к любому “нечистому” (т.е. изменяющему некое неявное “глобальное” состояние) коду и императивной/объектно-ориентированной парадигме в частности.
Для многих языков программирования существует целый ряд экспериментальных разработок и целых готовых библиотек для создания GUI-программ полностью функциональными, декларативными методами. Подобные методы в общем случае намного более предпочтительнее, поскольку позволяют:
- корректно полностью изолировать и осмыслить изменяемый стэйт программы (а не разбрасывать его по ООП-классам/методам и всему остальному коду программы)
- изолировать “чистую” логику от кода мутирования стэйта
- удобно тестировать и осмыслять (понимать) код программы “локально”
- избежать многих проблем с многопоточностью
- сильно улучшить модульность и композабельность всего кода
И это далеко не все преимущества.
Декларативный GTK⌗
Одной из таких декларативных GUI-библиотек для Haskell является пакет gi-gtk-declarative
, который
предоставляет высокоуровневые биндинги к подсистеме GTK3. Базовые идеи следующие:
- рендеринг GUI представляется чистыми функциями из стэйта программы в один и более декларативный виджет
- декларативные виджеты понимаются как репрезентация (настоящего) графического интерфейса пользователя
- основываясь на разнице между декларативными виджетами, библиотека использует специальный механизм патчей для вычисления обновлений, которые необходимо произвести в настоящих (грязных) GTK+ виджетах
- события (events) декларативны: пользователь указывает, какие события (в программе) генерируются какими сигналами от виджетов
Структурирование программы⌗
Библиотеку gi-gtk-declarative
удобно использовать вместе с пакетом gi-gtk-declarative-app-simple
, который упрощает
структурирование всей графической программы путём простенького фреймворка, немного похожего на MVC.
Программа описывается следующим типом App
:
data App window state event =
App
{ update :: state -> event -> Transition state event
, view :: state -> AppView window event
, inputs :: [Producer event IO ()]
, initialState :: state
}
Параметризация:
window
– тип toplevel-виджета GTK. Например,Gtk.Window
state
– внутренний стэйт нашей программы (который затем и будет мапиться в видимый GUI, т.е. вAppView window event
)event
– тип событий в нашей программе (события генерятся сигналами GTK-виджетов и могут вызывать транзицию (смену) стэйта).
Проекции App
:
update
– моделирует возможную мутацию (смену) стэйта или останов программы как зависимость от поступившего события и текущего стэйта.view
– рендеринг стэйта программы на экран. При этом асинхронно могут возникнуть события.inputs
– дополнительный (внешний) продюсер событий, который “впрыскивает” их в программу наряду с сигналами виджетов.initialState
– начальный стэйт нашей программы.
Для запуска собранного значения типа App
используется функция run
:
run
:: (Typeable event, Gtk.IsWindow window, Gtk.IsBin window)
=> App window state event
-> IO state
Простенькая программа⌗
Напишем простенькую программу, содержащую в окне кнопку и лэйбл. При нажатии на кнопку генерируется случайное число, которое затем устанавливается ткущим текстом лэйбла.
Удобные расширения языка и модули:
{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE OverloadedStrings #-}
module Main where
import qualified Data.Text as T
import Control.Monad
import qualified GI.Gtk as Gtk
import GI.Gtk.Declarative
import GI.Gtk.Declarative.App.Simple
import System.Random
Опишем внутреннее состояние нашей программы – просто число:
data State = State { unState :: Int }
instance Show State where
show = show . unState
Опишем возможные в программе события:
- Запрошен выход.
- Поступило число.
data Event = EventQuit | EventNumber Int
Автомат стэйта нашей программы. В момент транзиции есть возможность сгенерить новое событие, но нам это не нужно
(Nothing
). Переходим в конечное состояние (Exit
, т.е. завершение программы) по EventQuit
или в новое состояние с
установленным числом по EventNumber
:
update' :: State -> Event -> Transition State Event
update' _ EventQuit = Exit
update' s (EventNumber i) =
Transition s' (return Nothing)
where
s' = s { unState = i }
Рендеринг стэйта в декларативные виджеты. Дерево декларативных виджетов описывается от bin
(происходящий от одноимённого GTK-виджета – контейнера с одним ребёнком). Функции bin
, container
и widget
- важнейшие примитивы GI.Gtk.Declarative
(см. документацию), строящие собственно декларативные виджеты. Функции on
и onM
(из того-же пакета)
связывают чистым и грязным образом соответственно генерацию события нашей программы с возникающим сигналом GTK-виджета:
view' :: State -> AppView Gtk.Window Event
view' s =
bin
Gtk.Window
[ #title := "Hello"
, on #deleteEvent (\gev -> (True, EventQuit))
]
$ container
Gtk.Box
[ #orientation := Gtk.OrientationVertical ]
[
widget Gtk.Button
[ #label := "Generate number!"
, onM #clicked
(\b -> do
putStrLn "Click!"
r <- randomRIO (0, 999) :: IO Int
return (EventNumber r)
)
]
, widget Gtk.Label [#label := T.pack (show s)]
]
Соединяем всё воедино в тип App
. Внешних событий у нас нет, поэтому inputs
пустой.
main :: IO ()
main = void $ run
App
{ update = update'
, view = view'
, inputs = []
, initialState = State 0
}
Вот, собственно, и всё.