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:

Icons
Let's start with preparing necessary data for this widget, the icons - we will need icons for these following states:
unlocked state
locked state
unlocked hovered state
locked hovered state
Optionally:
force locked state
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:
pyimport 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.
pyfrom 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
.
pyclass 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.
pydef __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.
pyself.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.
pydef _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:
pyfrom 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.
pydef _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
pydef _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:
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:
pydef _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:
pydef 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.
pydef 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:
pydef __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:
pyclass 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:
pydef 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.
pyself.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:
pyself._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.
pyself.installEventFilter(QHoverEventFilter(self))
In both cases, we should have this on our hands:

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__
:
pyself.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.
pyself.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
.

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

And the final code:
pyimport 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()