Haskell уже имеет несколько достаточно полных средств для создания GUI. Но только некоторые из них являются кросплатформенными и часто требуют знания графической библиотеки, которая находится в основании. Особняком здесь стоит графический фреймворк Monomer. Ему присуща высокая степень абстракции над графикой и при этом, относительная легкость в использования (pure Haskell подход). Имеет хорошую документацию и примеры.
Здесь я постараюсь описать общие черты Monomer, используя некоторые переведенные фрагменты английской документации со своими добавлениями. В дальнейшем предполагается, что читатель имеет базовые знания языка Haskell. Об использовании линз смотрите раздел в самом конце.
В качестве примера используется код вьювера логов игры Dwarf Fortress. Поэтому эта статья, в определенном смысле, является продолжением статьи с анонсом. Скриншот основного окна приложения:
Вьювер логов считывает файл лога игры, парсит записи этого лога и присваивает определенный тег (тип) к каждой записи. В зависимости от тега записям присваивается цвет основного текста, а также окно приложения, в котором эта запись появится. Назначение распределения цветов и окон для тегов осуществляется в диалоговых окнах приложения. В этой статье мы коснемся только GUI аспекта этого приложения.
Во-первых, почему именно Monomer? Я стремился выбрать с одной стороны кросплатформенный фреймворк (игра работает и на Windows и на Linux), с другой стороны, хотелось бы, чтобы он был высокоуровневым. Последнее предполагает быстроту создания пользовательского интерфейса на основе системы готовых виджетов и высокой степени абстрагирования от низкоуровневых графических вещей, особенно, если они платформенно зависимы.
Здесь варианты которые я рассматривал:
Любое приложение Monomer имеет пять основных компонентов, предоставляемых функции startApp, которая запускает графический интерфейс.
Далее мы рассмотрим эти компоненты подробнее.
Модель представляет собой данные вашего приложения. Здесь вы можете хранить все, что составляет содержание предметной области вашего приложения. Когда приложение запускается, необходимо предоставить модель в ее начальном состоянии.
В частности, в начальном состоянии модели вьювера находится загруженная основная конфигурация приложения, словари распределения тегов по цветам и окнам, плюс другие данные необходимые для корректной работы приложения.
В дальнейшем, в течение работы приложения, содержание модели может меняться. Например, если пользователь изменил соответствие цветов, то меняется и модель. И, в дальнейшем, это изменение будет отображено в изменении цветов видимых записей лога. Изменением изображения при изменении модели занимается функция создания пользовательского интерфейса (buildUI).
Ниже приводится часть модели приложения с поясняющими комментариями.
Здесь указаны только основные данные модели. Остальные, не указанные здесь, играют вспомогательную роль.
Некоторые поля структуры назначаются только при загрузке программы (например, mainConfig, exePath или logFilePath), или могут меняться во время выполнения (например, curTime, logEntries или logWindowDistrib).
Инстанс тип-класса Eq здесь обязателен, так как старая и новая измененная модели могут сравниваются для принятия решения о слиянии (об этом ниже).
События представляют собой различные действия, на которые может реагировать процедура обработчик событий. Это алгебраический тип данных, значения которого могут принимать аргументы в зависимости от события. Так событию AppInit (инициализация графического интерфейса) не нужны аргументы, но событие AppAddRecord (добавление записи лога в хранилище) требует получения аргумента, а именно, записи лога.
В приложении довольно много событий, и здесь я приведу только некоторые из них, для иллюстрации.
Функция создания пользовательского интерфейса строит дерево виджетов. Всякий раз, когда модель изменяется, эта функция будет вызываться, и будет создаваться новая версия дерева виджетов. Затем эта новая версия дерева виджетов будет объединена с предыдущей версией дерева. Процесс такого объединения называется слиянием. Фреймворк предоставляет некоторый контроль над процессом слияния, а именно, можно задать условия, когда слияние не производится.
Код приложения включает в себя следующий фрагмент с объявлением функции создания пользовательского интерфейса:
Тип WidgetEnv (информация о среде, которую можно использовать при построении пользовательского интерфейса), как и тип WidgetNode (результат построения пользовательского интерфейса) включают модель AppModel и события AppEvent в качестве параметров.
Теперь рассмотрим параметры, которые получает функция:
Наконец, возвращается значение типа WidgetNode, которое может быть или отдельным виджетом или более сложным агрегатом виджетов.
Виджеты являются данными типа Widget, который включает процедуры для инициализации, слияния, удаления и отображения определенного типа виджета (например, checkbox или textField). Если нужен особый рендеринг, то можно создать свой собственный виджет делая его данными этого типа (и реализуя соответствующие процедуры).
WidgetNode содержит экземпляр виджета и всю информацию, относящуюся к его расположению, размеру, видимости, дочерним элементам и т. д. Когда упоминается «дерево виджетов», на самом деле это «дерево узлов». Обычно все функции, которые создают виджеты, возвращают данные типа WidgetNode, что позволяет объединять их в более крупные структуры.
Далее мы рассмотрим примеры некоторых основных виджетов.
Двумя виджетами для создания макета являются hstack и vstack. Они позволяют размещать виджеты рядом друг с другом в горизонтальном или вертикальном положении, стараясь удовлетворить требованиям размера каждого из них (h или v указывают на главную ось).
В коде приложения вы можете видеть как оба виджета используются для создания макета главного окна:
Этот виджет используется для отображения текста. А именно, он отображает данные типа Text.
Большинство виджетов поддерживают базовую версию, такую как label, и настраиваемую версию, которая обозначается символом _ в конце. В случае label_ есть следующие параметры конфигурации:
Например:
Виджет кнопки обеспечивает базовый способ взаимодействия для пользователей. Чтобы создать его, ему нужны заголовок и событие, которое определено в типе Events.
Кнопка поддерживает те же параметры конфигурации, что и label (multiline, ellipsis и т. д.), а также некоторые дополнительные параметры для других возможных событий, доступных с помощью button_:
Если указан только один аргумент как в примере, то он назначается как обработчик события onClick.
Управление событиями осуществляется особой процедурой
Снова у нас есть WidgetEnv и WidgetNode, но теперь у нас также есть EventResponse, который принимает те же два параметра (модель и событие).
Глядя на параметры, мы видим следующее:
При работе приложения эта процедура запускает обработчики ожидаемых событий (определенных в AppEvent) и возвращает список результатов в дальнейший процесс выполнения программы.
В коде приложения ряд обработчиков возвращает результат типа Model, который устанавливает новое состояние модели приложения. Если модель изменилась, это повлечет вызов функции создания пользовательского интерфейса. Во фрагменте ниже мы присваиваем специальному полю модели текст ошибки и возвращаем новую модель.
В обработчике события ниже мы получаем в качестве параметра новую запись лога, и затем изменяем модель (меняем режим слияния, изменяем время получения последней записи и добавляем новую запись в хранилище).
Также запускаем специальную процедуру для помещения фокуса приложения на эту последнюю запись.
Для диалоговых окон задания цветов для тегов и распределения тегов по окнам в приложении сделаны отдельные дочерние части программы со своими собственными функциями создания графического интерфейса и обработчиками событий. Эти части являются так называемыми композитами; основная часть программы также является «базовым» композитом.
Передача данных от основной программы к композитам осуществляется через модель. А обратная передача данных происходит через события основной программы.
Есть также вариант передачи данных к основной программе через ответ Report в обработчике событий самого композита.
Здесь речь идет о конфигурации графических компонент приложения, это не конфигурация самого приложения, которая является частью модели.
Здесь можно устанавливать несколько параметров, включая заголовок окна, тему, шрифты и событие, которое вызывается при запуске пользовательского интерфейса (функция startApp).
Хотя в этом приложении это не используется, Monomer поддерживает рисование OpenGL. Для информации об этом и многом другом обращайтесь к документации Monomer.
Линзы в Haskell это набор типов и функций, которые могут быть удобным средством получения и установки полей, а также средством обхода различных структур данных.
Monomer использует линзы в своем коде, а также предусмотрен специальный модуль (Monomer.Lens) для использования линз в программном интерфейсе. Код приложения также использует линзы: это автогенерируемые линзы для модели и некоторых других структур.
Среди операторов чаще всего используются следующие три:
Для присваивания полям типа Maybe a используется оператор ?~ как в одном из приведенных выше фрагментов кода. Это пример использования призм (Prizm), так как данные типа Maybe a могут быть представлены как призма (и в данном случае при автогенерации линз это так и делается).
Автогенерция линз происходит с использованием TemplateHaskel. В коде приложения все структуры с линзами имеют символ _ в начале названий полей (в примерах статьи этот символ опущен). В результате автогенерации линзы получают идентификаторы как у полей структуры, но без символа подчеркивания.
Здесь я постараюсь описать общие черты Monomer, используя некоторые переведенные фрагменты английской документации со своими добавлениями. В дальнейшем предполагается, что читатель имеет базовые знания языка Haskell. Об использовании линз смотрите раздел в самом конце.
В качестве примера используется код вьювера логов игры Dwarf Fortress. Поэтому эта статья, в определенном смысле, является продолжением статьи с анонсом. Скриншот основного окна приложения:
Вьювер логов считывает файл лога игры, парсит записи этого лога и присваивает определенный тег (тип) к каждой записи. В зависимости от тега записям присваивается цвет основного текста, а также окно приложения, в котором эта запись появится. Назначение распределения цветов и окон для тегов осуществляется в диалоговых окнах приложения. В этой статье мы коснемся только GUI аспекта этого приложения.
Во-первых, почему именно Monomer? Я стремился выбрать с одной стороны кросплатформенный фреймворк (игра работает и на Windows и на Linux), с другой стороны, хотелось бы, чтобы он был высокоуровневым. Последнее предполагает быстроту создания пользовательского интерфейса на основе системы готовых виджетов и высокой степени абстрагирования от низкоуровневых графических вещей, особенно, если они платформенно зависимы.
Здесь варианты которые я рассматривал:
- gtk2Hs: продвинутый и часто используемый фреймворк, Вот только имеет средний уровень абстрагирования.
- wxHaskell: тоже продвинутый и часто используемый фреймворк. Но, также, имеет средний уровень абстрагирования и с 2017 года развития не получал.
- Monomer: не столь продвинутый, как вышеуказанные, но активно разрабатываемый в настоящее время. Набор его стандартных виджетов уже довольно большой и оказался достаточным для создания приложения.
Базовые компоненты приложения.
Любое приложение Monomer имеет пять основных компонентов, предоставляемых функции startApp, которая запускает графический интерфейс.
- Модель: содержит данные, специфичные для приложения.
- События: генерируются из действий пользователя или асинхронных задач.
- Функция создания пользовательского интерфейса: создает пользовательский интерфейс с использованием текущей модели.
- Обработчик событий: реагирует на события и может обновлять модель, запускать асинхронные задачи и другие действия.
- Конфигурация: несколько параметров для указания размера окна, доступных шрифтов и темы.
Далее мы рассмотрим эти компоненты подробнее.
Модель (Model)
Модель представляет собой данные вашего приложения. Здесь вы можете хранить все, что составляет содержание предметной области вашего приложения. Когда приложение запускается, необходимо предоставить модель в ее начальном состоянии.
В частности, в начальном состоянии модели вьювера находится загруженная основная конфигурация приложения, словари распределения тегов по цветам и окнам, плюс другие данные необходимые для корректной работы приложения.
В дальнейшем, в течение работы приложения, содержание модели может меняться. Например, если пользователь изменил соответствие цветов, то меняется и модель. И, в дальнейшем, это изменение будет отображено в изменении цветов видимых записей лога. Изменением изображения при изменении модели занимается функция создания пользовательского интерфейса (buildUI).
Ниже приводится часть модели приложения с поясняющими комментариями.
data AppModel = AppModel
{ mainConfig :: MainConfig -- главная конфигурация приложения
, errorMsg :: Maybe Text -- сообщение об ошибке
, curTime :: UTCTime -- время получения последней записи лога
, lastId :: LEId -- идентификатор назначенный для последней записи лога
, logEntries :: LogEntriesDepository -- хранилище записей логов
, logColorDistrib :: LogColorDistrib -- текущее распределение цветов
, logWindowDistrib :: LogWindowDistrib -- текущее распределение окон
, logFilePath :: FilePath -- путь к файлу с игровым логом
, exePath :: FilePath -- путь к рабочей директории
...
} deriving (Eq)
Здесь указаны только основные данные модели. Остальные, не указанные здесь, играют вспомогательную роль.
Некоторые поля структуры назначаются только при загрузке программы (например, mainConfig, exePath или logFilePath), или могут меняться во время выполнения (например, curTime, logEntries или logWindowDistrib).
Инстанс тип-класса Eq здесь обязателен, так как старая и новая измененная модели могут сравниваются для принятия решения о слиянии (об этом ниже).
События (Events)
События представляют собой различные действия, на которые может реагировать процедура обработчик событий. Это алгебраический тип данных, значения которого могут принимать аргументы в зависимости от события. Так событию AppInit (инициализация графического интерфейса) не нужны аргументы, но событие AppAddRecord (добавление записи лога в хранилище) требует получения аргумента, а именно, записи лога.
В приложении довольно много событий, и здесь я приведу только некоторые из них, для иллюстрации.
data AppEvent = AppInit
| AppShowColorConfig
| AppCloseColorConfigScreen IsDialogApply
| AppColorConfigSaved
| AppAddRecord LogEntry
...
}
Создание пользовательского интерфейса.
Функция создания пользовательского интерфейса строит дерево виджетов. Всякий раз, когда модель изменяется, эта функция будет вызываться, и будет создаваться новая версия дерева виджетов. Затем эта новая версия дерева виджетов будет объединена с предыдущей версией дерева. Процесс такого объединения называется слиянием. Фреймворк предоставляет некоторый контроль над процессом слияния, а именно, можно задать условия, когда слияние не производится.
Код приложения включает в себя следующий фрагмент с объявлением функции создания пользовательского интерфейса:
buildUI :: WidgetEnv AppModel AppEvent -> AppModel -> WidgetNode AppModel AppEvent
buildUI wenv model = widgetTree where ...
Тип WidgetEnv (информация о среде, которую можно использовать при построении пользовательского интерфейса), как и тип WidgetNode (результат построения пользовательского интерфейса) включают модель AppModel и события AppEvent в качестве параметров.
Теперь рассмотрим параметры, которые получает функция:
- wenv (сокращение от Widget Environment) включает информацию об ОС, размере окна, состоянии ввода, фокусе и некоторых других элементах.
- model — это текущее состояние модели приложения.
Наконец, возвращается значение типа WidgetNode, которое может быть или отдельным виджетом или более сложным агрегатом виджетов.
Виджеты являются данными типа Widget, который включает процедуры для инициализации, слияния, удаления и отображения определенного типа виджета (например, checkbox или textField). Если нужен особый рендеринг, то можно создать свой собственный виджет делая его данными этого типа (и реализуя соответствующие процедуры).
WidgetNode содержит экземпляр виджета и всю информацию, относящуюся к его расположению, размеру, видимости, дочерним элементам и т. д. Когда упоминается «дерево виджетов», на самом деле это «дерево узлов». Обычно все функции, которые создают виджеты, возвращают данные типа WidgetNode, что позволяет объединять их в более крупные структуры.
Далее мы рассмотрим примеры некоторых основных виджетов.
Mакет (Layout)
Двумя виджетами для создания макета являются hstack и vstack. Они позволяют размещать виджеты рядом друг с другом в горизонтальном или вертикальном положении, стараясь удовлетворить требованиям размера каждого из них (h или v указывают на главную ось).
В коде приложения вы можете видеть как оба виджета используются для создания макета главного окна:
logScreenLayer = vstack [
hstack [
spacer,
hstack [mainButton "Colors" AppShowColorConfig]
`styleBasic` [ paddingH 5, paddingV 8 ],
hstack [mainButton "Windows" AppShowWindowConfig]
`styleBasic` [ paddingH 5, paddingV 8 ]
] `styleBasic` [],
logScreen wenv model
]
Метка (Label)
Этот виджет используется для отображения текста. А именно, он отображает данные типа Text.
Большинство виджетов поддерживают базовую версию, такую как label, и настраиваемую версию, которая обозначается символом _ в конце. В случае label_ есть следующие параметры конфигурации:
- multiline: разбивает текст на несколько строк, если ширины недостаточно.
- ellipsis: показывает многоточие, когда текст переполняется, а не просто обрезает его.
Например:
label_ "Это\nмногострочный текст" [multiline, ellipsis]
Кнопка (Button)
Виджет кнопки обеспечивает базовый способ взаимодействия для пользователей. Чтобы создать его, ему нужны заголовок и событие, которое определено в типе Events.
mainButton "Windows" AppShowWindowConfig
Кнопка поддерживает те же параметры конфигурации, что и label (multiline, ellipsis и т. д.), а также некоторые дополнительные параметры для других возможных событий, доступных с помощью button_:
- onClick: вызывает событие, при нажатии на кнопку.
- onFocus: вызывает событие, когда кнопка получает фокус.
- onBlur: вызывает событие, когда кнопка теряет фокус.
Если указан только один аргумент как в примере, то он назначается как обработчик события onClick.
Обработка событий (Event Hadling)
Управление событиями осуществляется особой процедурой
handleEvent :: WidgetEnv AppModel AppEvent -> WidgetNode AppModel AppEvent
-> AppModel -> AppEvent -> [EventResponse AppModel AppEvent AppModel ()]
handleEvent wenv _ model evt = case evt of ...
Снова у нас есть WidgetEnv и WidgetNode, но теперь у нас также есть EventResponse, который принимает те же два параметра (модель и событие).
Глядя на параметры, мы видим следующее:
- wenv: среда виджета.
- _: текущий узел (здесь он не используется; позволяет делать проверки текущей структуры виджетов).
- model: текущее состояние модели.
- evt: событие для обработки.
При работе приложения эта процедура запускает обработчики ожидаемых событий (определенных в AppEvent) и возвращает список результатов в дальнейший процесс выполнения программы.
В коде приложения ряд обработчиков возвращает результат типа Model, который устанавливает новое состояние модели приложения. Если модель изменилась, это повлечет вызов функции создания пользовательского интерфейса. Во фрагменте ниже мы присваиваем специальному полю модели текст ошибки и возвращаем новую модель.
AppErrorShow msg -> [
Model $ model
& errorMsg ?~ msg
]
В обработчике события ниже мы получаем в качестве параметра новую запись лога, и затем изменяем модель (меняем режим слияния, изменяем время получения последней записи и добавляем новую запись в хранилище).
AppAddRecord le -> [
Model $ model
& logMergeMode .~
(if isLogEntryVisible le then LMLast else LMNo)
& curTime .~ fromMaybe (model^.curTime) (le^.leTime)
& logEntries .~ addToLogEntriesDepository
(cfg^. acMaximumLogEntries) le (model^.logEntries),
Task $ afterAddRecord (showt (le^.leId))
]
Также запускаем специальную процедуру для помещения фокуса приложения на эту последнюю запись.
afterAddRecord :: Text -> IO AppEvent
afterAddRecord wKey =
return $ AppSetFocus wKey
Композиты (composites)
Для диалоговых окон задания цветов для тегов и распределения тегов по окнам в приложении сделаны отдельные дочерние части программы со своими собственными функциями создания графического интерфейса и обработчиками событий. Эти части являются так называемыми композитами; основная часть программы также является «базовым» композитом.
Передача данных от основной программы к композитам осуществляется через модель. А обратная передача данных происходит через события основной программы.
Есть также вариант передачи данных к основной программе через ответ Report в обработчике событий самого композита.
Конфигурация.
Здесь речь идет о конфигурации графических компонент приложения, это не конфигурация самого приложения, которая является частью модели.
Здесь можно устанавливать несколько параметров, включая заголовок окна, тему, шрифты и событие, которое вызывается при запуске пользовательского интерфейса (функция startApp).
let appConfig = [
appWindowTitle "DFLogViewerH",
appWindowState (MainWindowNormal aws),
appTheme (customDarkTheme cfg),
appFontDef "Regular" $ T.pack $
path </> fontsPath </> T.unpack (cfg^. acRegularFont),
appFontDef "Bold" $ T.pack $
path </> fontsPath </> T.unpack (cfg^. acEmphasizeFont),
appInitEvent AppInit,
appResizeEvent AppResize
]
OpenGL.
Хотя в этом приложении это не используется, Monomer поддерживает рисование OpenGL. Для информации об этом и многом другом обращайтесь к документации Monomer.
Линзы (Lenses)
Линзы в Haskell это набор типов и функций, которые могут быть удобным средством получения и установки полей, а также средством обхода различных структур данных.
Monomer использует линзы в своем коде, а также предусмотрен специальный модуль (Monomer.Lens) для использования линз в программном интерфейсе. Код приложения также использует линзы: это автогенерируемые линзы для модели и некоторых других структур.
Среди операторов чаще всего используются следующие три:
- ^. (view): получение значения поля структуры данных, на которое указывает линза.
- .~ (set): присваивание значения полю структуры данных, на которое указывает линза.
- &: этот оператор делает возможным множественные присваивания в одном выражении.
Для присваивания полям типа Maybe a используется оператор ?~ как в одном из приведенных выше фрагментов кода. Это пример использования призм (Prizm), так как данные типа Maybe a могут быть представлены как призма (и в данном случае при автогенерации линз это так и делается).
Автогенерция линз происходит с использованием TemplateHaskel. В коде приложения все структуры с линзами имеют символ _ в начале названий полей (в примерах статьи этот символ опущен). В результате автогенерации линзы получают идентификаторы как у полей структуры, но без символа подчеркивания.