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
Showing posts with label gui. Show all posts
Showing posts with label gui. Show all posts

Monday, March 9, 2015

Customizing QDials in Qt - Part 2

Welcome back to my tutorial on customizing QDials in Qt! In the first part of this tutorial I pointed out that while Qt gives us the possibility to style labels, buttons and many other widgets, there is no out-of-the-box way to customize QDials. I then explained that if we really do want to add some styling to a QDial, we have to subclass it and do a complete re-paint of the object. At the end of part one, you had this super awesome fully CSS-stylable dial:


That's a dial. We know that. Why? Because all dials look like that -- we've seen it 12000 times and now + 1. Let's innovate! Let's make this dial:



In this case, we're not sliding a small circle around the edge of a bigger one, we're actually drawing an arc with a length of between 0 and 360 degrees. Moreover, because the arc takes up relatively little space on the dial, I've added support for showing a title string as well as the dial value in the center of  our widget. Don't worry if you don't like the dial's background color, font color, arc color or the width of the arc, you'll have full support for customizing those options to your liking using stylesheets!

So, I guess I'll just throw the declaration of our QDial subclass -- CustomDial -- at you:

#ifndef CUSTOMDIAL_HPP
#define CUSTOMDIAL_HPP

#include <QDial>
#include <QString>
#include <QSharedPointer>

class QColor;
class QRectF;
class QPen;

class CustomDial : public QDial
{
    Q_OBJECT
    
    Q_PROPERTY(QString arcColor READ getArcColor WRITE setArcColor)
    
    Q_PROPERTY(double arcWidth READ getArcWidth WRITE setArcWidth)
    
public:
    
    explicit CustomDial(QWidget* parent = nullptr);
    
    CustomDial(const QString& text,
               QWidget * parent = nullptr,
               int minimum = 0,
               int maximum = 999);
    
    
    void setArcColor(const QString& color);
    
    QString getArcColor() const;
    
    
    void setStartAngle(double angle);
    
    double getStartAngle() const;
    
    
    void setMaximumAngle(double angle);
    
    double getMaximumAngle() const;
    
    
    void setArcWidth(double px);
    
    double getArcWidth() const;
    
    
    void setText(const QString& text);
    
    QString getText() const;
    
    
private slots:
    
    void updateValue();
    
private:
    
    virtual void paintEvent(QPaintEvent*) override;
    
    virtual void resizeEvent(QResizeEvent* event) override;
    
    double maximumAngleSpan_;
    
    double startAngle_;
    
    double arcWidth_;
    
    double angleSpan_;
    
    QString valueString_;
    
    QString text_;
    
    QSharedPointer arcRect_;
    
    QSharedPointer valueRect_;
    
    QSharedPointer textRect_;
    
    QSharedPointer arcColor_;
    
    QSharedPointer arcPen_;
};

#endif // CUSTOMDIAL_HPP

I'll talk about a few important aspects of this class. As in the last example, we subclass our CustomDial class from the QDial class, given that we want to retain its behavior -- the way you can slide it in a circular motion to change its value -- as well as its interface -- e.g. setRange(), setValue() or any size-related method.

By declaring a Q_PROPERTY for the color and width of the dial's arc, we will later be able to modify and customize those values from a stylesheet. Just to re-cap: the Q_PROPERTY macro takes a data type as its first argument, e.g. a QString for the color (an overload of QColor's constructor takes an RGB hex triplet as string), then a name for the property -- in our case arcColor and arcWidth, respectively -- followed by READ and WRITE member functions to retrieve the value and write to it in case it is changed in a stylesheet. Note that the two other properties we want to be able to customize -- the dial's background and font color -- already have property definitions built-in: you can  access them via the "background" and "color" properties in CSS.

The public methods of our CustomDial class are just interface -- getters and setters. Our private slot updateValue() we'll later on connect to the QDial::valueChanged signal so that we can update our internal variables, i.e. the span of the arc as well as the value string we display in the center of our dial. Note that we could technically calculate the arc span and other variables dependent on the dial's value in the paintEvent() method itself, however to keep things nice and optimized we take those invariants out, thus ensuring that we perform no unnecessary calculations should the dial value between two separate calls to paintEvent() not change.

I'll also quickly talk about the class' private members. paintEvent() is our familiar friend, who paints the widget onto our application's window. resizeEvent() is another inherited method from QDial, that is called automatically whenever the dial is forced to resize. Because we keep the painting areas / rectangles for our dial arc, dial title as well as the dial value string stored for optimization purposes, we'll have to resize those rectangles whenever the widget has to resize. That's what we do in resizeEvent().

startAngle is the angle at which the arc of our dial starts (270° in the dial shown above) and maximumAngleSpan is the maximum span the arc can have (-360° above). As such, the arc's angle span for any given dial value will be between startAngle (minimum dial value) and startAngle + maximumAngleSpan (maximum dial value). The other variables should be self-explanatory or will become so when I talk about them further.

Here is the definition of the CustomDial class:

#include "CustomDial.hpp"

#include <QPainter>
#include <QColor>
#include <QLabel>
#include <QRectf>
#include <QPen>
#include <QResizeEvent>

CustomDial::CustomDial(QWidget* parent)
: QDial(parent)
{ }

CustomDial::CustomDial(const QString& text,
                       QWidget* parent,
                       int minimum,
                       int maximum)
: QDial(parent),
  text_(text),
  arcRect_(new QRectF),
  valueRect_(new QRectF),
  textRect_(new QRectF),
  arcColor_(new QColor),
  arcPen_(new QPen)
{
    QDial::setRange(minimum, maximum);
    
    QDial::setCursor(Qt::PointingHandCursor);
    
    connect(this, &QDial::valueChanged,
            this, &CustomDial::updateValue);
    
    setMinimumSize(100,100);
    
    setMaximumAngle(-360);
    
    setStartAngle(270);
    
    updateValue();
}

CustomDial::~CustomDial() = default;

void CustomDial::paintEvent(QPaintEvent*)
{
    QPainter painter(this);
    
    // So that we can use the background color
    // Otherwise the background is transparent
    painter.setBackgroundMode(Qt::OpaqueMode);
    
    // Smooth out the circle
    painter.setRenderHint(QPainter::Antialiasing);
    
    // Use background color
    painter.setBrush(painter.background());
    
    // Get current pen before resetting so we have
    // access to the color() method which returns the
    // color from the stylesheet
    QPen textPen = painter.pen();
    
    // No border
    painter.setPen(QPen(Qt::NoPen));
    
    // Draw background circle
    painter.drawEllipse(QDial::rect());
    
    painter.setPen(textPen);
    
    painter.drawText(*textRect_, Qt::AlignHCenter | Qt::AlignBottom, text_);
    
    painter.drawText(*valueRect_, Qt::AlignCenter, valueString_);
    
    painter.setPen(*arcPen_);
    
    painter.drawArc(*arcRect_, startAngle_, angleSpan_);
    
}

void CustomDial::resizeEvent(QResizeEvent* event)
{
    QDial::setMinimumSize(event->size());
    
    double width = QDial::width() - (2 * arcWidth_);
    
    double height = width / 2;
    
    *textRect_ = QRectF(arcWidth_, arcWidth_, width, height);
    
    *valueRect_ = QRectF(arcWidth_, height, width, height);
    
    *arcRect_ = QRectF(arcWidth_ / 2,
                       arcWidth_ / 2,
                       QDial::width() - arcWidth_,
                       QDial::height() - arcWidth_);
}

void CustomDial::updateValue()
{
    double value = QDial::value();
    
    // Get ratio between current value and maximum to calculate angle
    double ratio = value / QDial::maximum();
    
    angleSpan_ = maximumAngleSpan_ * ratio;
    
    valueString_ = QString::number(value);
}

void CustomDial::setArcWidth(double px)
{
    arcWidth_ = px;
    
    *arcRect_ = QRectF(arcWidth_ / 2,
                       arcWidth_ / 2,
                       QDial::width() - arcWidth_,
                       QDial::height() - arcWidth_);
    
    arcPen_->setWidth(arcWidth_);
}

void CustomDial::setText(const QString& text)
{
    text_ = text;
}

QString CustomDial::getText() const
{
    return text_;
}

double CustomDial::getArcWidth() const
{
    return arcWidth_;
}

void CustomDial::setMaximumAngle(double angle)
{
    maximumAngleSpan_ = angle * 16;
}

double CustomDial::getMaximumAngle() const
{
    return maximumAngleSpan_ / 16;
}

void CustomDial::setStartAngle(double angle)
{
    startAngle_ = angle * 16;
}

double CustomDial::getStartAngle() const
{
    return startAngle_ / 16;
}

void CustomDial::setArcColor(const QString& color)
{
    arcColor_->setNamedColor(color);
    
    arcPen_->setColor(*arcColor_);
}

QString CustomDial::getArcColor() const
{
    return arcColor_->name();
}

A lot of this is just interface. A lot of it is not. But before I talk about anything that doesn't concern the getters and setters you see here, I just want to tell you why you see the number 16 floating around here. The reason why we have to multiply all angles we use by 16, such as the arc's start angle or angle span, is that we later on have to call QPainter::drawArc(), whose documentation reads:

void QPainter::drawArc ( const QRectF & rectangle, int startAngle, int spanAngle ) 

Draws the arc defined by the given rectangle, startAngle and spanAngle. 

The startAngle and spanAngle must be specified in 1/16th of a degree, i.e. a full circle equals 5760 (16 * 360). Positive values for the angles mean counter-clockwise while negative values mean the clockwise direction. Zero degrees is at the 3 o'clock position.

That should explain it. I'll talk about a few other non-self-explanatory methods.

In CustomDial::setColor(), we update the color of the arc by calling QColor::setNamedColor() on our internal arcColor_ variable and passing it the RGB hex triplet we get as a QString (e.g. #FFFFFF). We also set this color directly to our private arcPen_ variable which we'll later use to draw the arc in CustomDial::paintEvent().

CustomDial::setArcWidth() is the function responsible for, as the name implies, updating the width of the arc. When the width of the arc changes, we also have to update the rectangle in which we draw the arc later on in paintEvent(). The arc's dimensions are chosen in such a way that the arc sits right on the edge of the dial. Because the arc is essentially a line with a border of arcWidth_ / 2 to its left and right, we offset the position of the arc by arcWidth_ / 2 so that it fits onto the screen perfectly.

CustomDial::updateValue() is the private slot we connected to QDial::valueChanged so we can monitor the value of the dial and update our internal variables. In it, we calculate the ratio between the dial's current value and the maximum value (e.g. 50 / 100 = 0.5) and then calculate the current angleSpan of the arc as that ratio multiplied by the maximum angle span (e.g. 0.5 * -360° = -180°).
We also store the dial value as a string so we can display it later on.

CustomDial::resizeEvent() is the method we inherited from QDial, that is called whenever the widget is forced to resize. In it, we resize and update all our internally stored QRects (for the arc, for the dial value and the dial label / title / text). I should mention that the dial text / label occupies the top half of the dial and the dial value string the bottom half.

Lastly, the most important method: CustomDial::paintEvent(). It does the actual work -- the actual painting of our widget. I added some comments to the first part, the rest should be quite easy to understand. Basically, we first make the background color ("background" property in CSS) retrievable by setting the background mode to Qt::OpaqueMode, then set the QPainter object's current brush to that used for the background (which includes the background color). After ensuring that we get nicely drawn, anti-aliased circles via QPainter::setRenderHint(QPainter::Antialiasing), we store the current pen (which holds the color from the "color" CSS property), then set the pen to a borderless one and finally draw our background circle. We then reset the current pen to the stored one and so that we can draw the dial label and dial value string with the appropriate color. Lastly, we use the stored arcPen_ to draw the arc around the dial. You already saw the definition of QPainter::drawArc() above.

And that should make your CustomDial work! Of course, as promised, you can customize the dial using stylesheets. This is the CSS for the dial I showed at the top of this post:

CustomDial {
  background-color: transparent;
  font-size: 18px;
  font-family: DIN;
  color: #27272B;
  qproperty-arcColor: #27272B;
  qproperty-arcWidth: 3; }

Thanks for reading! I hope this helped! :)

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.

Saturday, September 20, 2014

Building a text editor with PyQt




I have always enjoyed building beautiful Graphical User Interfaces (GUIs) to the back-end computations, number-crunching and algorithms of my programs. For Python, my GUI library of choice is the Python binding for Qt, PyQt. This tutorial will show you how you can use PyQt to build a simple but useful rich-text editor. The first part of the tutorial will focus on the core features and skeleton of the editor. In the second part of the tutorial we'll take care of text-formatting and in the third part we'll add some useful extensions like a find-and-replace dialog, support for tables and more.

Before we get started, two things:


Once you`re set up and ready to go, we can embark on our journey to create a totally awesome text editor.

An empty canvas

We start out with an empty canvas, a bare-minimum PyQt application:

import sys
from PyQt4 import QtGui, QtCore
from PyQt4.QtCore import Qt

class Main(QtGui.QMainWindow):

    def __init__(self, parent = None):
        QtGui.QMainWindow.__init__(self,parent)

        self.initUI()

    def initUI(self):

        # x and y coordinates on the screen, width, height
        self.setGeometry(100,100,1030,800)

        self.setWindowTitle("Writer")

def main():

    app = QtGui.QApplication(sys.argv)

    main = Main()
    main.show()

    sys.exit(app.exec_())

if __name__ == "__main__":
    main()

The first thing we need to do is import the sys module, which PyQt needs to start our application, as well as all the necessary modules from the PyQt4 package (PyQt5 if you have the newer version).

We'll call our class Main and let it inherit from PyQt's QMainWindow class. In the __init__ method, we initialize the parent class as well as all the UI settings for our application. The latter we just pack into the initUI() method. At the moment, the only settings we need are those concerning position on the screen, the size of the window and the window's title. We can set the first two using the setGeometry() method, which lets us set the x and y coordinates of the window on the screen, the width and lastly its height. We also set our application's window title using the setWindowTitle() method. For simplicity, we'll just call our text editor Writer.

Lastly, we need a main function that takes care of instantiating and displaying our window. We do so by creating a new Main object and calling its show() method.

And there was text

Now that we have a basic PyQt application up and running, we can start making it look more like a text editor:

def initToolbar(self):

  self.toolbar = self.addToolBar("Options")

  # Makes the next toolbar appear underneath this one
  self.addToolBarBreak()

def initFormatbar(self):

  self.formatbar = self.addToolBar("Format")

def initMenubar(self):

  menubar = self.menuBar()

  file = menubar.addMenu("File")
  edit = menubar.addMenu("Edit")
  view = menubar.addMenu("View")

def initUI(self):

    self.text = QtGui.QTextEdit(self)
    self.setCentralWidget(self.text)

    self.initToolbar()
    self.initFormatbar()
    self.initMenubar()

    # Initialize a statusbar for the window
    self.statusbar = self.statusBar()

    # x and y coordinates on the screen, width, height

    self.setGeometry(100,100,1030,800)

    self.setWindowTitle("Writer")

I left out everything that stayed unchanged from the previous code. As you can see in the initUI() function, we first create a QTextEdit object and set it to our window's "central widget". This makes the QTextEdit object take up the window's entire space. Next up, we need to create three more methods: initToolbar(), initFormatbar() and initMenubar(). The first two methods create toolbars that will appear at the top of our window and contain our text editor's features, such as those concerning file management (opening a file, saving a file etc.) or text-formatting. The last method, initMenubar() creates a set of drop-down menus at the top of the screen.
As of now, the methods contain only the code necessary to make them appear.

For the initToolbar() and initFormatbar() methods, this means creating a new toolbar object by calling our window's addToolBar() method and passing it the name of the toolbar we're creating. Note that in the initToolbar() method, we need to also call the addToolBarBreak() method. This makes the next toolbar, the format bar, appear underneath this toolbar. In case of the menu bar, we call the window's menuBar() method and add three menus to it, "File", "Edit" and "View". We'll populate all of these toolbars and menus in a bit.

Lastly, in the initUI() method, we also create a status bar object. This will create a status bar at the bottom of our window.

An icon is worth a thousand words

Before we start injecting some life into our text editor, we're going to need some icons for its various features. If you had a look at the GitHub repository I linked to at the top of this post, you might have noticed that it contains a folder full of icons. I recommend that you download the repo (if you haven't yet) and copy the icons folder into your working directory. The icons are from iconmonstr, completely free and require no attribution.

File management

Now that we have a basic text editor skeleton in place, we can add some meat to the bone. We'll start with the functions concerning file management.

__init__():

def __init__(self, parent = None):
    QtGui.QMainWindow.__init__(self,parent)

    self.filename = ""

    self.initUI()

initToolbar():

def initToolbar(self):

  self.newAction = QtGui.QAction(QtGui.QIcon("icons/new.png"),"New",self)
  self.newAction.setStatusTip("Create a new document from scratch.")
  self.newAction.setShortcut("Ctrl+N")
  self.newAction.triggered.connect(self.new)

  self.openAction = QtGui.QAction(QtGui.QIcon("icons/open.png"),"Open file",self)
  self.openAction.setStatusTip("Open existing document")
  self.openAction.setShortcut("Ctrl+O")
  self.openAction.triggered.connect(self.open)

  self.saveAction = QtGui.QAction(QtGui.QIcon("icons/save.png"),"Save",self)
  self.saveAction.setStatusTip("Save document")
  self.saveAction.setShortcut("Ctrl+S")
  self.saveAction.triggered.connect(self.save)

  self.toolbar = self.addToolBar("Options")

  self.toolbar.addAction(self.newAction)
  self.toolbar.addAction(self.openAction)
  self.toolbar.addAction(self.saveAction)

  self.toolbar.addSeparator()

  # Makes the next toolbar appear underneath this one
  self.addToolBarBreak()

initMenubar():

file.addAction(self.newAction)
file.addAction(self.openAction)
file.addAction(self.saveAction)

Below the initUI() method:

def new(self):

    spawn = Main(self)
    spawn.show()

def open(self):

    # Get filename and show only .writer files
    self.filename = QtGui.QFileDialog.getOpenFileName(self, 'Open File',".","(*.writer)")

    if self.filename:
        with open(self.filename,"rt") as file:
            self.text.setText(file.read())

def save(self):

    # Only open dialog if there is no filename yet
    if not self.filename:
        self.filename = QtGui.QFileDialog.getSaveFileName(self, 'Save File')

    # Append extension if not there yet
    if not self.filename.endswith(".writer"):
        self.filename += ".writer"

    # We just store the contents of the text file along with the
    # format in html, which Qt does in a very nice way for us
    with open(self.filename,"wt") as file:
        file.write(self.text.toHtml())

As you might have noticed, all the actions we'll be creating for our text editor follow the same code pattern:
  • Create a QAction and pass it an icon and a name
  • Create a status tip, which will display a message in the status bar (and a tool tip if you hover the action)
  • Create a shortcut
  • Connect the QAction's triggered signal to a slot function
Once you've done this for the "new", "open" and "save" actions, you can add them to the toolbar, using the toolbar's addAction() method. Make sure you also call the addSeparator() method, which inserts a separator line between toolbar actions. Because these three actions are responsible for file management, we want to add a separator here. Also, we want to add these three actions to the "file" menu, so in the initMenubar() method, we add the three actions to the appropriate menu.
Next up, we need to create the three slot functions that we connected to our action in the initToolbar() method. The new() method is very easy, all it does is create a new instance of our window and call its show() method to display it.

Before we create the last two methods, let me mention that we'll use ".writer" as our text files' extensions. Now, for open(), we need to open PyQt's getOpenFileName dialog. This opens a file dialog which returns the name of the file the user opens. We also pass this method a title for the file dialog, in this case "Open File", the directory to open initially, "." (current directory) and finally a file filter, so that we only show ".writer" files. If the user didn't close or cancel the file dialog, we open the file and set its text to our text editor's current text.

Lastly, the save() method. We first check whether the current file already has a file name associated with it, either because it was opened with the open() method or already saved before, in case of a new text file. If this isn't the case, we open a getSaveFileName dialog which will again return a filename for us, given the user doesn't cancel or close the file dialog. Once we have a file name, we need to check whether the user already entered our extension when saving the file. If not, we add the extension. Finally, we save our file in HTML format (which stores style as well), using the QTextEdit's toHTML() method.

Printing

Next, we'll create some actions for printing and preview-ing our document.

initToolbar():

self.printAction = QtGui.QAction(QtGui.QIcon("icons/print.png"),"Print document",self)
self.printAction.setStatusTip("Print document")
self.printAction.setShortcut("Ctrl+P")
self.printAction.triggered.connect(self.print)

self.previewAction = QtGui.QAction(QtGui.QIcon("icons/preview.png"),"Page view",self)
self.previewAction.setStatusTip("Preview page before printing")
self.previewAction.setShortcut("Ctrl+Shift+P")
self.previewAction.triggered.connect(self.preview)

Further below:

self.toolbar.addAction(self.printAction)
self.toolbar.addAction(self.previewAction)

self.toolbar.addSeparator()

initMenubar():

file.addAction(self.printAction)
file.addAction(self.previewAction)

Below the initUI() method:

def preview(self):

    # Open preview dialog
    preview = QtGui.QPrintPreviewDialog()

    # If a print is requested, open print dialog
    preview.paintRequested.connect(lambda p: self.text.print_(p))

    preview.exec_()

def print(self):

    # Open printing dialog
    dialog = QtGui.QPrintDialog()

    if dialog.exec_() == QtGui.QDialog.Accepted:
        self.text.document().print_(dialog.printer())

We create the actions following the same scheme as we did for the file management actions and add them to our toolbar as well as the "file" menu. The preview() method opens a QPrintPreviewDialog and optionally prints the document, if the user wishes to do so. The print() method opens a QPrintDialog and prints the document if the user accepts.

Copy and paste - undo and redo

These actions will let us copy, cut and paste text as well as undo/redo actions:

initToolbar():

self.cutAction = QtGui.QAction(QtGui.QIcon("icons/cut.png"),"Cut to clipboard",self)
self.cutAction.setStatusTip("Delete and copy text to clipboard")
self.cutAction.setShortcut("Ctrl+X")
self.cutAction.triggered.connect(self.text.cut)

self.copyAction = QtGui.QAction(QtGui.QIcon("icons/copy.png"),"Copy to clipboard",self)
self.copyAction.setStatusTip("Copy text to clipboard")
self.copyAction.setShortcut("Ctrl+C")
self.copyAction.triggered.connect(self.text.copy)

self.pasteAction = QtGui.QAction(QtGui.QIcon("icons/paste.png"),"Paste from clipboard",self)
self.pasteAction.setStatusTip("Paste text from clipboard")
self.pasteAction.setShortcut("Ctrl+V")
self.pasteAction.triggered.connect(self.text.paste)

self.undoAction = QtGui.QAction(QtGui.QIcon("icons/undo.png"),"Undo last action",self)
self.undoAction.setStatusTip("Undo last action")
self.undoAction.setShortcut("Ctrl+Z")
self.undoAction.triggered.connect(self.text.undo)

self.redoAction = QtGui.QAction(QtGui.QIcon("icons/redo.png"),"Redo last undone thing",self)
self.redoAction.setStatusTip("Redo last undone thing")
self.redoAction.setShortcut("Ctrl+Y")
self.redoAction.triggered.connect(self.text.redo)

Further below:

self.toolbar.addAction(self.cutAction)
self.toolbar.addAction(self.copyAction)
self.toolbar.addAction(self.pasteAction)
self.toolbar.addAction(self.undoAction)
self.toolbar.addAction(self.redoAction)

self.toolbar.addSeparator()

initMenubar():

edit.addAction(self.undoAction)
edit.addAction(self.redoAction)
edit.addAction(self.cutAction)
edit.addAction(self.copyAction)
edit.addAction(self.pasteAction)

As you can see, we don't need any separate slot functions for these actions, as our QTextEdit object already has very handy methods for all of these actions. Note that in the initMenubar() method, we add these actions to the "Edit" menu and not the "File" menu.

Lists

Finally, we'll add two actions for inserting lists. One for numbered lists and one for bulleted lists:

initToolbar():

bulletAction = QtGui.QAction(QtGui.QIcon("icons/bullet.png"),"Insert bullet List",self)
bulletAction.setStatusTip("Insert bullet list")
bulletAction.setShortcut("Ctrl+Shift+B")
bulletAction.triggered.connect(self.bulletList)

numberedAction = QtGui.QAction(QtGui.QIcon("icons/number.png"),"Insert numbered List",self)
numberedAction.setStatusTip("Insert numbered list")
numberedAction.setShortcut("Ctrl+Shift+L")
numberedAction.triggered.connect(self.numberList)

Further below:

self.toolbar.addAction(bulletAction)
self.toolbar.addAction(numberedAction)

Below the initUI() method:

    def bulletList(self):

        cursor = self.text.textCursor()

        # Insert bulleted list
        cursor.insertList(QtGui.QTextListFormat.ListDisc)

    def numberList(self):

        cursor = self.text.textCursor()

        # Insert list with numbers
        cursor.insertList(QtGui.QTextListFormat.ListDecimal)

As you can see, we don't make these actions class members because we don't need to access them anywhere else in our code, we only need to create and use them within the scope of initToolbar().
Concerning the slot functions, we retrieve our QTextEdit's QTextCursor, which has a lot of very useful methods, such insertList(), which, well, does what it's supposed to do. In case of bulletList(), we insert a list with the QTextListFormat set to ListDisc. For numberList(), we insert a list with ListDecimal format.

Final changes

To finish off this part of Building a text editor with PyQt, let's make some final changes in the initUI() method:

self.text.setTabStopWidth(33)


Because PyQt's tab width is very strange, I recommend you set the QTextEdit's "tab stop width" to 33 pixels, which is around 8 spaces.

self.setWindowIcon(QtGui.QIcon("icons/icon.png"))


Now that we have icons, we can add an icon for our window.

self.text.cursorPositionChanged.connect(self.cursorPosition


By connecting our QTextEdit's cursorPositionChanged signal to a function, we can display the cursor's current line and column number in the status bar. Here is the corresponding slot function, below initUI():

def cursorPosition(self):

    cursor = self.text.textCursor()

    # Mortals like 1-indexed things
    line = cursor.blockNumber() + 1
    col = cursor.columnNumber()

    self.statusbar.showMessage("Line: {} | Column: {}".format(line,col))

We first retrieve our QTextEdit's QTextCursor, then grab the cursor's column and block/line number and finally display these numbers in the status bar.

That'll be it for this part of the series. See you soon.

Originally published on BinPress.