Hi! Hope you're enjoying this blog. I have a new home at www.goldsborough.me. Be sure to also check by there for new posts <3

Sunday, February 15, 2015

Customizing QDials in Qt - Part 1

I am currently working on the Qt GUI for my C++ FM Synthesizer, Anthem, and wanted to share a particularly fun and interesting bit of that process in this post, namely how to customize the QDial class in Qt. While Qt generally provides a myriad of styling and customization options for all of its widgets, its developers seem to have forgotten about QDial. Out of the box, your only styling option for QDial is to keep it exactly how it is. But is that going to stop us from creating super sassy dials for our GUI projects? No!

I will show you how to create these two types of dials with fully customizable colors:



This tutorial will provide a general introduction and explain the implementation of the first dial. Part two will deal with the second dial shown.

When Qt draws a widget on the screen, it calls the widget's paintEvent() method, which then proceeds to paint the necessary rectangles, ellipses, lines and other elements and fills them with colors, shades and gradients to provide for a visual representation of the actions the widget is supposed to perform. For example, in Qt's implementation, QDial::paintEvent(QPaintEvent* pe) looks like so:

void QDial::paintEvent(QPaintEvent *)
{
    QStylePainter p(this);
    QStyleOptionSlider option;
    initStyleOption(&option);
    p.drawComplexControl(QStyle::CC_Dial, option);
}

The QPaintEvent object provides information about the region the widget is supposed to be drawn in, but this argument is usually ignored, as the rendered Widget normally fills the whole area available to it and not just a specific region. If you then spend 15 minutes following up the call chain to look for the piece of source code in Qt's repository that actually implements the drawing of QDial, just so that you can show it off in your tutorial, you'll find that this is how Qt draws its QDial (found here):

if (const QStyleOptionSlider *dial = qstyleoption_cast(opt)) {
            // OK, this is more a port of things over
            p->save();

            // avoid dithering
            if (p->paintEngine()->hasFeature(QPaintEngine::Antialiasing))
                p->setRenderHint(QPainter::Antialiasing);

            int width = dial->rect.width();
            int height = dial->rect.height();
            qreal r = qMin(width, height) / 2.0;
            qreal d_ = r / 6.0;
            qreal dx = dial->rect.x() + d_ + (width - 2 * r) / 2.0 + 1;
            qreal dy = dial->rect.y() + d_ + (height - 2 * r) / 2.0 + 1;
            QRect br = QRect(int(dx), int(dy), int(r * 2 - 2 * d_ - 2), int(r * 2 - 2 * d_ - 2));

            QPalette pal = opt->palette;
            // draw notches
            if (dial->subControls & QStyle::SC_DialTickmarks) {
                p->setPen(pal.foreground().color());
                p->drawLines(calcLines(dial, widget));
            }

            if (dial->state & State_Enabled) {
                p->setBrush(pal.brush(QPalette::ColorRole(styleHint(SH_Dial_BackgroundRole,
                                                                    dial, widget))));
                p->setPen(Qt::NoPen);
                p->drawEllipse(br);
                p->setBrush(Qt::NoBrush);
            }
            p->setPen(QPen(pal.dark().color()));
            p->drawArc(br, 60 * 16, 180 * 16);
            p->setPen(QPen(pal.light().color()));
            p->drawArc(br, 240 * 16, 180 * 16);

            qreal a;
            QPolygonF arrow(calcArrow(dial, a));

            p->setPen(Qt::NoPen);
            p->setBrush(pal.button());
            p->drawPolygon(arrow);

            a = angle(QPointF(width / 2, height / 2), arrow[0]);
            p->setBrush(Qt::NoBrush);

            if (a <= 0 || a > 200) {
                p->setPen(pal.light().color());
                p->drawLine(arrow[2], arrow[0]);
                p->drawLine(arrow[1], arrow[2]);
                p->setPen(pal.dark().color());
                p->drawLine(arrow[0], arrow[1]);
            } else if (a > 0 && a < 45) {
                p->setPen(pal.light().color());
                p->drawLine(arrow[2], arrow[0]);
                p->setPen(pal.dark().color());
                p->drawLine(arrow[1], arrow[2]);
                p->drawLine(arrow[0], arrow[1]);
            } else if (a >= 45 && a < 135) {
                p->setPen(pal.dark().color());
                p->drawLine(arrow[2], arrow[0]);
                p->drawLine(arrow[1], arrow[2]);
                p->setPen(pal.light().color());
                p->drawLine(arrow[0], arrow[1]);
            } else if (a >= 135 && a < 200) {
                p->setPen(pal.dark().color());
                p->drawLine(arrow[2], arrow[0]);
                p->setPen(pal.light().color());
                p->drawLine(arrow[0], arrow[1]);
                p->drawLine(arrow[1], arrow[2]);
            }

            // draw focus rect around the dial
            QStyleOptionFocusRect fropt;
            fropt.rect = dial->rect;
            fropt.state = dial->state;
            fropt.palette = dial->palette;
            if (fropt.state & QStyle::State_HasFocus) {
                br.adjust(0, 0, 2, 2);
                if (dial->subControls & SC_DialTickmarks) {
                    int r = qMin(width, height) / 2;
                    br.translate(-r / 6, - r / 6);
                    br.setWidth(br.width() + r / 3);
                    br.setHeight(br.height() + r / 3);
                }
                fropt.rect = br.adjusted(-2, -2, 2, 2);
                drawPrimitive(QStyle::PE_FrameFocusRect, &fropt, p, widget);
            }
            p->restore();
        }

Why am I showing you this? Because to customize a QDial, we have to re-paint it from scratch. Which is quite fun (and not so complicated as it looks here)! In general, when Qt provides no simple or intuitive way to style a QDial from a stylesheet or when its default rendering is an ... erm, "unpleasant sight", it is best to do a low-level re-paint of the widget.

For our first dial type, we want a static background circle as a surface for a movable, smaller circle that acts as the actual "knob" of the dial. The smaller circle (knob) will rotate around the edge of the background circle (dial). The angle at which the smaller circle is drawn relative to the center of the background circle will depend on the value of the dial. Just like the developers of the original QDial class, we also don't want our knob to go from 0 to 360 degrees, which would be visually unintuitive to the user (is it at 0 or 360 degrees?), but move within some other range. To keep things simple, we'll just use 90 degrees less for the total range. Moreover, we want the knob not to go from 0 degrees to 270 (center right to bottom), but rather from 225 degrees to -45 (or 315, but in this case we're going backwards). What you can take from all of this is that we'll have to do some trigonometry. Yey! Lastly, some words on styling. I love Qt's CSS (or QSS) feature, so we'll want full integration and support for styling from stylesheets for the dial's color and the knob's color, margin and size.

Our first step on the road to custom dials is subclassing the QDial class:

#ifndef CUSTOMDIAL_HPP
#define CUSTOMDIAL_HPP

#include <QDial>

class CustomDial : public QDial
{
    Q_OBJECT

    Q_PROPERTY(double knobRadius READ getKnobRadius WRITE setKnobRadius)

    Q_PROPERTY(double knobMargin READ getKnobMargin WRITE setKnobMargin)

public:

    CustomDial(QWidget * parent = nullptr,
               double knobRadius = 5,
               double knobMargin = 5);

    void setKnobRadius(double radius);

    double getKnobRadius() const;

    void setKnobMargin(double margin);

    double getKnobMargin() const;

private:

    virtual void paintEvent(QPaintEvent*) override;

    double knobRadius_;

    double knobMargin_;
};

#endif // CUSTOMDIAL_HPP

Besides the paintEvent() method to re-draw our widget, we also have knob radius and margin variables and appropriate accessors. Moreover, notice the Q_PROPERTY definitions, which will let us access these variables from a stylesheet.

This is the full definition of the CustomDial class and specifically of CustomDial::paintEvent():

#include "CustomDial.hpp"

#include <QPainter>
#include <QColor>

#include <cmath>

CustomDial::CustomDial(QWidget* parent,
                       double knobRadius,
                       double knobMargin)
: QDial(parent),
  knobRadius_(knobRadius),
  knobMargin_(knobMargin)
{
    // Default range
    QDial::setRange(0,100);
}

void CustomDial::setKnobRadius(double radius)
{
    knobRadius_ = radius;
}

double CustomDial::getKnobRadius() const
{
    return knobRadius_;
}

void CustomDial::setKnobMargin(double margin)
{
    knobMargin_ = margin;
}

double CustomDial::getKnobMargin() const
{
    return knobMargin_;
}

void CustomDial::paintEvent(QPaintEvent*)
{
    static const double degree270 = 1.5 * M_PI;

    static const double degree225 = 1.25 * M_PI;

    QPainter painter(this);

    // So that we can use the background color
    painter.setBackgroundMode(Qt::OpaqueMode);

    // Smooth out the circle
    painter.setRenderHint(QPainter::Antialiasing);

    // Use background color
    painter.setBrush(painter.background());

    // Store color from stylesheet, pen will be overriden
    QColor pointColor(painter.pen().color());

    // No border
    painter.setPen(QPen(Qt::NoPen));

    // Draw first circle
    painter.drawEllipse(0, 0, QDial::height(), QDial::height());

    // Reset color to pointColor from stylesheet
    painter.setBrush(QBrush(pointColor));

    // Get ratio between current value and maximum to calculate angle
    double ratio = static_cast(QDial::value()) / QDial::maximum();

    // The maximum amount of degrees is 270, offset by 225
    double angle = ratio * degree270 - degree225;

    // Radius of background circle
    double r = QDial::height() / 2.0;

    // Add r to have (0,0) in center of dial
    double y = sin(angle) * (r - knobRadius_ - knobMargin_) + r;

    double x = cos(angle) * (r - knobRadius_ - knobMargin_) + r;

    // Draw the ellipse
    painter.drawEllipse(QPointF(x,y),knobRadius_, knobRadius_);
}

The constructor just passes the QWidget parent pointer to QDial's constructor and also sets a default range of [0, 100] for the dial. Of course, all public and protected methods of QDial are also accessible from CustomDial, so we can reset this range outside of the constructor if we need to later on.

Now to paintEvent().

To position our knob on the dial, we'll need some trigonometry, which, when used with circles, usually involves Pi, so we'll want to have some of that in our code somewhere. Because Pi is not destined to change in the foreseeable future,  we can have two const doubles that we scale appropriately for the angles we need (1.5 * pi = 270 degrees; 1.25 * pi = 225 degrees) and which we also make static, so that they are initialized only once for this function.

Next, we need a QPainter object that will do the actual painting and rendering for us. Its constructor's only argument is the QPaintDevice that it it supposed to draw on, in this case "this" -- our dial widget. Also, we set the render hint QPainter::Antialising, to get smooth circles, using painter.setRenderHint().

In order to use the background color from a stylesheet (set either by "background: ...;" or "background-color: ...;" in CSS), we need to set our CustomDial class' background mode to Qt::OpaqueMode. This will activate the background color for the painter object and we can safely retrieve the color that was set in the stylesheet with painter.background(), which returns a QBrush object (for filling). If we needed the QColor object with the background color itself, we could get it with painter.background().color(). However, given that the next step is to set the brush for the background circle we're about to draw, we can just use the brush object itself and set it as the painter's current brush by passing it to painter.setBrush(). By default, the painter will also draw a border around the circle. No thanks. The border and its color is determined by the painter's current QPen object, so to disable the border entirely, we just call painter.setPen(QPen(Qt::NoPen)), which will set the painter's pen to an invisible one. However, before we get rid of the pen, we need to retrieve its color, as this is the other property that can be set using stylesheets ("color" property in CSS) and which we need for the knob color. So, first store the pen's color, then get rid of the pen.

Next up, we can finally draw our first circle. The painter's method for this is unfortunately not drawCircle(), but drawEllipse(), which means we have to pass the same diameter twice to get a circle. I hope you will survive the extra effort. drawEllipse() takes the x and y coordinates to start the drawing for, and then a width and height to determine the size of the ellipse. We draw the ellipse starting in the top left corner (0, 0) and get its diameter from the height of the CustomDial widget. This means that you can (and have to) set the size of the dial using CustomDial::setFixedSize() or any other resizing method.

Once we have the background circle drawn, we can move on to the smaller knob circle. First, we reset the brush of the painter so that it uses the color we retrieved from the stylesheet earlier. Then, it is time to math (I know that's not a verb). So, we calculate the ratio between the current dial value and its maximum, and from that ratio then determine the angle at which we'll draw the knob. Remember, the maximum angle is 270 degrees, and we offset it by -225 degrees to get our preferred range of angles. Knowing that in a circle, the sine of an angle is equal to the opposite side (our y coordinate) divided by the hypothenuse (or radius), and the cosine of that angle to the adjacent side (our x coordinate) divided by the radius, we can reform these equations to get our x and y coordinates. Note the two offsets. The first, (r - knobRadius_ - knobMargin_), essentially makes the available background circle space for the knob smaller, so that it won't stick to the dial's edges. The second offset, + r, ensures that position (0, 0) is in the center of the background circle and not in the top left corner.

Finally, we draw our knob circle by calling painter.drawEllipse() again, this time the overload that has a center QPointF with our x and y coordinates as its first argument and then two radii as its second and third argument. These radii are both the same for a circle.

To customize the look of our dial, we can use CSS / QSS stylesheets, as promised:

CustomDial
{
    background-color: #27272B;
    color: #FFFFFF;
    qproperty-knobRadius: 5;
    qproperty-knobMargin: 5;
}

These properties are also accessible via CustomDial::setStylesheet(), of course:

CustomDial* dial = new CustomDial(this);

dial->setStyleSheet("background-color: #27272B; color: #FFFFFF;");
dial->setStyleSheet("qproperty-knobMargin: 5; qproperty-knobMargin: 5;");

And that'll be it for this part of the tutorial! I hope you learned something and will be back for the second part.

No comments :

Post a Comment