Императивный 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

Опишем возможные в программе события:

  1. Запрошен выход.
  2. Поступило число.
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
       }

Вот, собственно, и всё.