Skillversitas

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; the setFocusPolicy, the setFixedSize, and the initial value follow.
  • The paintEvent is a pure function of the state. It reads the widget's data and draws it; the same state produces the same picture. The update is the repaint, and the render follows the allocation (the self.width()/height()), not the sizeHint.
  • The sizeHint is the request, the minimumSizeHint is the bound. The layout asks the sizeHint, decides the actual; the setFixedSize (the dial) is the fixed, the sizeHint the flexible. The render uses the allocation, the sizeHint is the layout's input.
  • The input events update the state and the setValue does the update + the signal. The mousePressEvent/keyPressEvent read the event, call the setValue (the update, the valueChanged), and the accept (the handled) or the super() (the propagate). The setFocusPolicy is the key-input opt-in.
  • The public API is the state accessors, the valueChanged is the signal. The setValue/value are the interface the app drives, the valueChanged is 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.