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


Преамбула


Началось все с того, что понадобилась однажды индикация одноразрядных признаков. Некоторое приложение получает по некоторому порту некоторые данные, пакет надо разобрать и отобразить на экране. Хорошо бы при этом как-то имитировать привычную приборную лицевую панель. Для отображения цифровых данных 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 <QPainter>
#include <QPen>
#include <QBrush>
#include <QColor>
#include <QGradient>

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


Рисование


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


    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, потому что он не используется. Пусть потерпит, он пока не нужен, но скоро понадобится. Запущенный проект выдает такой результат:


рисунок


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


код
    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);

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


рисунок


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


код
    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 подобран на глазок. Вот результат:


рисунок


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


код
    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);

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


рисунок


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


код
    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);

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


рисунок


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


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


рисунок


Баловство


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


    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 <QMouseEvent>

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


Эпилог


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


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


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

Комментарии (13)


  1. Serge78rus
    09.10.2018 10:04

    Красиво, но не очень реалистично. На реальном горящем светодиоде блики от внешнего освещения не видны. То есть блики нужны только для отображения выключенного состояния.


    1. RomeoGolf Автор
      09.10.2018 17:07

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


  1. Sazonov
    09.10.2018 10:15

    Спасибо, конечно, за статью, но кроме обычного рисования на Qt я тут ничего не увидел. Стоит хотя-бы вынести большинство констант в properties, чтобы можно было настраивать поведение через те же стили. А ещё лучше сделать все тоже самое средствами QtQuick, если нужна интерактивность. Там даже параллакс можно достаточно дёшево прикрутить.
    Для эффективного рисования таких вещей всё-таки намного проще использовать картинки + qss + QToolButton (это на случай, если всякие hover эффекты хочется из коробки). В крайнем случае, если нужно эффективное масштабирование, можно взять svg, оверхеда практически не будет. Еще режут глаза статические переменные внутри методов.


    1. RomeoGolf Автор
      09.10.2018 17:13

      Кроме рисования на Qt заголовок ничего и не обещал :-)
      Нарисовать такую кнопку у меня получилось довольно быстро, около часа на всё, причем, следующий подобный виджет пошел еще быстрее. Найти описание рисования кнопки у меня вышло быстрее, чем найти готовые приличные картинки «бесплатно, без регистрации и СМС» — это к вопросу о SVG и готовых картинках. Стремиться к идеалу надо, конечно, другими путями, тут вариант «просто, но симпатично».


      1. Sazonov
        09.10.2018 17:41
        +1

        Ну да, тут вопрос в масштабах проекта. Если человек 20-30 его делают, то резко возрастает вероятность появления кучи велосипедов с перекрывающийся функциональностью. Я с таким сталкивался. Поэтому считаю, что если вы рассчитываете на реюзабельность виджета, то нужно делать его максимально продуманным и гибким. И крайне желательно, чтобы он реализовывал стандартные механизмы Qt по кастомизации, а именно поддержку стилей.
        По поводу картинок — погуглите по запросу “fatcow icons”. Практически полностью покрывает потребности многих проектов.


        1. RomeoGolf Автор
          09.10.2018 18:07

          За наводку на иконки — гран мерси, премного благодарен. Хотя по возможности предпочитаю рисовать что-то свое (если время остается).


  1. andi123
    09.10.2018 13:24

    Я конечно понимаю, что тут про принцип и подход к реализации.
    Но если кому-то нужен светодиод без лишних велосипедов, то например в кедах есть /usr/include/kled.h.
    Причем я его использовал еще как минимум 12 лет назад.


    1. RomeoGolf Автор
      09.10.2018 17:17

      Кеды — это очень хорошо. ХРюша — похуже, но выбора нет. Под винду ничего приличного не нашел, оказалось быстрее сделать самому.


      1. andi123
        09.10.2018 17:31
        +1

        Когда есть исходники всегда можно их по-заимствовать kdelibs/kdeui/widgets/kled.cpp
        Причем, виджет чистый, зависит только от qt.


        1. RomeoGolf Автор
          09.10.2018 18:01

          Это я уже понял, спасибо за наводку. Просто кед под рукой нет, надо где-то
          в сети искать.


  1. A1ien
    09.10.2018 17:45

    А почему не использовать QML?


    1. RomeoGolf Автор
      09.10.2018 18:15

      Честно сказать, он мне чисто субъективно не нравится. Предпочитаю обходиться без.


      1. A1ien
        09.10.2018 20:56

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