GUI с нуля за час. Ищем решения типовых задач при создании графического интерфейса

Колонка
23 сентября 2018, 18:07
GUI с нуля за час.

Lead iOS-разработчик в neoviso Игорь Шавловский в материале «Давайте напишем Windows!» предложил «зажигать пиксели один за другим». Теперь переходим к рендеру и разбираем код из репозитория на GitHub. Для её запуска используйте класс CustomApplication.  

Давайте подумаем, как именно будет организована работа приложения. Для этого зададим правильные вопросы: кто, когда, где и что должен рисовать?

Так как корневой элемент интерфейс — это рабочий стол, рисовать следует именно его, при этом команды должны уходить на драйвер устройства (VideoDevice). Допустим, устройство есть, рабочий стол тоже. Но как их связать вместе? Нужен посредник (Application), который будет готовить девайс и через графический контекст передавать на него команды рендера с рабочего стола. Когда он должен это делать

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

Как работает GUI-приложение

Что ещё может происходить в UI-цикле? Если нам надо переместить окно на рабочем столе или сменить текст в лейбле, добавить дочерний элемент, можем ли мы сделать это во время отрисовки? Очевидно, эти операции могут быть небезопасными. Например, можно добавить элемент в массив childs  в тот момент, когда он находится в процессе перечисления, что вызовет ошибку. И это не единственное тонкое место — изменение размеров элементов, текста, цвета и других параметров во время отрисовки могут создать проблемы в коде рендера.

Правильным решением здесь будет синхронизация процесса рендера и модификации параметров элементов интерфейса. Поскольку подобные операции (назовём их invoke) в GUI происходят довольно часто, хорошим решением будет просто выполнять их в том же потоке, что и рендер, непосредственно перед ним. Для этого сделаем очередь операций и выберем метод добавления операции в очередь (invokeLater).

Программист, который хочет провести модификацию иерархии/параметров элементов, должен делать это только внутри таких операций. Кроме того, события ввода (например, с мыши и клавиатуры) также должны обрабатываться в этой очереди. Теперь мы имеем представление о том, как работает GUI-приложение.

За пределами приложения лежат устройства ввода-вывода, которые симулированы  классами из пакета by.dev.gui.driver, а «виртуальной машиной» является простой JFrame, который получает от «видеоустройства» картинку (Image) и посылает события мыши в соответствующее «устройство» ввода.

Монитор представляет собой буфер пикселей, в который и рисуется интерфейс, а в конце каждого UI-цикла созданная им видеостраница посылается на «матрицу».

Для управления буфером пикселей создан класс Texture. Ещё вы могли заметить, что GraphicsContext потерял свойство offset и теперь всегда рисует в текстуру, начиная с верхнего левого угла (это изменение будет объяснено ниже при рассмотрении алгоритмов полупрозрачности), у него появился метод fill() для монохромной заливки прямоугольной области, а также наследник SafeGraphicsContext. Дело в том, что дочерние элементы зачастую могут выходить за границы своих родителей, следовательно, их пиксели могут отрисоваться в несуществующие области текстуры. Чтобы не производить проверку попадания в границы родителя каждый раз, проще всего делать её на стороне графического контекста.

Барабанная дробь! Вот мы и создали все инструменты, чтобы всё вышесказанное обрело хоть какой-то практический смысл.

Пишем собственные элементы интерфейса и рисуем их

Начнём с рабочего стола (Desktop). Это будет просто область, залитая одним цветом, с инструментом для управления окнами приложений. Фактически всё, что нужно для нашего примера, — это механизм выбора и контроля текущего активного окна, а также доставки событий мыши до элементов интерфейса, над которыми они произошли.

Окно приложения (Window) — тоже залитый прямоугольник с границей и заголовком, на котором лежит кнопка закрытия окна. Кнопка (Button) — это прямоугольная область, которая меняет цвет при нажатии на неё и посылает событие (callback) по окончанию нажатия.

И мышь! Возьмите её в руки, повертите, определите, что вы можете с ней сделать: мышь можно перемещать и нажимать на ней кнопки. Отлично, значит это и  будет интерфейсом нашего драйвера. Поскольку мышь — устройство ввода, то для него мы не отправляем управляющие команды и лишь принимаем их через MouseDeviceCallback. Отметим, что для драйвера мыши могут существовать и команды управления: установка позиции курсора, чувствительности и т. д.

Осталось доставить входящие в приложение события мыши (MouseEvent) до их конечных получателей — элементов интерфейса. Первым делом надо решить, кто будет управлять этой доставкой — это должен быть класс, который имеет доступ ко всей иерархии элементов, а в нашем случае это как раз рабочий стол, приложение будет сообщать ему о новом событии мыши, вызывая метод onMouseEvent, остальное будет на его совести. Что же именно он должен делать?

Сначала надо придумать алгоритм поиска элемента, который находится в точке, в которой произошло событие мыши — nodeForMouseEvent(). Для этого надо посмотреть на рабочий стол, навести мышь на какую-нибудь кнопку и подумать, как найти именно её, зная только список элементов, лежащих на рабочем столе. И также держать в голове, что в случае, если поверх вашей кнопки лежит окно, оно перехватит событие, и кнопка его не получит. Значит событие получает тот элемент, который лежит выше всех в этой точке. Учитывая порядок отрисовки дочерних элементов в методе draw(), можно понять, что чем выше индекс элемента в массиве childs, тем позже он рисуется. В том числе если положить кнопку А на кнопку В, то кнопка А является получателем события мыши только в том случае, если нажатие не попадает в область кнопки В и попадает в область кнопки А. Так как нарисованный последним элемент лежит выше всех,  искать получателя события надо рекурсивно, начиная с родительского элемента в порядке убывания индекса элемента.  

Имея этот алгоритм, мы сможем, например, найти кнопку, которая соответствует событию нажатия кнопки мыши и выполнить её действие. Однако просто проброса события до элемента, который находится под курсором мыши, недостаточно.

Как можно реализовать drag-n-drop для окон?  

Речь идёт о механизме перетаскивания окна зажатием мыши на его заголовке. Допустим, событие нажатия мыши происходит над заголовком конкретного окна. Окно запоминает, что его в данный момент «тянут» и ждёт событий перемещения мыши и отжатия кнопки. Однако эти события дойдут до окна только в том случае, если пользователь перемещает мышь исключительно в пределах элемента, отвечающего на это событие, то есть заголовка окна. Если же мышь выйдет за его пределы, то nodeForMouseEvent уже не найдёт его как получатель события, и перемещение окна прекратится. Более того, если окно не получит события отпускания кнопки мыши, оно так и останется навсегда в режиме перетаскивания.

По теме
Все материалы по теме

Подобные артефакты часто можно увидеть в видеоиграх, когда перетаскиваемый предмет «прилипает» к курсору, если другая программа, например, антивирус, забрала фокус у активного окна непосредственно в момент перетаскивания, и отпускание мыши так и не было доставлено до элемента.

Скажу больше: отдельные элементы в силу своей специфики могут реагировать на события мыши, даже если они не находятся прямо под курсором (курсор за границами элемента), или находятся, но перекрыты другими элементами. Например, если вы тянете файл с рабочего стола в корзину, на события мыши тут будут реагировать сразу два элемента интерфейса: перетаскиваемый файл и сама корзина, которая подсветится при поднесении к ней файла. Причём два элемента — это не предел, их может быть намного больше. Отсюда вывод: события мыши должны получать все элементы интерфейса, и уже какой-то конкретный механизм определяет, как они будут делить это событие между собой.

Можно придумать несколько таких механизмов, но для реализации нашей задачи (событие кнопки при отпускании мыши и перетаскивании окон) можно обойтись малой кровью — подходом с запоминанием активного элемента. Это элемент, который в данный момент эксклюзивно отвечает на события мыши. Пока он активен, рабочий стол будет направлять события исключительно ему, независимо от того, находится ли мышь над элементом или элемент в границах своего родителя. Пока активного элемента нет, рабочий стол будет предлагать событие элементу под курсором, вызывая метод doMouseEvent(). Метод должен возвращать boolean, который говорит, готов ли элемент стать активным. Как только один из элементов вернёт true, он станет активным и будет перехватывать все события мыши. Продолжаться это будет до тер пор, пока doMouseEvent() не вернёт false, что означает — элемент более не активен, и все другие опять могут получать события мыши. Этого простого и немного костыльного подхода хватит, чтобы удовлетворить наши потребности. 

Создайте приложение сами!

Имея эти элементы, события мыши и класс Application, мы можем создать простое приложение. Предлагаю написать его с точки зрения разработчика, то есть используя написанный нами GUI как фреймворк. Код приложения вы можете найти в классе CustomApplication.  

Усложняем задачу и добавляем полупрозрачность

Напоследок предлагаю усложнить задачу и добавить полупрозрачность. Это типовое задание, которое встречается при разработке интерфейса. Однако без аппаратного ускорения подобная задача становится сравнительно сложной. Давайте разбираться вместе.

Всегда помните — начинаем с малого, с частного, вычленяем простые операции, задаём нужные вопросы. И главный вопрос в каждой задаче — а в чём же она состоит, что такое полупрозрачность? И опять всё сводится к одному пикселю: оригинальный пиксель, лежащий «снизу», переписывается новым пикселем, но при этом не полностью, а в какой-то степени, называемой alpha. Alpha обычно измеряется в тех же единицах, что и каналы цвета, то есть в нашем случае — числом от 0 до 255.

Теперь надо «поиграть» с alpha и понять, какая математика за этим стоит. Техническим языком эта математика называется blending, то есть способ наложения двух цветов. Можно открыть Photoshop и посмотреть как результирующий цвет меняется в зависимости от показателя прозрачности и способа смешивания.

Но нам нужен универсальный и простой способ смешивания. Проще всего понять его в мысленном эксперименте. Представляем себе чёрный квадрат и кладём поверх белый, только полностью прозрачный. Чёрный квадрат останется чёрным, потому что белый не сможет на него повлиять. Теперь представляем, что alpha белого квадрата максимальна, то есть теперь он полностью перепишет чёрный цвет своим. А сейчас  мысленно погоняйте в голове прозрачность от минимуму к максимуму и наоборот. Промежуточные цвета будут серыми, то есть все каналы равномерно будут перетекать от 0 к 255, выдавая в результате градиент серого, и чем больше прозрачность белого, тем более тёмный серый мы получаем. Теперь надо увидеть правило, согласно которому всё это работает. Фактически мы имеем простое параметрическое уравнение:

color = source + (dest — source) * (alpha / alphaMax);

То есть для примера с белым и чёрным цветами по каждому каналу уравнение будет выглядеть так:

red = green = blue = 0 + (255 — 0) * (alpha / 255);

Всегда проверяйте свои формулы, подставляя значения из известных предельных случаев!

0 + (255 — 0) * (0 / 255) = 0 (при полной прозрачности чёрный остаётся нулём);
0 + (255 — 0) * (255 / 255) = 255 (при нулевой прозрачности цвет становится белым белый).

Итак, мы поняли, как смешать два пикселя с заданным коэффициентом. Следующий этап — понять, как это смешивание встроить в наш рендер. Во-первых, добавим к Node свойство alpha. Во-вторых, подумаем над самым простым случаем, когда над рабочим столом рисуется одна полупрозрачная кнопка. Сама кнопка может менять цвет в зависимости от своего состояния, на ней может находиться текст, картинка, то есть внешний вид кнопки определяется только ей самой, он не зависит от родителя. Значит альфа должна быть применена в момент рисования уже «готовой» кнопки на рабочий стол.

Сработает ли наш старый подход, при котором сначала отрисуется рабочий стол, а поверх — кнопка? В момент рисования пикселя кнопки она возьмёт текущее значение цвета этого пикселя и прибавит к нему собственный цвет с альфой по описанной сверху формуле и запишет результат поверх исходного цвета. Всё выглядит нормально.

Но что если эта полупрозрачная кнопка будет лежать на полупрозрачном окне? Тогда сначала опять же отрисуется рабочий стол, потом поверх его окно. И вроде всё хорошо? Но рисование кнопки всё испортит. Дело в том, что кнопка относительно рабочего стола полупрозрачна не только на свою полупрозрачность, но и на полупрозрачность окна, которая складывается мультипликативно (так как альфа окна «наследуется» всеми его элементами). Даже если мы сложим альфу кнопки с альфой окна и используем её для отрисовки элемента, мы не получим правильный результат. Результатом отрисовки дочернего элемента на родителя с полупрозрачностью будет исключительно смесь цветов самого элемента и родителя, независимо от более верхних уровней иерархии. Их сумма в свою очередь уже будет рисоваться на следующий слой таким же образом.

Это отлично видно, если мы представим, что альфа окна равна 50%, а кнопка не прозрачна вообще. В этом случае теоретически все пиксели окна, покрытые кнопкой, в результате должны быть лишь смесью половины рабочего стола и половины кнопки, но в момент рисования кнопки эти пиксели уже «загрязнены» окном. Математически это выглядит так:

C = Cdt*(1 — Aw) + Cw * Aw;

Cw = Cw * (1 — Ab) + Cb * Ab;

C = Cdt*(1 — Aw) + (Cw * (1 — Ab) + Cb * Ab)* Aw;

Если Ab = 1 и Aw = 0.5, то

C = 0.5 * Cdt + 0.5 * Cb;

C — цвет результата, Cdt, Cw, Cb, Aw, Ab — цвета и альфа элементов (dt — desktop, w — window, b — button).

Как можно решить эту проблему? Трудность заключается в том, что мы можем лишь корректно нарисовать один полупрозрачный элемент поверх статичной картинки, но на момент отрисовки конкретного элемента его цвет зависит не только от него самого, но и от его детей. Предлагаю читателям подумать над решением, перед тем как читать следующий абзац.

А что если мы полностью где-то отрисуем элемент до того, как врисовывать его в родителя? Но для этого надо иметь отрисованные дочерние элементы, а они, в свою очередь, должны знать своих детей и т. д. В этом случае сначала отрисуются элементы, лежащие в вершинах дерева иерархии, потом — их родители, а в самом конце — на рабочем столе появятся готовые отрендеренные окна. Куда же надо их рендерить? А что, если каждому элементу иметь свою текстуру такого же размера, как и он сам? Тогда задачей рисования элемента будет только рендер самого себя и отрисовка дочерних элементов. Именно поэтому из графического контекста пропало свойство отступа — оно не требуется, все элементы теперь рисуют исключительно в свою текстуру в своей же системе координат. Давайте взглянем на код!

Осталось запустить приложение!

И убедиться, что всё работает корректно. Конечно, можно заметить, что всё работает очень медленно, но это не удивительно: мы рисуем пиксель за пикселем, не используя ни возможности быстрого копирования в памяти, ни аппаратное ускорение. Скорость алгоритмов не была целью. Главное  — найти чистые алгоритмы путём анализа, и мы с этим справились.

Напоследок сделам минимальную оптимизацию: не надо перерисовывать элементы при каждом цикле рендера, особенно, если учитывать, что в данный момент все они — лишь залитые прямоугольники. Для этого у элемента вводим свойство needsRedraw, которое и будет индикатором того, что его текстуру следует перерисовать. Это свойство должно выставляться в true при каждом изменении свойств, влияющих на внешний вид элемента. Более того, это должно происходить не только с самим элементом, но и со всеми его родителями, так как их текстуры зависят от внешнего вида всех дочерних элементов.

Если вы начинающий программист, пожалуйста, напишите в комментариях, была ли вам полезна эта статья и интересна ли тема алгоритмизации и поиска решений.

Обсуждение