Custom Widgets & QPainter

Drawing custom widgets with QPainter — shapes, gradients, transformations, animation, and building a real-time gauge.

6 min read
53681 chars

Why Custom Widgets?

Standard Qt widgets (buttons, labels, sliders) cover most cases. But embedded HMI, dashboards, and instruments need custom drawing — gauges, waveforms, status indicators, and more.

Qt uses QPainter to draw anything on screen. You override paintEvent() in your QWidget subclass and draw everything you need.


QPainter Basics

#include <QWidget>
#include <QPainter>

class DrawWidget : public QWidget {
    Q_OBJECT
protected:
    void paintEvent(QPaintEvent *) override {
        QPainter p(this);
        p.setRenderHint(QPainter::Antialiasing);  // smooth edges

        // Pen controls outlines
        p.setPen(QPen(QColor("#89b4fa"), 2, Qt::SolidLine));

        // Brush controls fill
        p.setBrush(QBrush(QColor("#313244")));

        // Draw shapes
        p.drawRect(10, 10, 100, 60);
        p.drawEllipse(150, 10, 80, 80);
        p.drawLine(10, 100, 200, 100);
        p.drawRoundedRect(250, 10, 100, 60, 12, 12);

        // Text
        p.setPen(Qt::white);
        p.setFont(QFont("Fira Code", 12));
        p.drawText(10, 150, "Hello Qt Painter!");
    }
};

Coordinate System & Transforms

void paintEvent(QPaintEvent *) override {
    QPainter p(this);
    p.setRenderHint(QPainter::Antialiasing);

    // Save/restore state
    p.save();

    // Move origin to center
    p.translate(width() / 2.0, height() / 2.0);

    // Rotate 45 degrees
    p.rotate(45.0);

    // Scale by 1.5x
    p.scale(1.5, 1.5);

    // Draw relative to new origin
    p.drawRect(-30, -30, 60, 60);

    p.restore();  // back to original transform

    // Draw at original coordinate
    p.drawEllipse(0, 0, 10, 10);
}

Gradients

// Linear gradient
QLinearGradient grad(0, 0, 0, height());
grad.setColorAt(0.0, QColor("#89b4fa"));   // top: blue
grad.setColorAt(1.0, QColor("#313244"));   // bottom: dark

p.setBrush(grad);
p.drawRect(rect());

// Radial gradient
QRadialGradient radial(center, radius);
radial.setColorAt(0.0, Qt::white);
radial.setColorAt(1.0, Qt::transparent);
p.setBrush(radial);
p.drawEllipse(center, radius, radius);

// Conical gradient (for gauges)
QConicalGradient conic(center, startAngle);
conic.setColorAt(0.0, QColor("#a6e3a1"));  // green
conic.setColorAt(0.5, QColor("#fab387"));  // orange
conic.setColorAt(1.0, QColor("#f38ba8"));  // red

QPainterPath — Complex Shapes

#include <QPainterPath>

QPainterPath path;
path.moveTo(0, 50);
path.lineTo(50, 0);
path.lineTo(100, 50);
path.quadTo(100, 100, 50, 100);   // quadratic bezier
path.cubicTo(0, 100, 0, 75, 0, 50);  // cubic bezier
path.closeSubpath();

p.fillPath(path, QColor("#89b4fa"));
p.strokePath(path, QPen(Qt::white, 2));

// Arc for gauge needle
QPainterPath arc;
arc.arcMoveTo(-80, -80, 160, 160, 180);  // start at 180°
arc.arcTo(-80, -80, 160, 160, 180, -value * 1.8);  // sweep
p.strokePath(arc, QPen(QColor("#a6e3a1"), 8, Qt::SolidLine, Qt::RoundCap));

Visual: Coordinate System

(0,0) ────────────────────────► X+
  │
  │   ┌──────────────────────┐
  │   │  Widget rect()       │
  │   │                      │
  │   │  translate(cx, cy)   │
  │   │       ↓              │
  │   │  New origin at (cx,cy)│
  │   └──────────────────────┘
  ▼
  Y+

Lab 1 — Circular Gauge Widget

class GaugeWidget : public QWidget {
    Q_OBJECT
    Q_PROPERTY(float value READ value WRITE setValue NOTIFY valueChanged)

    float   m_value   = 0.0f;
    float   m_min     = 0.0f;
    float   m_max     = 100.0f;
    QString m_unit    = "%";
    QString m_label   = "Level";

public:
    explicit GaugeWidget(QWidget *p = nullptr) : QWidget(p) {
        setMinimumSize(200, 200);
    }

    float value() const { return m_value; }
    void  setRange(float min, float max) { m_min=min; m_max=max; }
    void  setUnit(const QString &u)  { m_unit = u;   update(); }
    void  setLabel(const QString &l) { m_label = l;  update(); }

public slots:
    void setValue(float v) {
        v = qBound(m_min, v, m_max);
        if (!qFuzzyCompare(m_value, v)) {
            m_value = v;
            emit valueChanged(v);
            update();
        }
    }

signals:
    void valueChanged(float v);

protected:
    void paintEvent(QPaintEvent *) override {
        QPainter p(this);
        p.setRenderHint(QPainter::Antialiasing);

        const QRectF r = rect().adjusted(10, 10, -10, -10);
        const QPointF c = r.center();
        const float rad = qMin(r.width(), r.height()) / 2.0f - 10;

        // Background circle
        p.setBrush(QColor("#1e1e2e"));
        p.setPen(QPen(QColor("#45475a"), 2));
        p.drawEllipse(c, rad, rad);

        // Arc track
        const float startAngle = 225 * 16;   // 225° in 1/16° units
        const float spanAngle  = -270 * 16;  // sweep 270°
        p.setPen(QPen(QColor("#313244"), 12, Qt::SolidLine, Qt::RoundCap));
        p.drawArc(QRectF(c.x()-rad+10, c.y()-rad+10, (rad-10)*2, (rad-10)*2),
                  startAngle, spanAngle);

        // Value arc (colored)
        float pct = (m_value - m_min) / (m_max - m_min);
        QColor arcColor = pct < 0.5f ? QColor("#a6e3a1")
                        : pct < 0.8f ? QColor("#fab387")
                                     : QColor("#f38ba8");
        p.setPen(QPen(arcColor, 12, Qt::SolidLine, Qt::RoundCap));
        p.drawArc(QRectF(c.x()-rad+10, c.y()-rad+10, (rad-10)*2, (rad-10)*2),
                  startAngle, static_cast<int>(-270 * 16 * pct));

        // Value text
        p.setPen(QColor("#cdd6f4"));
        p.setFont(QFont("Fira Code", 18, QFont::Bold));
        p.drawText(r, Qt::AlignCenter,
                   QString::number(m_value, 'f', 1) + " " + m_unit);

        // Label
        p.setFont(QFont("Fira Code", 10));
        p.setPen(QColor("#6c7086"));
        QRectF labelRect = r;
        labelRect.setTop(c.y() + rad * 0.4f);
        p.drawText(labelRect, Qt::AlignHCenter | Qt::AlignTop, m_label);

        // Tick marks
        p.setPen(QPen(QColor("#45475a"), 1));
        for (int i = 0; i <= 10; ++i) {
            float angle = qDegreesToRadians(225.0f - i * 27.0f);
            float len   = (i % 5 == 0) ? 10.0f : 5.0f;
            float x1    = c.x() + (rad - 5) * std::cos(angle);
            float y1    = c.y() - (rad - 5) * std::sin(angle);
            float x2    = c.x() + (rad - 5 - len) * std::cos(angle);
            float y2    = c.y() - (rad - 5 - len) * std::sin(angle);
            p.drawLine(QPointF(x1, y1), QPointF(x2, y2));
        }
    }
};

// Usage
GaugeWidget *temp = new GaugeWidget;
temp->setRange(0, 100);
temp->setUnit("°C");
temp->setLabel("Temperature");

QTimer *t = new QTimer;
QObject::connect(t, &QTimer::timeout, [temp](){
    temp->setValue(20.0f + (rand() % 600) / 10.0f);
});
t->start(500);

Lab 2 — Waveform Display

class WaveformWidget : public QWidget {
    Q_OBJECT
    QVector<float> m_samples;
    float          m_min = -1.0f, m_max = 1.0f;
    QColor         m_lineColor = QColor("#89b4fa");

public:
    explicit WaveformWidget(int bufSize = 200, QWidget *p = nullptr)
        : QWidget(p), m_samples(bufSize, 0.0f)
    {
        setMinimumSize(400, 150);
        setBackground(QColor("#1e1e2e"));
    }

public slots:
    void addSample(float value) {
        m_samples.removeFirst();
        m_samples.append(value);
        update();
    }

protected:
    void paintEvent(QPaintEvent *) override {
        QPainter p(this);
        p.setRenderHint(QPainter::Antialiasing);

        // Background
        p.fillRect(rect(), QColor("#1e1e2e"));

        // Grid
        p.setPen(QPen(QColor("#313244"), 1, Qt::DotLine));
        p.drawLine(0, height()/2, width(), height()/2);  // zero line

        // Waveform
        if (m_samples.isEmpty()) return;
        QPainterPath path;
        float xStep = static_cast<float>(width()) / (m_samples.size() - 1);
        float yScale = height() / (m_max - m_min);

        for (int i = 0; i < m_samples.size(); ++i) {
            float x = i * xStep;
            float y = height() - (m_samples[i] - m_min) * yScale;
            if (i == 0) path.moveTo(x, y);
            else        path.lineTo(x, y);
        }
        p.setPen(QPen(m_lineColor, 2));
        p.drawPath(path);

        // Fill below
        QPainterPath fill = path;
        fill.lineTo(width(), height());
        fill.lineTo(0, height());
        fill.closeSubpath();
        QLinearGradient grad(0, 0, 0, height());
        grad.setColorAt(0.0, QColor(137, 180, 250, 80));
        grad.setColorAt(1.0, QColor(137, 180, 250, 0));
        p.fillPath(fill, grad);
    }
};

Interview Questions

Q1: When is paintEvent called and can you call update() from paintEvent?

paintEvent is called by the Qt event system whenever the widget needs repainting — on show, resize, or when update() or repaint() is called. Never call update() from inside paintEvent — it causes infinite loops.

Q2: What is the difference between update() and repaint()?

update() schedules a repaint on the next event loop iteration (batched, efficient). repaint() forces an immediate repaint in the current call stack (use only when absolutely necessary, e.g., progress updates during long operations).

Q3: Why use QPainterPath instead of individual draw calls?

A QPainterPath describes complex shapes (curves, arcs, composite paths) as a single unit. You can fill, stroke, clip, or hit-test with it. More efficient than many individual draw calls for complex shapes.

Q4: How do you implement double-buffering in Qt?

Qt automatically double-buffers widgets on most platforms. For explicit off-screen rendering, draw to a QPixmap or QImage first, then draw that to the widget in paintEvent. This eliminates flicker.

Q5: How do you make a custom widget accessible (e.g., for screen readers)?

Override accessibleName(), accessibleDescription(), and implement QAccessibleWidget. Set setAccessibleName() and setAccessibleDescription() on the widget. Register with QAccessible::installFactory for full custom accessibility.


Application: 4-Channel Dashboard

class MultiGaugeDashboard : public QWidget {
    Q_OBJECT
public:
    MultiGaugeDashboard(QWidget *p = nullptr) : QWidget(p) {
        setWindowTitle("Sensor Dashboard");
        resize(800, 500);

        auto *grid = new QGridLayout(this);
        setStyleSheet("background:#1e1e2e;");

        // Create 4 gauges
        m_gauges["temp"]  = makeGauge(0, 100,  "°C",  "Temperature");
        m_gauges["volt"]  = makeGauge(0, 5,    "V",   "Voltage");
        m_gauges["humid"] = makeGauge(0, 100,  "%RH", "Humidity");
        m_gauges["pres"]  = makeGauge(900,1100,"hPa", "Pressure");

        grid->addWidget(m_gauges["temp"],  0, 0);
        grid->addWidget(m_gauges["volt"],  0, 1);
        grid->addWidget(m_gauges["humid"], 1, 0);
        grid->addWidget(m_gauges["pres"],  1, 1);

        // Simulate live data
        auto *timer = new QTimer(this);
        connect(timer, &QTimer::timeout, this, &MultiGaugeDashboard::simulate);
        timer->start(300);
    }

private slots:
    void simulate() {
        m_gauges["temp"] ->setValue(20 + (rand() % 600) / 10.0f);
        m_gauges["volt"] ->setValue(3.0f + (rand() % 20) / 10.0f);
        m_gauges["humid"]->setValue(40 + rand() % 50);
        m_gauges["pres"] ->setValue(980 + rand() % 60);
    }

private:
    GaugeWidget *makeGauge(float min, float max,
                           const QString &unit, const QString &label)
    {
        auto *g = new GaugeWidget(this);
        g->setRange(min, max);
        g->setUnit(unit);
        g->setLabel(label);
        g->setValue((min + max) / 2.0f);
        return g;
    }

    QHash<QString, GaugeWidget*> m_gauges;
};

References

ResourceLink
Qt Docs: QPainterhttps://doc.qt.io/qt-6/qpainter.html
Qt Docs: Paint Systemhttps://doc.qt.io/qt-6/paintsystem.html
Qt Docs: QPainterPathhttps://doc.qt.io/qt-6/qpainterpath.html
Qt Docs: Custom Widgetshttps://doc.qt.io/qt-6/widget-classes.html

Next tutorial → Qt Serial Port