Функциональное реактивное программирование 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
в нижней части окна).