Custom Widgets
A custom widget in Qt is a QWidget subclass that draws its own content (the paintEvent), handles its own input (the mouse and the key events), and negotiates its own size (the sizeHint, the minimumSizeHint). The mechanism is the virtual-method override: the Qt's virtual functions are the Python methods with the same name (the paintEvent, the mousePressEvent, the sizeHint), and the subclass implements the ones it needs, calling the super() form where the base behavior is wanted. This chapter covers the subclass shape (the __init__, the paintEvent), the size negotiation (the sizeHint, the minimumSizeHint), the input handling (the mousePressEvent, the keyPressEvent), the widget's properties (the setEnabled, the focus), and the complete example - the custom dial widget.
The Subclass Shape
from PyQt5.QtWidgets import QWidget, QApplication
from PyQt5.QtGui import QPainter, QColor, QPen
from PyQt5.QtCore import Qt, QSize, pyqtSignal
import sys, math
# the custom widget: the QWidget subclass - the paintEvent
# (the draw), the sizeHint (the requested size), the input
# (the mouse / the key), and the super().__init__ first
class MyWidget(QWidget):
valueChanged = pyqtSignal(float) # the custom signal
def __init__(self, parent=None):
super().__init__(parent) # the base init FIRST
self._value = 0.0
self.setFocusPolicy(Qt.StrongFocus) # the focusable
def sizeHint(self):
# the requested size (the layout asks, the widget
# answers, the layout decides the actual)
return QSize(200, 60)
def minimumSizeHint(self):
return QSize(80, 30)
def paintEvent(self, event):
# the pure function of the state (the _value):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
w, h = self.width(), self.height()
p.fillRect(0, 0, w, h, QColor(245, 245, 245))
p.setPen(QPen(QColor(0, 80, 160), 4))
x = self._value * w
p.drawLine(int(x), 0, int(x), h)
p.end()
def setValue(self, v):
self._value = max(0.0, min(1.0, v))
self.update() # the repaint
self.valueChanged.emit(self._value) # the signal
def value(self):
return self._value
# the discipline: the super().__init__(parent) is the first
# line (the base widget is created); the sizeHint is the
# request; the paintEvent is the pure function of the state;
# the setValue is the state + the update + the signal (the
# the public API the app uses, the the render is the detail)
The custom widget is a QWidget subclass, and the mechanism is the virtual-method override: the Qt's virtual functions are the Python methods with the same name (the paintEvent, the mousePressEvent, the sizeHint), and the subclass implements the ones it needs, calling the super() where the base behavior is wanted. The __init__ calls the super().__init__(parent) first (the base widget created), then sets the widget's state (the setFocusPolicy, the initial value). The sizeHint is the requested size (the layout asks, the widget answers, the layout decides the actual - the minimumSizeHint is the bound), and the paintEvent is the pure function of the state (the QPainter, the render from the _value). The public API is the setValue/value (the state accessors, the update the repaint, the valueChanged the signal the app connects) - the widget is a component with a state and a view of it, and the setValue is the interface the app drives. The custom signal (pyqtSignal on the widget) is the widget's own event, the same system the signals chapter covered - the custom widget is a QObject (the QWidget is a QObject), and the valueChanged is its signal.
The Input Handling
from PyQt5.QtGui import QMouseEvent, QKeyEvent
from PyQt5.QtCore import Qt
# the input events: the widget's virtual methods (the override)
# the mouse: the press, the move, the release, the enter,
# the leave; the key: the press, the release
class InteractiveWidget(QWidget):
def mousePressEvent(self, event: QMouseEvent):
# event.pos() is the position in the WIDGET
self._dragging = True
self._set_value_from_x(event.pos().x())
event.accept() # the event handled (the default)
# the super().mousePressEvent(event) is the base
# (the no-op for the plain widget) - the call is
# optional for the override that fully handles
def mouseMoveEvent(self, event: QMouseEvent):
if self._dragging:
self._set_value_from_x(event.pos().x())
def mouseReleaseEvent(self, event: QMouseEvent):
self._dragging = False
event.accept()
def keyPressEvent(self, event: QKeyEvent):
# the key: the event.key() is the Qt.Key
if event.key() == Qt.Key_Left:
self.setValue(self.value() - 0.05)
event.accept()
elif event.key() == Qt.Key_Right:
self.setValue(self.value() + 0.05)
event.accept()
else:
super().keyPressEvent(event) # the base (the
# beep / the propagate) for the unhandled key
def _set_value_from_x(self, x):
self.setValue(x / self.width())
# the focus: the setFocusPolicy (the StrongFocus - the
# Tab + the click focus) - the key events reach the widget
# only when it has the focus (the focus policy is the opt-in)
# the hasFocus() in the paintEvent: the focus ring (the
# the accessibility chapter's rule, the focus drawn)
# if self.hasFocus(): the draw the ring
The input events are the widget's virtual methods: the mousePressEvent, the mouseMoveEvent, the mouseReleaseEvent (the mouse - the event.pos() the position in the widget, the event.accept() the handled), and the keyPressEvent, the keyReleaseEvent (the key - the event.key() the Qt.Key, the super() call for the unhandled key). The pattern is the same as the other toolkits' event handlers: the handler reads the event, updates the state (the setValue), and the accept (the handled, the no propagate) or the super() call (the base, the propagate). The focus is the setFocusPolicy (the StrongFocus - the Tab plus the click focus) - the key events reach the widget only when it has the focus, and the hasFocus() in the paintEvent is the focus ring (the accessibility chapter's rule, the focus drawn by the custom widget). The input is the state's driver: the mouse and the key update the state, the setValue does the update and the valueChanged, and the paintEvent renders - the same two-step (the state change, the repaint) the drawing chapters use, on the widget's own surface.
The Complete Example: The Dial
from PyQt5.QtWidgets import QWidget, QApplication, QVBoxLayout
from PyQt5.QtGui import QPainter, QPen, QColor, QBrush, QMouseEvent
from PyQt5.QtCore import Qt, QSize, pyqtSignal, QRectF
import sys, math
class Dial(QWidget):
"""The custom dial: the value 0..360, the draggable."""
valueChanged = pyqtSignal(int)
def __init__(self, parent=None):
super().__init__(parent)
self._angle = 0
self.setFixedSize(160, 160)
self.setFocusPolicy(Qt.StrongFocus)
self.setCursor(Qt.PointingHandCursor)
def sizeHint(self):
return QSize(160, 160)
def paintEvent(self, event):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
c = self.rect().center()
r = min(self.width(), self.height()) // 2 - 8
# the face
p.setBrush(QBrush(QColor(250, 250, 250)))
p.setPen(QPen(QColor(60, 60, 60), 2))
p.drawEllipse(c, r, r)
# the ticks (the 12)
for i in range(12):
a = math.radians(i * 30)
x1 = c.x() + (r - 6) * math.cos(a)
y1 = c.y() - (r - 6) * math.sin(a)
x2 = c.x() + r * math.cos(a)
y2 = c.y() - r * math.sin(a)
p.drawLine(int(x1), int(y1), int(x2), int(y2))
# the pointer (the the angle)
a = math.radians(self._angle)
p.setPen(QPen(QColor(200, 30, 30), 4))
p.drawLine(c.x(), c.y(),
int(c.x() + (r - 10) * math.cos(a)),
int(c.y() - (r - 10) * math.sin(a)))
if self.hasFocus():
p.setPen(QPen(QColor(0, 0, 200), 2))
p.drawEllipse(c, r + 4, r + 4) # the focus ring
p.end()
def mousePressEvent(self, event: QMouseEvent):
self._set_from_event(event)
def mouseMoveEvent(self, event: QMouseEvent):
if event.buttons() & Qt.LeftButton:
self._set_from_event(event)
def _set_from_event(self, event: QMouseEvent):
c = self.rect().center()
dx = event.pos().x() - c.x()
dy = c.y() - event.pos().y()
angle = int(math.degrees(math.atan2(dy, dx))) % 360
self.setValue(angle)
def setValue(self, angle):
self._angle = angle % 360
self.update()
self.valueChanged.emit(self._angle)
def value(self):
return self._angle
def main():
app = QApplication(sys.argv)
win = QWidget()
lay = QVBoxLayout(win)
dial = Dial()
label = None
from PyQt5.QtWidgets import QLabel
label = QLabel("angle: 0")
dial.valueChanged.connect(
lambda a: label.setText("angle: %d" % a))
lay.addWidget(dial)
lay.addWidget(label)
win.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
The complete example is the custom widget as a component: the Dial is a QWidget subclass with the valueChanged signal (the custom, the int angle), the sizeHint (the 160x160), the paintEvent that renders the face, the ticks, and the pointer as a pure function of the _angle (the hasFocus the focus ring), the mousePressEvent/mouseMoveEvent that set the angle from the pointer position (the atan2 from the center), and the setValue/value as the public API (the update, the valueChanged). The driver - the window with the QVBoxLayout, the dial, the QLabel connected to the valueChanged - shows the widget as a self-contained component with a clean API: the app reads the value through the signal, the widget renders it, and the render is the pure function of the state the drawing chapter's discipline requires. The custom widget is where the toolkit's vocabulary ends and the app's own controls begin, and the structure - the paintEvent, the sizeHint, the input events, the setValue/value API, the valueChanged signal - is the pattern for every one of them.
The Working Rules
- The
super().__init__(parent)is the first line of the__init__. The base widget is created before the subclass's state; thesetFocusPolicy, thesetFixedSize, and the initial value follow. - The
paintEventis a pure function of the state. It reads the widget's data and draws it; the same state produces the same picture. Theupdateis the repaint, and the render follows the allocation (theself.width()/height()), not thesizeHint. - The
sizeHintis the request, theminimumSizeHintis the bound. The layout asks thesizeHint, decides the actual; thesetFixedSize(the dial) is the fixed, thesizeHintthe flexible. The render uses the allocation, thesizeHintis the layout's input. - The input events update the state and the
setValuedoes theupdate+ the signal. ThemousePressEvent/keyPressEventread the event, call thesetValue(theupdate, thevalueChanged), and theaccept(the handled) or thesuper()(the propagate). ThesetFocusPolicyis the key-input opt-in. - The public API is the state accessors, the
valueChangedis the signal. ThesetValue/valueare the interface the app drives, thevalueChangedis the event the app connects, and the render is the internal detail the API drives - the widget is a component with a state and a view of it.
Summary
The custom widget is a QWidget subclass using the virtual-method override: the paintEvent is the paint handler (the QPainter, the pure function of the state), the sizeHint/minimumSizeHint are the size negotiation (the request, the bound - the render follows the allocation), and the input events (mousePressEvent, the keyPressEvent) are the state's drivers (the event.pos(), the event.key(), the accept or the super()). The setup is the super().__init__(parent) first, the setFocusPolicy for the key input, the setFixedSize or the sizeHint for the size, and the hasFocus focus ring in the paintEvent. The public API is the setValue/value (the update, the valueChanged signal the app connects), and the dial example is the pattern as a component - the face, the ticks, the pointer rendered from the _angle, the mouse that sets the angle from the atan2, the valueChanged the driver reads. The discipline is the one the drawing chapters established: the state is the source, the render is the derived view, the update is the repaint, and the public API is the state accessors the app drives - the custom widget is the toolkit's vocabulary extended by the app's own controls.