Знакомство с Threepenny-GUI

Что же это?

Реализация графического интерфейса для приложений, это, к сожалению, пока ещё непростая задача (по крайней мере, для разработчиков на 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 ()
setup win = void $ do
  return win # set title "ReaCalc"

main :: IO ()
main = startGUI defaultConfig setup

Из кода видно, что работа с окном происходит в специальной монаде 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 = -- ...
  --- ...
  out <- UI.input # set (attr "readonly") "true"
                  # set (attr "style") "text-align: right; min-width: 240px"
                  # set value "0"

  buttons <- mapM (mapM mkButton) buttonLabels

  UI.getBody win #+ [return out]
                 #+ map UI.row (map (map return) buttons)

  where
    mkButton s = UI.input # set text s
                          # set value s
                          # set (attr "type") "button"
                          # set (attr "style") "min-width: 50px"

buttonLabels :: [[String]]
buttonLabels = map words $ lines "7 8 9 CE C\n4 5 6 + -\n1 2 3 * /\n . 0 ="

Элементы управления настраиваются так же, как это делалось бы в 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), характеризующие событие. Два потока событий одного типа можно объединить в один с помощью комбинатора

UI.unionWith :: (a -> a -> a) -> Event a -> Event a -> Event a

Этот комбинатор, помимо, собственно, объединения потоков, занимается обработкой ситуации, когда два события из разных потоков имеют одинаковую временную отметку: в этом случае вызывается функция, передаваемая в комбинатор первым параметром - она то и решает, что делать с данными.

Behaviour a, в свою очередь, изображает некую “ячейку”, в которой содержатся данные типа a. Данные в коробке всегда имеют некое мгновенное значение, меняющееся с течением времени под действием событий.

Наша программа изображает некое устройство (калькулятор), имеющее начальное состояние, возникающее единожды при “включении”. В процессе работы это состояние меняется под действием команд (нажатий кнопок). Индикатор же калькулятора занимается отображением значения, характеризующего это сотояние. При этом, состояние существует в течении всего времени работы кальклятора.

Тут нам пригодится функция

UI.accumB :: MonadIO m => a -> Event (a -> a) -> m (Behavior a)

Она принимает начальное значение и поток событий, содержащих функции, способные изменить это значение, а возвращает - “поведение”, т.е. ячейку в которой и будет храниться изменяющееся во времени состояние.

Дополним функцию setup кодом, отвечающим да интерактив:

import           Graphics.UI.Threepenny      ((<@), Event, sink, accumB)
-- ...
setup win = -- ...
  -- ...
  let clicks = buttonClicks (zip (concat buttons) (concat buttonLabels))
      commands = fmap populate clicks
  calcBehaviour <- accumB (def :: State) commands
  return out # sink value (fmap display calcBehaviour)
  -- ...
  where
    buttonClicks :: [(Element, String)] -> Event String
    buttonClicks = foldr1 (UI.unionWith const) . map makeClick
      where
        makeClick (e, s) = (UI.pure s) <@ (UI.click e)

Некоторого пояснения требует код функции makeClick, но на самом деле тут всё просто: клики по кнопке (e) попадают в поток, но значения событий отбрасываются (комбинатором <@), а вместо них вставляется текст кнопки (pure s).

А sink в коде означает “сток” - связывание ячейки со свойством элемента (тут это value). После связывания изменяющееся значение будет автоматически отражаться на “индикаторе”.

К вышеперечисленному хочется добавить, что Event и Behaviour реализуют класс Functor. Это позволяет обрабатывать потоки событий очень “функционально”!

Заключение

Приложение реализовано! Кода получилось немного, но даже этот небольшой пример в достаточной мере демонстрирует, что с помощью FRP можно писать GUI в функциональном стиле, не скатываясь в “callback hell”. Даже внутреннее состояние реализуется явно, что должно радовать любого функциональщика (надеюсь).

Исходники проекта можно посмотреть тут