Qt: рисование по мотивам векторной графики

kp7voi6ve63y4yxzdtthd-dxs80.pngQt предоставляет программисту очень богатые возможности, однако набор виджетов ограничен. Если ничего из имеющегося в наличии не подходит, приходится рисовать что-то свое. Простейший способ — использовать готовые картинки — имеет серьезные недостатки: необходимость хранения изображений в файле или ресурсах, проблемы с масштабируемостью, с переносимостью форматов изображений. Ниже описывается вариант использования принципов векторной графики без использования собственно векторных изображений.


Преамбула

Началось все с того, что понадобилась однажды индикация одноразрядных признаков. Некоторое приложение получает по некоторому порту некоторые данные, пакет надо разобрать и отобразить на экране. Хорошо бы при этом как-то имитировать привычную приборную лицевую панель. Для отображения цифровых данных Qt предлагает «из коробки» класс QLCDNumber, похожий на знакомые семисегментные индикаторы, а вот одиночных лампочек что-то не видно.

Использование флажков (они же check boxes) и переключателей (они же radio buttons) для этих целей плохо, и вот список причин:


  • Это неправильно семантически. Кнопки — они и есть кнопки, и предназначены для ввода пользователем, а не для показа ему чего-либо.
  • Отсюда вытекает второе: пользователь так и норовит тыкнуть в такие кнопки. Если при этом обновление информации не особенно быстрое, индикация будет врать, а пользователь — сообщать о неправильной работе программы, мерзко хихикая.
  • Если заблокировать кнопку для нажатия (setEnabled (false)), то она становится некрасиво серой. Помнится, в Delphi, в районе версии 6, был такой финт ушами: можно было положить флажок на панель и отключить доступность панели, а не флажка, тогда флажок не был ни серым, ни активным. Тут такой фокус не проходит.
  • Кнопки имеют фокус ввода. Соответственно, если в окне есть элементы ввода, и пользователь гуляет по ним с помощью клавиши «Tab», ему придется погулять и по элементам вывода, это неудобно и некрасиво.
  • В конце концов, такие кнопки просто неэстетично смотрятся, особенно рядом с семисегментниками.

Вывод: надо рисовать лампочку самому.


Муки выбора

Сначала поискал готовые решения. В ту далекую пору, когда использовал Delphi, можно было найти просто гигантское количество готовых компонентов, как от серьезных фирм, так и любительского изготовления. В Qt с этим напряженка. У QWT есть кое-какие элементы, но не то. Любительщины вообще не видел. Наверное, если грамотно рыть на Github`е, то можно что-то найти, но я, пожалуй, быстрее сам сделаю.

Первое, что напрашивалось из самодельного — использовать два файла-картинки с изображениями включенной и выключенной лампочки. Плохо:


  • Надо найти хорошие картинки (или нарисовать, но художник я никакой);
  • Принципиальный вопрос: тырить нехорошо, даже картинки, даже валяющиеся под ногами;
  • Надо их хранить где-то. В файлах совсем плохо: случайно сотрется — и нету кнопок. В ресурсах получше, но тоже не хочется, если можно обойтись;
  • Масштабируемость никакая;
  • Настраиваемость (цвета, например) достигается только добавлением файлов. То есть, ресурсоемко и негибко.

Второе, что вытекает из первого — вместо картинок использовать векторные изображения. Тем более, что Qt умеет рендерить SVG. Тут уже чуть проще с поиском собственно изображения: в сети много уроков по векторной графике, можно найти что-то более-менее подходящее и адаптировать под свои нужды. Но остается вопрос по хранению и настраиваемости, да и рендеринг не бесплатен по ресурсам. Копейки, конечно, но все же…

И третье вытекает из второго: можно же воспользоваться принципами векторной графики при самостоятельной прорисовке изображения! Файл векторной картинки в текстовом виде указывает, что и как рисовать. Я могу кодом указать то же самое, используя векторные туториалы. Благо, у объекта QPainter имеются в наличии необходимые инструменты: перо, кисть, градиент и рисование примитивов, даже заливка текстурой. Да, инструменты далеко не все: нет масок, режимов наложения, но совсем уж фотореалистичности не требуется.

Поискал немного примеры в сети. Взял первый попавшийся урок: «Рисуем кнопку в графическом редакторе Inkscape» с сайта «Рисовать легко». Кнопка из этого урока гораздо больше похожа на лампочку, чем на кнопку, что меня вполне устраивает. Делаю заготовку: вместо Inkscape — проект в Qt.


Проба пера

Создаю новый проект. Выбираю название проекта rgbled (потому что хочу сделать что-то вроде RGB-светодиода) и путь к нему. Выбираю базовый класс QWidget и название RgbLed, отказываюсь создавать файл формы. Проект по умолчанию после запуска делает пустое окно, оно пока неинтересное.


Подготовка к рисованию

Заготовка есть. Теперь надо завести закрытые члены класса, которые будут определять геометрию рисунка. Существенным плюсом векторной графики является ее масштабируемость, поэтому константных чисел должно быть по минимуму, и те лишь задавать пропорции. Размеры будут пересчитываться в событии resizeEvent (), которое надо будет переопределить.

В используемом уроке по рисованию размеры задаются в пикселах по ходу действия. Мне же нужно заранее определить, что я буду использовать и как пересчитывать.

Рисуемая картинка состоит из таких элементов:


  • внешнее кольцо (с наклоном наружу, часть выпуклого ободка)
  • внутреннее кольцо (с наклоном внутрь)
  • корпус лампочки-светодиода, «стекло»
  • тень по краю стекла
  • верхний блик
  • нижний блик

Концентрические круги, то есть, всё, кроме бликов, определяется позицией центра и радиусом. Блики определяются центром, шириной и высотой, причем позиция X центров бликов совпадает с позицией X центра всего рисунка.

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


код
private:
    int height;
    int width;
    int minDim;
    int half;
    int centerX;
    int centerY;
    QRect drawingRect;

    int outerBorderWidth;
    int innerBorderWidth;
    int outerBorderRadius;
    int innerBorderRadius;
    int topReflexY;
    int bottomReflexY;
    int topReflexWidth;
    int topReflexHeight;
    int bottomReflexWidth;
    int bottomReflexHeight;

Затем переопределяю защищенную функцию, вызываемую при изменении размеров виджета.


код
protected:
    void resizeEvent(QResizeEvent *event);

void RgbLed::resizeEvent(QResizeEvent *event)
{
    QWidget::resizeEvent(event);
    this->height = this->size().height();
    this->width = this->size().width();
    this->minDim = (height > width) ? width : height;
    this->half = minDim / 2;
    this->centerX = width / 2;
    this->centerY = height / 2;

    this->outerBorderWidth = minDim / 10;
    this->innerBorderWidth = minDim / 14;
    this->outerBorderRadius = half - outerBorderWidth;
    this->innerBorderRadius = half - (outerBorderWidth + innerBorderWidth);

    this->topReflexY = centerY
            - (half - outerBorderWidth - innerBorderWidth) / 2;
    this->bottomReflexY = centerY
            + (half - outerBorderWidth - innerBorderWidth) / 2;
    this->topReflexHeight = half / 5;
    this->topReflexWidth = half / 3;
    this->bottomReflexHeight = half / 5;
    this->bottomReflexWidth = half / 3;

    drawingRect.setTop((height - minDim) / 2);
    drawingRect.setLeft((width - minDim) / 2);
    drawingRect.setHeight(minDim);
    drawingRect.setWidth(minDim);
}

Здесь вычисляется сторона квадрата, в который вписана лампочка, центр этого квадрата, радиус ободка, занимающего максимально возможную площадь, ширина ободка, внешняя часть которого пусть будет 1/10 от диаметра, а внутренняя — 1/14. Затем вычисляется положение бликов, которые находятся в серединах верхнего и нижнего радиусов, ширина и высота подбираются на глазок.

Кроме того, в защищенные поля сразу добавлю набор цветов, которые будут использоваться.


код
    QColor ledColor;
    QColor lightColor;
    QColor shadowColor;
    QColor ringShadowDarkColor;
    QColor ringShadowMedColor;
    QColor ringShadowLightColor;
    QColor topReflexUpColor;
    QColor topReflexDownColor;
    QColor bottomReflexCenterColor;
    QColor bottomReflexSideColor;

По названиям примерно понятно, что это цвета лампочки, светлой части тени, темной части тени, три цвета кольцевой тени вокруг лампочки и цвета градиентов бликов.

Цвета надо бы инициализировать, поэтому дополню заготовку конструктора.


код
RgbLed::RgbLed(QWidget *parent) : QWidget(parent),
    ledColor(Qt::green),
    lightColor(QColor(0xE0, 0xE0, 0xE0)),
    shadowColor(QColor(0x70, 0x70, 0x70)),
    ringShadowDarkColor(QColor(0x50, 0x50, 0x50, 0xFF)),
    ringShadowMedColor(QColor(0x50, 0x50, 0x50, 0x20)),
    ringShadowLightColor(QColor(0xEE, 0xEE, 0xEE, 0x00)),
    topReflexUpColor(QColor(0xFF, 0xFF, 0xFF, 0xA0)),
    topReflexDownColor(QColor(0xFF, 0xFF, 0xFF, 0x00)),
    bottomReflexCenterColor(QColor(0xFF, 0xFF, 0xFF, 0x00)),
    bottomReflexSideColor(QColor(0xFF, 0xFF, 0xFF, 0x70))
{

}

Еще надо не забыть вставить в заголовочный файл инклуды классов, которые понадобятся при рисовании.


код
#include 
#include 
#include 
#include 
#include 

Этот код компилируется успешно, но в окне виджета ничего не изменилось. Пора начинать рисовать.


Рисование

Ввожу закрытую функцию

    void drawLed(const QColor &color);

и переопределяю защищенную функцию

    void paintEvent(QPaintEvent *event);

Событие перерисовки будет вызывать собственно рисование, которому в качестве параметра передается цвет «стекла».


код
void RgbLed::paintEvent(QPaintEvent *event)
{
    QWidget::paintEvent(event);
    this->drawLed(ledColor);
}

Пока так. А функцию рисования начинаем понемногу заполнять.


код
void RgbLed::drawLed(const QColor &color)
{
    QPainter p(this);

    QPen pen;
    pen.setStyle(Qt::NoPen);
    p.setPen(pen);
}

Сперва создается объект-художник, который и будет заниматься рисованием. Затем создается карандаш, который нужен для того, чтобы карандаша не было: в данном изображении обводка по контуру не просто не нужна, а вообще не нужна.

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


код
    QRadialGradient outerRingGradient(QPoint(centerX,
                          centerY - outerBorderRadius - (outerBorderWidth / 2)),
                          minDim - (outerBorderWidth / 2));
    outerRingGradient.setColorAt(0, lightColor);
    outerRingGradient.setColorAt(1, shadowColor);
    QBrush outerRingBrush(outerRingGradient);
    p.setBrush(outerRingBrush);
    p.drawEllipse(this->drawingRect);
    qDebug() << "draw";

Среда подчеркивает параметр color функции drawLed, потому что он не используется. Пусть потерпит, он пока не нужен, но скоро понадобится. Запущенный проект выдает такой результат:


рисунок

trd5w4j8fbipcfgzjzhgydbxln8.png

Добавляем еще порцию кода.


код
    QRadialGradient innerRingGradient(QPoint(centerX,
                          centerY + innerBorderRadius + (innerBorderWidth / 2)),
                          minDim - (innerBorderWidth / 2));
    innerRingGradient.setColorAt(0, lightColor);
    innerRingGradient.setColorAt(1, shadowColor);
    QBrush innerRingBrush(innerRingGradient);
    p.setBrush(innerRingBrush);
    p.drawEllipse(QPoint(centerX, centerY),
                  outerBorderRadius, outerBorderRadius);

Почти тот же самый круг, только меньше размером и вверх ногами. Получаем такую картинку:


рисунок

slqa-p1yxoytsvbaw86hsqniexw.png

Дальше наконец-то понадобится цвет стекла:


код
    QColor dark(color.darker(120));
    QRadialGradient glassGradient(QPoint(centerX, centerY),
                              innerBorderRadius);
    glassGradient.setColorAt(0, color);
    glassGradient.setColorAt(1, dark);
    QBrush glassBrush(glassGradient);
    p.setBrush(glassBrush);
    p.drawEllipse(QPoint(centerX, centerY),
                  innerBorderRadius,
                  innerBorderRadius);

Здесь при помощи функции darker из переданного цвета получается такой же цвет, но потемнее, для организации градиента. Коэффициент 120 подобран на глазок. Вот результат:


рисунок

hswqdl5hkf_fxabxawraf8alsgq.png

Добавляю кольцевую тень вокруг стекла. Так сделано в уроке по векторной графике, и это должно добавить объему и реалистичности:


код
    QRadialGradient shadowGradient(QPoint(centerX, centerY),
                              innerBorderRadius);
    shadowGradient.setColorAt(0, ringShadowLightColor);
    shadowGradient.setColorAt(0.85, ringShadowMedColor);
    shadowGradient.setColorAt(1, ringShadowDarkColor);
    QBrush shadowBrush(shadowGradient);
    p.setBrush(shadowBrush);
    p.drawEllipse(QPoint(centerX, centerY),
                  innerBorderRadius,
                  innerBorderRadius);

Тут градиент трехступенчатый, чтобы тень была гуще к краю и бледнела к центру. Получается так:


рисунок

ga0nwjnp8vjh6yupym3d_yyo5lc.png

Добавляю блики, сразу оба. Верхний блик в отличие от нижнего (и всех остальных элементов) сделан линейным градиентом. Художник из меня так себе, поверю на слово автору урока. Возможно, в этом есть какая-то правда, экспериментировать с разными видами градиентов не буду.


код
    QLinearGradient topTeflexGradient(QPoint(centerX,
                                     (innerBorderWidth + outerBorderWidth)),
                              QPoint(centerX, centerY));
    topTeflexGradient.setColorAt(0, topReflexUpColor);
    topTeflexGradient.setColorAt(1, topReflexDownColor);
    QBrush topReflexbrush(topTeflexGradient);
    p.setBrush(topReflexbrush);
    p.drawEllipse(QPoint(centerX, topReflexY), topReflexWidth, topReflexHeight);

    QRadialGradient bottomReflexGradient(QPoint(centerX,
                                     bottomReflexY + (bottomReflexHeight / 2)),
                              bottomReflexWidth);
    bottomReflexGradient.setColorAt(0, bottomReflexSideColor);
    bottomReflexGradient.setColorAt(1, bottomReflexCenterColor);
    QBrush bottomReflexBrush(bottomReflexGradient);
    p.setBrush(bottomReflexBrush);
    p.drawEllipse(QPoint(centerX, bottomReflexY),
                  bottomReflexWidth,
                  bottomReflexHeight);

Вот, собственно, и все, готовая лампочка, как на КДПВ.


рисунок

99xxbmwsmy1r6fo-dht2pif7wdg.png

На заметность бликов и выпуклости стекла влияет цвет, точнее, то, насколько он темный. Возможно, имеет смысл добавить регулировку яркости бликов и коэффициента затемнения в функции darker в зависимости от темности, но это уже перфекционизм, я считаю.

Ниже — пример использования в окне программы.


рисунок

xyttix1vqiabybl7-no-9d2dgk8.png


Баловство

Для интереса можно поиграться с цветами. Например, переопределив защищенное событие клацанья мыши

    void mousePressEvent(QMouseEvent *event);

таким образом:


код
void RgbLed::mousePressEvent(QMouseEvent *event)
{
    static int count = 0;
    if (event->button() == Qt::LeftButton) {
        switch (count) {
        case 0:
            ledColor = Qt::red;
            count++;
            break;
        case 1:
            ledColor = Qt::green;
            count++;
            break;
        case 2:
            ledColor = Qt::blue;
            count++;
            break;
        case 3:
            ledColor = Qt::gray;
            count++;
            break;
        default:
            ledColor = QColor(220, 30, 200);
            count = 0;
            break;
        }
        this->repaint();
    }
    QWidget::mousePressEvent(event);
}

не забыв добавить мышиные события в заголовок:

#include 

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


Эпилог

Что касается рисования, то на этом все. А виджету следует добавить функциональности. В моем случае было добавлено булево поле «использовать ли состояние», еще одно булево поле, определяющее состояние «Вкл» или «Выкл» и цвета по умолчанию для этих состояний, а также открытые геттеры и сеттеры для всего этого. Эти поля используются в функции paintEvent () для выбора цвета, передаваемого drawLed () в виде параметра. В результате можно отключить использование состояний и задавать «лампочке» любой цвет, а можно включить состояния и зажигать или гасить лампочку по событиям. Особенно удобно сделать сеттер состояния открытым слотом и соединить его с сигналом, который надо отслеживать.

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

Но это уже не принципиально. Целью было показать, где можно взять образцы для подражания при прорисовке собственных виджетов и как эту прорисовку несложно реализовать без использования картинок растровых или векторных, в ресурсах или файлах.

© Habrahabr.ru