В прошлом выпуске были доработаны методы рисования с использованием рекурсивного обхода дерева:
Пора сделать элементы интерфейса интерактивными. Допустим, чтобы они реагировали на клик.
Программа может получать событие клика c координатами курсора мыши. Нужно понять, попал ли этот клик в какой-либо из элементов.
Сделать это просто: нужно перебрать все элементы в списке, и так как у каждого есть свой прямоугольник, то проверить, попадают ли координаты клика в этот прямоугольник.
Но есть целых три нюанса.
Во-первых, один элемент может находиться поверх другого, полностью или частично перекрывая его. Клик попадает в оба, но нужен только тот, который лежит сверху. Поскольку список построен иерархически, то выше всех будет тот элемент, который добавлен последним, так что это не проблема.
Во-вторых, так как прямоугольники элементов позиционируются относительно прямоугольников их родителей, мы не можем просто взять любой прямоугольник и проверить его. Нужно, начиная от корневого родителя, пересчитывать эффективные координаты прямоугольника, как это делалось в прошлой части для рисования. К счастью, инструментарий уже есть, и кроме того, он позволяет не проверять вообще все элементы списка. Если клик попал в родительский элемент верхнего уровня (окно), то далее мы проверяем попадания только в детей этого окна, а далее у детей детей и т.д. То есть древовидная структура эффективно ограничивает диапазон поиска.
В-третьих, сам по себе такой клик смысла не имеет. Да, мы можем нарисовать, как нажалась кнопка, но это не принесёт никакой пользы. У каждого действия в интерфейсе должна быть цель. К примеру, нажатие на кнопку увеличивает счётчик. Где находится этот счётчик и как его привязать к именно этой кнопке? Вот главная задача.
Модели
Интерфейс типично строится из набора окон, а если говорить точнее, форм (по аналогии с HTML form), где каждая форма по-своему уникальна. Она содержит определённое количество кнопок (будем для краткости упоминать только кнопки), и каждая кнопка предназначена для чего-то.
Обратим внимание, что с точки зрения программы абсолютно неважно, где находятся кнопки, какого они размера или цвета, или что на них написано. Важно лишь то, с какими данными, или с каким состоянием программы, эти кнопки связаны.
Мы можем построить модель формы, состоящую из этих данных. К примеру, форма содержит две кнопки. Нажатие на одну кнопку увеличивает один счётчик, нажатие на другую – другой. Модель такой формы будет иметь следующий вид:
Я не использую приставку GUI_ в типе модели, потому что она никак не связана с GUI, это просто данные, они полностью абстрагированы от изображения на экране.
Мы можем связать эти данные с GUI, сделав в типе GUI_Item дополнительное поле с указателем на данные. Назовём его, к примеру, data_ptr. Тогда одна кнопка будет иметь указатель data_ptr = &model.cnt1, а другая &model.cnt2.
Для того чтобы понять, что конкретно делать при нажатии кнопки, нужно иметь ещё одно поле handler – указатель на функцию-обработчик. К примеру, сделаем функцию button_cnt_handler():
Обеим кнопкам можно назначить указатель на одну и ту же функцию, так как у них одно и то же действие, но разные данные модели.
Нормально ли то, что мы должны расширить структуру элемента GUI_Item, добавив туда указатели на обработчик и на данные? Вообще да, потому что именно так работает, к примеру, JavaScript:
item.onсlick = function()...
То есть у объекта item есть поле onclick, которому присваивается указатель на некую функцию. Если нам покажется, что мы чрезмерно раздуваем структуру GUI_Item, то можно ткнуть палкой в тот же JS, где у объекта одних только on-полей... несколько десятков, не считая всего остального.
Вот это называется "раздуваем". А мы так, погулять вышли. Но есть другие соображения. Каждый обработчик это функция, а функция имеет ограниченный контекст, который не подходит для сложных операций.
Например, нужно сделать так, чтобы при увеличении одного счётчика другой уменьшался, и наоборот. Имея указатель на cnt1, мы увеличим его, но у нас не будет указателя на cnt2, чтобы его уменьшить. Тогда можно сделать указатель на всю модель, и если раньше функция-обработчик была одна, а указатели на данные разные, то теперь функции будут разные, а указатель на данные один:
При желании можно делать и так, и сяк. В общем-то нужно только дать указатели на правильные данные и на правильную функцию.
Но меня по-прежнему беспокоят указатели, которые создают опасную зависимость между объектами GUI и данными. Это всё из-за Rust!
Представим, что обслуживанием одной формы занимается одна функция, которая чётко знает, какие там есть элементы, знает логику их работы, и имеет полную модель данных.
Тогда указатели не нужны. Вместо этого достаточно опознавать элементы по какому-либо идентификатору. Так, первой кнопке в форме можно назначить идентификатор 1, а второй 2. Тогда функция обработки будет иметь следующий вид:
Достаточно окинуть её взглядом, чтобы увидеть всю логику. И несмотря на то, что из-за отсутствия прямых указателей приходится использовать дополнительные if, такой код выглядит более умиротворённым. Мы можем дописать сюда обработку элементов всех других типов, которые есть в нашей форме. Естественно, чем больше будем дописывать, тем длиннее и сложнее будет становиться функция, но стандартные методы структуризации кода нас спасут.
Главное же здесь то, что функция становится контроллером целого окна, который сосредотачивает в себе всю логику, а не её кусочки, разбросанные по разным обработчикам. Модель уже есть, а форма становится, соответственно, View в шаблоне MVC.
Контуры будущего обработчика уже обрисовываются, но нужно учесть ещё одну вещь.
Рассмотрим клик на неактивной вкладке. Что при этом происходит? Неактивная вкладка должна стать активной, а активная – неактивной. Изменится и их внешний вид. Кто должен этим заниматься? Уж точно не наш контроллер, ведь ему без разницы, где элементы расположены на экране и как они выглядят, он знает только про модель.
Значит, этим должен заниматься другой обработчик, который уже в ведомстве GUI. Последовательность действий получается такая:
- Клик по заголовку вкладки
- Находим целевой элемент – группу вкладок
- Вызываем для группы вкладок стандартный GUI-обработчик
- GUI-обработчик изменяет статусы GUI-элементов, чтобы они перерисовались правильно
- Передаём событие дальше в контроллер
И перед тем как мы сделаем несколько разных контроллеров для нескольких разных форм, можно пронзить мыслью пространство и время и увидеть ближайшее будущее.
Предположим, по клику на кнопке окно должно закрываться. Нетрудно догадаться, что практически каждое окно будет иметь такую кнопку. Значит, в каждом контроллере нам придётся обработать такое стандартное действие, как закрытие окна:
Это быстро надоест, и так же быстро мы придём к выводу, что для события закрытия окна нам совершенно неважно знать, была ли нажата какая-то кнопка, была ли это вообще кнопка или что-то другое. Главное то, что в результате окно должно быть закрыто.
Поэтому мы можем абстрагироваться от чисто "механических" событий клика мышью и перейти к "логическим" событиям, которые управляют потоком выполнения программы. Для этого нужно определить типы наших собственных событий. Чтобы долго не морочиться, сделаем пока такой перечислимый тип из всего одного значения:
typedef enum { GUI_EVT_WINDOW_CLOSE } GUI_Event;
Теперь мы можем расширить структуру GUI_Item и сделать в ней поле event_type. Это поле мы можем оставлять пустым, либо назначать ему конкретный тип. В нашей ситуации мы назначим кнопке, которая должна закрывать окно, event_type = GUI_EVT_WINDOW_CLOSE.
Далее, когда мы будем обрабатывать эту кнопку стандартным GUI-обработчиком, в качестве результата обработки мы можем отдать структуру, которая содержит все данные об элементе и тип события, который в нём записан (если мы оставили его пустым, будет подставлен какой-то тип по умолчанию, например GUI_EVT_CLICK, либо так и оставим пустым).
Данные, которые вернулись, уже ни к чему не привязаны. Мы не обязаны сразу же вызывать какой-то обработчик. У нас есть структура, которую можно использовать в любой момент.
И когда такой момент наступит, мы для начала используем общий контроллер, который выполняет универсальные действия. Так, если этот контроллер видит событие GUI_EVT_WINDOW_CLOSE, то у него есть вся необходимая информация, чтобы закрыть целевое окно и больше ничего не выяснять.
Аналогичным образом для инкремента счётчика мы можем сделать собственное логическое событие GUI_EVT_COUNTER_INCREMENT, и обработать его общим контроллером.
А вот если событие не обрабатывается общим контроллером или просто не указано, тогда он по корневому родителю элемента найдёт специфический контроллер специфического окна, которому и передаст событие для обработки.
У нас получился ещё один компонент, называемый роутером или маршрутизатором. Он направляет события в нужные контроллеры, но сначала в тот, который обрабатывает их по умолчанию.
В следующем выпуске я сведу все описанные методы в одну систему, почищу код, сделаю какое-нибудь внятное окно с полезной нагрузкой, и код можно будет уже положить на github.
Читайте дальше: