В данной заметке мы покажем, как функциональное реактивное программирование (FRP) позволяет нам полностью декларативно описать механику графической программы и чрезвычайно упростить её код. В качестве иллюстрации мы напишем незамысловатую программу-рисовалку, которая использует (обыкновенные) императивные биндинги к GTK и Cairo и пользовательский интерфейс которой создан в UI-редакторе Glade.

Screenshot

Для FRP мы будем использовать замечательный пакет reactive-banana. Мостик между GTK и reactive-banana заполнит библиотечка reactive-banana-gi-gtk.

Подготовка

Зависимости нашей программы:

  • text
  • mtl
  • reactive-banana
  • reactive-banana-gi-gtk
  • gi-gtk
  • gi-gdk
  • gi-gobject
  • gi-cairo
  • cairo

Этапы построения программы

Мы можем разделить процесс создания программы на следующие этапы:

  1. создание UI в удобном редакторе Glade
  2. осмысление логики работы программы
  3. создание реактивной сети, реализующей данную логику
  4. запуск реактивной сети

Создание UI в Glade

В нашей простой программке мы планируем рисовать на виджете Gtk.DrawingArea, который для этого должен будет занимать всё окно. Вместе с тем, в самом низу окна мы разместим полоску Gtk.Label для вывода справочной информации (Gtk.Statusbar в моей версии Glade добавить не получается – данный виджет просто забыли внести в список!).

Glade

Горизонтальный Gtk.Box содержит эти два виджета, я дал им имена area и label соответственно.

Логика работы программы

Идея в следующем:

  • имеем и поддерживаем специальную поверхность Cairo.Surface, которую рисуем на графический контекст нашего виджета Gtk.DrawingArea всякий раз, когда перерисовка нужна (фактический сигнал GTK draw)
  • отлавливаем движения мышкой по area: если при движении зажата левая кнопка мыши - рисуем на нашей Cairo.Surface в месте курсора чёрный квадрат, если правая - белый квадрат
  • если состоялось любое рисование, выводим координаты последнего квадрата (“мазка”) в label внизу окна

Дополнительно:

  • движения без зажатых кнопок мыши нам не интересны (подписываемся только на движения с зажатыми левой или правой кнопкой)
  • после каждого “мазка” запрашиваем у GTK перерисовку area (Gtk.widgetQueueDraw), чтобы обработчик draw сделал “блит” нашей виртуальной поверхности на физический контекст

Модуль Main

Преамбула

Расширения языка ниже нужны для удобства (текстовые имена GTK-виджетов и короткие названия GTK-сигналов и аттрибутов).

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE OverloadedLabels #-}
module Main where

import RenderWithCairo

import qualified GI.Gtk as Gtk
import qualified GI.Gdk as Gdk

import qualified Graphics.Rendering.Cairo as Cairo

import qualified Data.Text as T
import Reactive.Banana
import Reactive.Banana.GI.Gtk
import Reactive.Banana.Frameworks

Модуль RenderWithCairo содержит функцию рисования биндингами cairo поверх контекста из биндинга gi-cairo (данная библиотека является лишь автоматической прослойкой и не содержит функций для собственно рисования и манипуляции Cairo).

Описание реактивной сети

К описанию реативной сети в терминах reactive-banana фактически сводится вся наша программа, поэтому разберём данный этам подробнее.

Описание реактивной сети в reactive-banana сводится к созданию и соединению путём простых комбинаторов, а также интерфейсами Functor и Applicative дискретных событий (Event a) и поведенческих паттернов (Behavior a). Таким образом создаётся явная и понятная зависимость одних событий от других. Полезным результатом же работы сети являются окказиональные вызовы I/O-действий (IO ()) при наступлении избранных (сложных) событий Event a. Именно данные “выхлопы” (reactimate), фактически, и меняют видимое пользователю состояние всей программы, а значит в некотором роде являются целью всей сети (в нашем примере собственно сеть ничего не “возвращает”).

Описание сети происходит в монаде MomentIO, где мы смешиваем прочий I/O код (GTK, Cairo) с функциональными объявлениями реактивных событий и их зависимостями друг от друга (имена: xxxE - события, xxxB - паттерны). Библиотека reactive-banana-gi-gtk дополнительно предоставляет функции signalE0 и signalE1R для преобразования GTK-сигналов виджетов в банановые реактивные события, и, наоборот, функцию sink для преобразования реактивного паттерна (преобразования смены поведения) в обновления GTK-аттрибутов виджетов. Вспомогательная функция castB упрощает построение виджетов Gtk.Builderом.

Банановый комбинатор mapEventIO используется вместо fmap для “грязного” преобразования одного события в другое (для чтения аттрибутов GTK-виджетов и GDK-структур нужен I/O).

Код ниже теперь должен быть довольно понятным:

networkDescription :: MomentIO ()
networkDescription = do
  b <- Gtk.builderNew
  Gtk.builderAddFromFile b "scribble.glade"

  surf <- liftIO $ Cairo.createImageSurface
          Cairo.FormatRGB24 2000 2000
  Cairo.renderWith surf $ do
    Cairo.setSourceRGB 1 1 1
    Cairo.paint

  window <- castB b "window" Gtk.Window
  windowDestroyE <- signalE0 window #destroy
  reactimate $ Gtk.mainQuit <$ windowDestroyE

  area <- castB b "area" Gtk.DrawingArea
  areaRedrawE <- signalE1R area #draw True
  reactimate $ (\areaCtx -> do
                   renderWithCairoContext' areaCtx $ do
                     Cairo.setSourceSurface surf 0 0
                     Cairo.paint
               ) <$> areaRedrawE

  Gtk.widgetAddEvents area
    [ Gdk.EventMaskButton1MotionMask
    , Gdk.EventMaskButton3MotionMask
    , Gdk.EventMaskButtonPressMask]

  areaMotionE <- signalE1R area #motionNotifyEvent True
  motionCoordsStateE <-
    mapEventIO (\em -> do
                   x <- Gtk.get em #x
                   y <- Gtk.get em #y
                   s <- Gtk.get em #state
                   return (s, (x, y))
               ) areaMotionE
  let filterModifierE m = filterE (elem m . fst)

  let leftCoordsE =
        snd <$>
        filterModifierE Gdk.ModifierTypeButton1Mask motionCoordsStateE
  let rightCoordsE =
        snd <$>
        filterModifierE Gdk.ModifierTypeButton3Mask motionCoordsStateE

  let drawRedraw f xy = do
        Cairo.renderWith surf (f xy)
        Gtk.widgetQueueDraw area

  reactimate $ drawRedraw (drawAt (Left ())) <$> leftCoordsE
  reactimate $ drawRedraw (drawAt (Right ())) <$> rightCoordsE

  mouseCoordsB <- stepper (0, 0) $ snd <$> motionCoordsStateE
  label <- castB b "label" Gtk.Label
  sink label [#label :== (T.pack . show) <$> mouseCoordsB]


  Gtk.widgetShowAll window

Вспомогательные функции

Непосредственно “мазок кистью” заключается в рисовании чёрного или белого квадратика со стороной 5 на Cairo-поверхности.

drawAt :: Either () () -> (Double, Double) -> Cairo.Render ()
drawAt (Left ()) (x, y) = do
  Cairo.setSourceRGB 0 0 0
  Cairo.rectangle x y 5 5
  Cairo.fill
drawAt (Right ()) (x, y) = do
  Cairo.setSourceRGB 1 1 1
  Cairo.rectangle x y 5 5
  Cairo.fill

Запуск реактивной сети

Вот так происходит компиляция и запуск нашей сети (actuate не блокируется):

main :: IO ()
main = do
  Gtk.init Nothing

  net <- compile networkDescription
  actuate net

  Gtk.main

Модуль RenderWithCairo

Код ниже - известный хак для взаимодействия полных биндингов Cairo (cairo) и её GTK-интроспекции:

module RenderWithCairo where

import Control.Monad.Reader (runReaderT)
import qualified GI.Cairo as GI.Cairo
import qualified GI.GObject as GI
import Graphics.Rendering.Cairo.Internal
  (Render(runRender))
import Graphics.Rendering.Cairo.Types (Cairo(Cairo))
import Foreign.Ptr (castPtr)



renderWithCairoContext' :: GI.Cairo.Context
                       -> Render () -> IO ()
renderWithCairoContext' ct r = GI.withManagedPtr ct $ \p ->
  runReaderT (runRender r) (Cairo (castPtr p))


renderWithCairoContext ct r =
  renderWithCairoContext' ct r >> return True

Результат

Вот так выглядит результат (обратите внимание на Gtk.Label в нижней части окна).

Final Screenshot