Знакомство с Threepenny-GUI
date = fromGregorian 2015 jan 22
category = "GUI"
tags = ["GUI", "FRP"]
Что же это?
Реализация графического интерфейса для приложений, это, к сожалению, пока ещё непростая задача (по крайней мере, для разработчиков на Haskell). Ещё больше усложняет задачу необходимость сделать приложение кроссплатформенным. Создатель Threepenny-GUI предлагает одно из решений данной проблемы - использование в качестве средства отображения интерфейса браузер. Такое решение даёт очевидные преимущества: используя браузер, мы получаем отличную кросплатформенность и богатые возможности для оформления интерфейса. А кроме того Threepenny-GUI даёт нам возможность использовать при разработке ещё и FRP - Function Reactive Programming - очень мощный подход для разработки ПО, особенно - интерактивного. FRP в данном случае основывается на библиотеке Reactive Banana.
Замечу, что не стоит делать поспешных выводов и решать для себя, что разработка приложений, рассчитанных на отображение в браузере, подразумевает под собой создание странички на HTML+JS с прикручиванием оной к простенькому серверу. К счастью, в данном случае это не так!
Данная статья, надеюсь, даст представление о возможности быстрой разработки GUI на Haskell, достаточное для того, чтобы иметь данный подход ввиду, буде такая задача возникнет.
Примером нам будет служить приложение, имитирующее настольный калькулятор с базовым набором операций. Его мы и разработаем. Итак, поехали!
Окно
А мы увидели Солнце, открыли окна. А иначе зачем это все? (© “Пикник”)
Каждое (ну, почти каждое) графическое приложение начинается с окна. Threepenny
создаёт окно сама (помним, что это страница в браузере), от нас же ожидается
его настройка и наполнение. Поручим это функции setup
, которая будет
вызываться из main
:
import Control.Monad (void)
import qualified Graphics.UI.Threepenny as UI
import Graphics.UI.Threepenny (set, title, (#), UI, Window)
import Graphics.UI.Threepenny.Core (startGUI, defaultConfig)
-- ...
setup :: Window -> UI ()
= void $ do
setup win return win # set title "ReaCalc"
main :: IO ()
= startGUI defaultConfig setup main
Из кода видно, что работа с окном происходит в специальной монаде UI
:
cамо окно (как и все остальные UI-элементы) - “чистое”, а операции над ним
- “не очень”, поэтому типичное взаимодействие с элементом выглядит так:
return elem # set ... ...
.
В общем то, всё! Приложение уже можно запускать! У меня это выглядит так:
$ cabal run
Listening on http://127.0.0.1:8023/
[22/Jan/2015:20:23:04 +0300] Server.httpServe: START, binding to [http://127.0.0.1:8023/]
Открыв в браузере url http://127.0.0.1:8023/
, мы увидим страницу с
указанным нами title. Да, страница пока пуста, но приложение уже работает!
Настало время наполнить окно содержимым. Создадим “индикатор” - элемент,
отображающий результаты вычислений. Под индикатором будет располагаться блок клавиш.
Дополним setup
:
import Graphics.UI.Threepenny (set, attr, text, value, (#+))
-- ...
= -- ...
setup win --- ...
<- UI.input # set (attr "readonly") "true"
out # set (attr "style") "text-align: right; min-width: 240px"
# set value "0"
<- mapM (mapM mkButton) buttonLabels
buttons
#+ [return out]
UI.getBody win #+ map UI.row (map (map return) buttons)
where
= UI.input # set text s
mkButton s # set value s
# set (attr "type") "button"
# set (attr "style") "min-width: 50px"
buttonLabels :: [[String]]
= map words $ lines "7 8 9 CE C\n4 5 6 + -\n1 2 3 * /\n . 0 =" buttonLabels
Элементы управления настраиваются так же, как это делалось бы в HTML - мы добавляем стили, указываем атрибуты, и т.п. Но всё оперирование компонентами производится на Haskell, ну не красота ли!? (на самом деле, красоты в текущем интерфейсе мало - на вид он, что называется, “инженерный”. Ну да ладно, эта статья не о дизайне, всё таки!)
Теперь интерфейс в наличии, настало время его “оживить”!
Логика
I’m the operator with my pocket calculator
I am adding, and subtracting
I’m controlling, and composing (© Kraftwerk)
Логика работы калькулятора реализована в модуле Calc
. В статье потребуется
лишь API, предоставляемый модулем:
State
- внутреее состояние калькулятора. Начальное стостояние можно получить черезdef :: State
(соответствующий класс типов реализован);populate :: String -> State -> State
- модификатор состояния, изменяющий оное в соответствии со строковой командой;display :: State -> String
- функция, педоставляющая отображение состояния в привычном виде (текущее значение на “экране” “калькулятора”).
Пройдя по ссылке на исходники в конце статьи, можно познакомиться с содержимым модуля.
Интерактив
By pressing down a special key,
it plays a little melody (© Kraftwerk)
В Reactive Banana интерактивность описывется в терминах Event
и Behaviour
.
Event a
представляет собой, не одиночное событие, как это бывает в классических
event-driven системах, а поток событий, выдаваемых потоком по мере того, как оные
происходят. Каждое событие в потоке сопровождается временной отметкой и содержит
некие данные (типа a
), характеризующие событие. Два потока событий одного типа
можно объединить в один с помощью комбинатора
:: (a -> a -> a) -> Event a -> Event a -> Event a UI.unionWith
Этот комбинатор, помимо, собственно, объединения потоков, занимается обработкой ситуации, когда два события из разных потоков имеют одинаковую временную отметку: в этом случае вызывается функция, передаваемая в комбинатор первым параметром - она то и решает, что делать с данными.
Behaviour a
, в свою очередь, изображает некую “ячейку”, в которой содержатся
данные типа a
. Данные в коробке всегда имеют некое мгновенное значение,
меняющееся с течением времени под действием событий.
Наша программа изображает некое устройство (калькулятор), имеющее начальное состояние, возникающее единожды при “включении”. В процессе работы это состояние меняется под действием команд (нажатий кнопок). Индикатор же калькулятора занимается отображением значения, характеризующего это сотояние. При этом, состояние существует в течении всего времени работы кальклятора.
Тут нам пригодится функция
:: MonadIO m => a -> Event (a -> a) -> m (Behavior a) UI.accumB
Она принимает начальное значение и поток событий, содержащих функции, способные изменить это значение, а возвращает - “поведение”, т.е. ячейку в которой и будет храниться изменяющееся во времени состояние.
Дополним функцию setup
кодом, отвечающим да интерактив:
import Graphics.UI.Threepenny ((<@), Event, sink, accumB)
-- ...
= -- ...
setup win -- ...
let clicks = buttonClicks (zip (concat buttons) (concat buttonLabels))
= fmap populate clicks
commands <- accumB (def :: State) commands
calcBehaviour return out # sink value (fmap display calcBehaviour)
-- ...
where
buttonClicks :: [(Element, String)] -> Event String
= foldr1 (UI.unionWith const) . map makeClick
buttonClicks where
= (UI.pure s) <@ (UI.click e) makeClick (e, s)
Некоторого пояснения требует код функции makeClick
, но на самом деле тут
всё просто: клики по кнопке (e
) попадают в поток, но значения событий
отбрасываются (комбинатором <@
), а вместо них вставляется текст кнопки
(pure s
).
А sink
в коде означает “сток” - связывание ячейки со свойством элемента
(тут это value
). После связывания изменяющееся значение будет автоматически
отражаться на “индикаторе”.
К вышеперечисленному хочется добавить, что Event
и Behaviour
реализуют
класс Functor
. Это позволяет обрабатывать потоки событий очень “функционально”!
Заключение
Приложение реализовано! Кода получилось немного, но даже этот небольшой пример в достаточной мере демонстрирует, что с помощью FRP можно писать GUI в функциональном стиле, не скатываясь в “callback hell”. Даже внутреннее состояние реализуется явно, что должно радовать любого функциональщика (надеюсь).
Исходники проекта можно посмотреть тут