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

Для 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
Этапы построения программы⌗
Мы можем разделить процесс создания программы на следующие этапы:
- создание UI в удобном редакторе Glade
- осмысление логики работы программы
- создание реактивной сети, реализующей данную логику
- запуск реактивной сети
Создание UI в Glade⌗
В нашей простой программке мы планируем рисовать на виджете Gtk.DrawingArea, который для этого должен будет занимать
всё окно. Вместе с тем, в самом низу окна мы разместим полоску Gtk.Label для вывода справочной информации (Gtk.Statusbar в моей версии Glade добавить не получается – данный виджет просто забыли внести в список!).

Горизонтальный Gtk.Box содержит эти два виджета, я дал им имена area и label соответственно.
Логика работы программы⌗
Идея в следующем:
- имеем и поддерживаем специальную поверхность
Cairo.Surface, которую рисуем на графический контекст нашего виджетаGtk.DrawingAreaвсякий раз, когда перерисовка нужна (фактический сигнал GTKdraw) - отлавливаем движения мышкой по
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 в нижней части окна).
