Lock Widget in PySide6 - A Lesson on Event Filters

24th of March 2025

In this short tutorial, we will look at how to create a nice interactible lock widget in Qt/PySide6.

note

This tutorial was created and tested on PySide version 6.8.1.

At the end, you should have something like this:

End result for this lock widget tutorial.

Icons

Let's start with preparing necessary data for this widget, the icons - we will need icons for these following states:

Unlocked lock icon. unlocked state
Unlocked lock icon. locked state
Unlocked lock icon. unlocked hovered state
Unlocked lock icon. locked hovered state

Optionally:

Unlocked lock icon. force locked state
Unlocked lock icon. force unlocked state

Icon Loading

For simplicity's sake, I've put the icons next to the widget in the same package and I create them at runtime so that we do not require initialized QApplication.

To create/load them, let's make a simple helper function:

py
import os from PySide6.QtGui import QIcon def _createQIcon(filename): # Assuming the icons are located next to the lock widget module # whose path is contained in `__file__` variable, we can obtain # their parent directory using os.path.dirname. dirpath = os.path.dirname(__file__) filepath = os.path.join(dirpath, filename) # Create icon's filepath. return QIcon(filepath) # Load the icon.

note

You may want to setup your icon creation differently, especially for larger applications using some form of icon management system with caching or similar.

Lock State

Let's define possible lock states of the widget using Python's Enum.

py
from enum import Enum class LOCK_STATE(Enum): unlocked = 0 locked = 1 forceLocked = 2

note

I have yet to encounter need for "force unlocked" state so we omit it in this tutorial and leave it up to the reader to implement his own in case of need.

Class Definition

We will be subclassing QPushButton in our QLockWidget.

py
class QLockWidget(QPushButton): stateChanged = Signal(LOCK_STATE) def __init__(self, startState=LOCK_STATE.unlocked, parent=None): super().__init__("", parent=parent) self._state = startState

Because we want to notify the outside world about state changes, we create a Signal called stateChanged that emits the new LOCK_STATE value of the lock widget.

Secondly, we give users an option to define which lock state the widget should be when we initialize it.

We pass an empty string to the super init so that the button has no visible text.

Additionally, we will prepare our icons in a local member variable.

py
def __init__(self, startState=LOCK_STATE.unlocked, parent=None): super().__init__("", parent=parent) self._state = startState self._icon_by_lock_state = { LOCK_STATE.unlocked: _createQIcon("lock_unlocked.svg"), LOCK_STATE.locked: _createQIcon("lock_locked.svg"), LOCK_STATE.forceLocked: _createQIcon("lock_force_locked.svg"), } self._locked_hover_icon = _createQIcon("lock_locked_hover.svg") self._unlocked_hover_icon = _createQIcon("lock_unlocked_hover.svg")

tip

You might want to cache the icons or simply load them in the module's global space if you are sure a `QApplication` exists when the lock widget module is being imported anywhere in your codebase. This holds true for example when creating a plugin for Maya, Houdini, Krita or similar software.

State Logic

So far, we have a blank button that doesn't do anything.

There are many ways we can approach the lock widget's logic. For expendability's sake, I will show you a very custom-made approach that allows us to add mentioned force locked and unlocked states. If you wish to omit these particular states a checkable QPushButton or styled QCheckBox would do as well.

For our custom logic, we will add a callback to the button's clicked signal in the initializer.

py
self.clicked.connect(self._tryToggleState)

Let's create the _tryToggleState function. Why the "try"? In case of forced states, I want to be clear that toggling the state might fail in particular cases. The function is very straight forward. We if/elif/else the current state and switch to the proper alternate state. At the end, we notify any listening objects about the state change.

Technically speaking, an alternative approach would be to have only unlocked and locked state and utilizing the setEnabled(False) logic of QWidget. Nonetheless, I wanted to explicitly separate these states at the time of writing.

py
def _tryToggleState(self): if self._state == LOCK_STATE.forceLocked: # The widget is force locked. # It is usually a good idea to have some form of an audio cue when an operation fails. QApplication.beep() print("The lock is force locked and cannot be unlocked!") return # Early return because we will be emitting state changed signal at the function block's end. elif self._state == LOCK_STATE.locked: self._state = LOCK_STATE.unlocked elif self._state == LOCK_STATE.unlocked: self._state = LOCK_STATE.locked else: # Handle programmer errors with an assertion. assert False, "Unexpected lock widget state '{}' encountered!".format(self._state) # Notify any observers/slots about the change of state by emitting the stateChanged signal. self.stateChanged.emit(self._state)

We can test if everything is in order by creating our lock widget as follows:

py
from PySide6.QtWidgets import QApplication, QDialog, QVBoxLayout if __name__ == "__main__": app = QApplication() # Optional - style your application to a dark theme. # The icons provided here assume a dark themed application. app.setStyleSheet("* { color: #eee; background-color: #444; }") # Secondly, we put the widget in a dialog and its layout. # It is possible to .show() a widget directly, but this will be nicer # for future testing - e.g. how it behaves in different layouts. dialog = QDialog() layout = QVBoxLayout(dialog) lockWidget = QLockWidget() lockWidget.stateChanged.connect(lambda state: print("Lock Widget state changed to {}".format(state))) layout.addWidget(lockWidget) dialog.show() app.exec()

Appearance

Basic Icon

To change the button's appearance, we will be using above-mentioned icons. Let's add an _updateGUI method.

py
def _updateGUI(self): icon = self._icon_by_lock_state[self._state] self.setIcon(icon)

We want to call this in the initializer (__init__) as well as in _tryToggleState

py
def _tryToggleState(self): if self._state == LOCK_STATE.forceLocked: # The widget is force locked. # It is usually a good idea to have some form of an audio cue when an operation fails. QApplication.beep() print("The lock is force locked and cannot be unlocked!") return # Early return because we will be emitting state changed signal at the function block's end. elif self._state == LOCK_STATE.locked: self._state = LOCK_STATE.unlocked elif self._state == LOCK_STATE.unlocked: self._state = LOCK_STATE.locked else: # Handle programmer errors with an assertion. assert False, "Unexpected lock widget state '{}' encountered!".format(self._state) self._updateGUI() # Notify any observers/slots about the change of state by emitting the stateChanged signal. self.stateChanged.emit(self._state)

We should have the following: QLockWidget visualization so far.

Hover Logic

Let's make it prettier by changing the icon when mouse hovers over it.

First, we'll define a self._hovered variable in the initializer and set it by default to False. In the _updateGUI method, we'll add icon selection logic for hovered state as follows:

py
def _updateGUI(self): icon = self._icon_by_lock_state[self._state] if self._hovered: if self._state == LOCK_STATE.locked: icon = self._locked_hover_icon elif self._state == LOCK_STATE.unlocked: icon = self._unlocked_hover_icon self.setIcon(icon)

But we have yet to actually catch the event when user's mouse hovers over the widget. There are multiple ways how to achieve this, I will show you two most common:

1. Using QWidget's enterEvent and leaveEvent

We will override QWidget's enterEvent and leaveEvent as follows:

py
def enterEvent(self, event): self._hovered = True self._updateGUI() def leaveEvent(self, event): self._hovered = False self._updateGUI()

We simply set the self._hovered state to True when user's mouse enters the widget and update the GUI, vice versa for when the user's mouse leaves the widget.

2. Using QObject's installEventFilter

note

This approach is more involved and not necessary for our lock widget. You may skip this chapter if you are not interested in event filters and their usage. 😅

This one is a tiny bit more complicated but bear with me, it has some advantages - especially how modular it might be.

In this case, we will use the function installEventFilter on our lock widget. For that, Qt requires a QObject instance that implements the eventFilter(self, object, event) method.

In our case, we might simply use our lock widget for this. Let's define an eventFilter method there.

py
def eventFilter(self, obj, event): if event.type() == QEvent.Enter: self._hovered = True self._updateGUI() return True elif event.type() == QEvent.Leave: self._hovered = False self._updateGUI() return True return False

Notice that the logic is very similar to our enter and leave events. Just condensed to a single function. However, eventFilter should return a boolean value denoting whether the event has been handled and should not be propagated further. I.e. if we return True - we say the event has been handled and it should not be sent upstream, if we return False, the event has not been handled in our eventFilter and should be propagated further.

Defining the eventFilter method will not have any effect until we install it. We will install it in the __init__ function:

py
def __init__(self, startState=LOCK_STATE.unlocked, parent=None): super().__init__("", parent=parent) self._state = startState self._hovered = False self._icon_by_lock_state = { LOCK_STATE.unlocked: _createQIcon("lock_unlocked.svg"), LOCK_STATE.locked: _createQIcon("lock_locked.svg"), LOCK_STATE.forceLocked: _createQIcon("lock_force_locked.svg"), } self._locked_hover_icon = _createQIcon("lock_locked_hover.svg") self._unlocked_hover_icon = _createQIcon("lock_unlocked_hover.svg") self.installEventFilter(self) self.clicked.connect(self._tryToggleState) self._updateGUI()

Now it should be working same as the first enterEvent/leaveEvent approach.

You might be wondering however, what is the advantage of doing something like this. In this case, there is no real advantage to using an event filter because we are only interested in a single widget.

However, in a bigger codebase, we might separate concerns/functionality into reusable modules. We might define a reusable QHoverEventFilter class as follows:

py
class QHoverEventFilter(QObject): def eventFilter(self, obj, event): if hasattr(obj, "setHovered"): if event.type() == QEvent.Enter: obj.setHovered(True) return True elif event.type() == QEvent.Leave: obj.setHovered(False) return True return False

We check for existence of a setHovered method on the filtered obj. If it exists, we set it accordingly and return True saying that we have handled the event similarly as in the above approach.

For this to work, we must create the setHovered method in any class we want to install the event filter on. So let's do that in our lock widget:

py
def setHovered(self, hovered): self._hovered = hovered self._updateGUI()

warning

For simplicity's sake, we do no "handshaking" procedure between the widget and event filter object installed to it to ensure that we have not messed up somewhere - e.g. we might have a typo like `hasattr(obj, "setHoevred")` or we might just forget to implement `setHovered` - we will have no indication besides nothing happening. 🤷‍♂️ We might solve this in multitude of ways, but I think this article is dense as is.

Now for the tricky part where a lot of people, myself included, make mistakes when starting with PySide - installing the event filter.

You might think that adding the following to our QLockWidget's __init__ will suffice:

error

The following event filter installation will not work.

py
self.installEventFilter(QHoverEventFilter())

Why doesn't this work? Well, we create the QHoverEventFilter instance and we esentially allow Python to garbage collect it at the end of the initializer's block.

There are two ways around that:

We either ensure that Python's garbage collector does not touch it ourselves by saving it as a member variable of the class:

py
self._eventFilter = QHoverEventFilter() self.installEventFilter(self._eventFilter)

Or we parent the event filter to our lock widget - therefore Qt will ensure its existence until the lock widget is destroyed. This is IMHO the best way to handle this.

py
self.installEventFilter(QHoverEventFilter(self))

In both cases, we should have this on our hands:

QLockWidget visualization so far 02.

Final Touches

Depending on your preferences, we can style or remove the borders and background of the button with a simple stylesheet adjustment in __init__:

py
self.setStyleSheet("background: transparent; border: none;")

Lastly, you may also want to pass in desired size (QSize instance) of the button and set it there as well.

py
self.setIconSize(size) self.setFixedSize(size)

Note that the icon's aspect ratio will be kept by Qt when using setIconSize. So if you pass in size that does not match with the icons' aspect ratios, the button (clickable area) will be stretched but the icon will only occupy the maximum space it can without stretching as demonstrated below with 256x32 and 32x256 widgets with border: 1px solid red.

Size demonstration.

And we're finished. Hope you liked this tutorial - here is the final product in all its glory with a helpful information label:

Final product.

And the final code:

py
import os from enum import Enum from PySide6.QtCore import QObject, QSize, Qt, QEvent, Signal from PySide6.QtWidgets import QApplication, QPushButton from PySide6.QtGui import QIcon, QPixmap _DEFAULT_SIZE = QSize(32, 32) def _createQIcon(filename): dirpath = os.path.dirname(__file__) filepath = os.path.join(dirpath, filename) return QIcon(filepath) class LOCK_STATE(Enum): unlocked = 0 locked = 1 forceLocked = 2 class QLockWidget(QPushButton): stateChanged = Signal(LOCK_STATE) def __init__(self, startState=LOCK_STATE.unlocked, size=_DEFAULT_SIZE, parent=None): """ Constructor. Args: parent (type(None), QObject) """ super().__init__("", parent=parent) self._icon_by_lock_state = { LOCK_STATE.unlocked: _createQIcon("lock_unlocked.svg"), LOCK_STATE.locked: _createQIcon("lock_locked.svg"), LOCK_STATE.forceLocked: _createQIcon("lock_force_locked.svg"), } self._locked_hover_icon = _createQIcon("lock_locked_hover.svg") self._unlocked_hover_icon = _createQIcon("lock_unlocked_hover.svg") self._state = startState self._hovered = False self.setStyleSheet("background: transparent; border: none;") self.setIconSize(size) self.setFixedSize(size) self.clicked.connect(self._tryToggleState) self._updateGUI() def enterEvent(self, event): self._hovered = True self._updateGUI() def leaveEvent(self, event): self._hovered = False self._updateGUI() def _updateGUI(self): """ Update the lock's GUI. """ icon = self._icon_by_lock_state[self._state] if self._hovered: if self._state == LOCK_STATE.locked: icon = self._locked_hover_icon elif self._state == LOCK_STATE.unlocked: icon = self._unlocked_hover_icon self.setIcon(icon) def _tryToggleState(self): """ Attempt to toggle the lock's state. This can fail for locks that are force locked. """ assert(self._state is not None) if self._state == LOCK_STATE.forceLocked: QApplication.beep() print("The lock is force locked and cannot be unlocked!") return elif self._state == LOCK_STATE.locked: self._state = LOCK_STATE.unlocked elif self._state == LOCK_STATE.unlocked: self._state = LOCK_STATE.locked else: assert False, "Unexpected lock widget state '{}' encountered!".format(self._state) self._updateGUI() self.stateChanged.emit(self._state) from PySide6.QtWidgets import QApplication, QDialog, QFormLayout, QLabel if __name__ == "__main__": app = QApplication() # Optional - style your application to a dark theme. # The icons provided here assume a dark themed application. app.setStyleSheet("* { color: #eee; background-color: #444; }") dialog = QDialog() dialog.setWindowTitle("QLockWidget Tutorial") layout = QFormLayout(dialog) demoLabel = QLabel("") lockWidget = QLockWidget() lockWidget.stateChanged.connect(lambda state: demoLabel.setText("I am {}".format(state.name))) layout.addRow(lockWidget, demoLabel) dialog.show() app.exec()