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?
paintEventis called by the Qt event system whenever the widget needs repainting — on show, resize, or whenupdate()orrepaint()is called. Never callupdate()from insidepaintEvent— 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
QPainterPathdescribes 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
QPixmaporQImagefirst, then draw that to the widget inpaintEvent. This eliminates flicker.
Q5: How do you make a custom widget accessible (e.g., for screen readers)?
Override
accessibleName(),accessibleDescription(), and implementQAccessibleWidget. SetsetAccessibleName()andsetAccessibleDescription()on the widget. Register withQAccessible::installFactoryfor 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
| Resource | Link |
|---|---|
| Qt Docs: QPainter | https://doc.qt.io/qt-6/qpainter.html |
| Qt Docs: Paint System | https://doc.qt.io/qt-6/paintsystem.html |
| Qt Docs: QPainterPath | https://doc.qt.io/qt-6/qpainterpath.html |
| Qt Docs: Custom Widgets | https://doc.qt.io/qt-6/widget-classes.html |
Next tutorial → Qt Serial Port