Creating searchable widget dashboards

Make dashboard UIs easier to use with widget search & text prediction
Heads up! You've already completed this tutorial.

Dashboard applications are a popular way to display live data and user controls, whether interacting with APIs or controlling locally attached devices. However, as more controls are added, dashboards can quickly get out of control, making it hard to find the controls you want when you want them. One common solution is to provide the user with a way to filter the displayed widgets, allowing them to zero-in on the information and tools that are important to them right now.

There are many ways to filter lists, including dropdowns and facets, but one of the most intuitive is a simple, live, search box. As long as elements are well named, or tagged with appropriate metadata, this can be both fast and easy to understand.

In this tutorial we'll build a simple search based widget filter, which can be used to filter a custom compound control widget. This could be an app to control the electrical sensors/gadgets around your home. The finished example is shown below —

The interface includes a search bar with autocomplete, a scrollable region list, and a series of independent custom widgets.

Basic application template

We start from our basic application template, shown below, which you can save to file as app.py

python
# app.py
import sys
from PyQt5.QtWidgets import (QMainWindow, QApplication)


class MainWindow(QMainWindow):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        container = QWidget()
        containerLayout = QVBoxLayout()
        container.setLayout(containerLayout)

        self.setCentralWidget(container)


app = QApplication(sys.argv)
w = MainWindow()
w.show()
sys.exit(app.exec_())

If you run this, it will display a blank window.

bash
python app.py

In this template we have created a MainWindow class subclassed from QMainWindow. In the __init__ block we've added a container QWidget and applied a vertical layout with QVBoxLayout. This container widget is then set as the central widget of the window.

Any widgets we subsequently add to this layout will be laid out vertically in the window. Next we'll define our control widgets, which we can then add to the layout.

Building our custom compound widget

For our control panel widgets we'll be creating a compound widget, that is, a widget created by combining 2 or more standard widgets together. By adding our own logic to these widgets we can create our custom behaviours.

If you're interested in creating more complex custom widgets, take a look at our custom widgets tutorials.

Create a new file called customwidgets.py in the same folder as app.py. We will define our custom widget here, then import it into our main application code.

The custom widget is a simple On/Off toggle widget, so we've given it the slightly uninspired name OnOffWidget.

Start by adding the imports and creating a basic stub for the widget class. The widget accepts a single parameter name which we store in self.name to use for searching later. This is also be used to create a label for the widget later.

python
# customwidgets.py
from PyQt5.QtWidgets import (QWidget, QLabel, QPushButton, QHBoxLayout)


class OnOffWidget(QWidget):

    def __init__(self, name):
        super().__init__()

        self.name = name  # Name of widget used for searching.
        self.is_on = False  # Current state (true=ON, false=OFF)

Note that we've also added an attribute self.is_on which holds the widget's current state, with True for ON and False for OFF. We'll add logic to update this later.

In app.py we can import this custom widget as follows —

python
from customwidgets import OnOffWidget

For development purposes create a single instance of OnOffWidget and add it to the containerLayout in the parent app. This will allow you to see the effects of your changes to the widgets code by running app.py.

python
# app.py
import sys
from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QMainWindow, QApplication)
from customwidgets import OnOffWidget

class MainWindow(QMainWindow):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        container = QWidget()
        containerLayout = QVBoxLayout()
        container.setLayout(containerLayout)

        onoff = OnOffWidget('Testing')
        containerLayout.addWidget(onoff)

        self.setCentralWidget(container)


app = QApplication(sys.argv)
w = MainWindow()
w.show()
sys.exit(app.exec_())

Running this will produce a window containing a single OnOffWidget. However, since the widget does not display anything itself, the window will appear empty. We'll fix that now by specifying the layout of our custom widget and adding some contents.

OnOffWidget is a custom widget containing a label and two On/Off toggle buttons, that could for example be used to control lights or gadgets in your home.

python
class OnOffWidget(QWidget):
    def __init__(self, name):
        super().__init__()

        self.name = name # Name of widget used for searching.
        self.is_on = False # Current state (true=ON, false=OFF)

        self.lbl = QLabel(self.name)    #  The widget label
        self.btn_on = QPushButton("On")     # The ON button
        self.btn_off = QPushButton("Off")   # The OFF button

        self.hbox = QHBoxLayout()       # A horizontal layout to encapsulate the above
        self.hbox.addWidget(self.lbl)   # Add the label to the layout
        self.hbox.addWidget(self.btn_on)    # Add the ON button to the layout
        self.hbox.addWidget(self.btn_off)   # Add the OFF button to the layout
        self.setLayout(self.hbox)

The passed in name (already stored in self.name) is now used to create a QLabel to identify the widget. Next we create two buttons labelled On and Off respectively to act as toggles, and then add the label and the two buttons into a layout for display.

OnOffWidget plain, without toggle styles OnOffWidget plain, without toggle styles

As it currently stands, the widget can be created and the buttons clicked, however they don't do anything. Next we'll hook up the button signals and use them to toggle the state of the widget from ON to OFF.

Updating widget state

The current state of the widget will be displayed by changing the colour of the two buttons. When in the ON state the On button will be highlighted green, when OFF the Off button will be highlighted in red.

We do this by defining two methods .on and .off which are called to turn the widget to ON and OFF state respectively. We also add a update_button_state method which updates the appearance of the buttons to indicate the current state.

python
class OnOffWidget(QWidget):

    def __init__(self, name):
        super().__init__()

        # ... rest of __init__ hidden for clarity.

        self.btn_on.clicked.connect(self.on)
        self.btn_off.clicked.connect(self.off)

        self.update_button_state()

    def off(self):
        self.is_on = False
        self.update_button_state()

    def on(self):
        self.is_on = True
        self.update_button_state()

    def update_button_state(self):
        """
        Update the appearance of the control buttons (On/Off)
        depending on the current state.
        """
        if self.is_on == True:
            self.btn_on.setStyleSheet("background-color: #4CAF50; color: #fff;")
            self.btn_off.setStyleSheet("background-color: none; color: none;")
        else:
            self.btn_on.setStyleSheet("background-color: none; color: none;")
            self.btn_off.setStyleSheet("background-color: #D32F2F; color: #fff;")

Notice that we call update_button_state at the end of the __init__ block to set the initial state of the buttons, and the .on and .off methods both call update_button_state after changing .is_on.

In this simple example you could combine the update_button_state code into the .on and .off methods, but as you build more complex controls it is a good idea to keep a central update display state method to ensure consistency.

The .on and .off methods are connected to the button .clicked signals as follows —

python
self.btn_on.clicked.connect(self.on)

For a refresher on using Qt signals and slots, head over to the Signals & Slots tutorial.

With these signals connected up, clicking the buttons will now toggle the widget is_on state and update the buttons' appearance.

Building our main application layout

Now we have completed our custom control widget, we can finish the layout of the main application. The full code is shown at first, and then explained in steps below.

python
# app.py
from PyQt5 import QtWidgets, uic
import sys
from PyQt5.QtWidgets import (
    QWidget, QLineEdit, QLabel, QPushButton, QScrollArea, QMainWindow,
    QApplication, QHBoxLayout, QVBoxLayout, QSpacerItem, QSizePolicy
    )
from PyQt5.QtCore import QObject, Qt, pyqtSignal
from PyQt5.QtGui import QPainter, QFont, QColor, QPen

from customwidgets import OnOffWidget


class MainWindow(QMainWindow):

    def __init__(self, *args, **kwargs):
        super().__init__()

        self.controls = QWidget()  # Controls container widget.
        self.controlsLayout = QVBoxLayout()   # Controls container layout.
        # List of names, widgets are stored in a dictionary by these keys.
        widget_names = [
            "Heater", "Stove", "Living Room Light", "Balcony Light",
            "Fan", "Room Light", "Oven", "Desk Light",
            "Bedroom Heater", "Wall Switch"
        ]
        self.widgets = []

        # Iterate the names, creating a new OnOffWidget for
        # each one, adding it to the layout and
        # and storing a reference in the self.widgets list
        for name in widget_names:
            item = OnOffWidget(name)
            self.controlsLayout.addWidget(item)
            self.widgets.append(item)

        spacer = QSpacerItem(1, 1, QSizePolicy.Minimum, QSizePolicy.Expanding)
        self.controlsLayout.addItem(spacer)
        self.controls.setLayout(self.controlsLayout)

        # Scroll Area Properties.
        self.scroll = QScrollArea()
        self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
        self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.scroll.setWidgetResizable(True)
        self.scroll.setWidget(self.controls)

        # Search bar.
        self.searchbar = QLineEdit()

        # Add the items to VBoxLayout (applied to container widget)
        # which encompasses the whole window.
        container = QWidget()
        containerLayout = QVBoxLayout()
        containerLayout.addWidget(self.searchbar)
        containerLayout.addWidget(self.scroll)

        container.setLayout(containerLayout)
        self.setCentralWidget(container)

        self.setGeometry(600, 100, 800, 600)
        self.setWindowTitle('Control Panel')


app = QtWidgets.QApplication(sys.argv)
w = MainWindow()
w.show()
sys.exit(app.exec_())

We'll now step through the sections of the above code, explaining how each of the key parts works in turn.

We start first by creating our OnOffWidget widget instances. The list of widgets to generate is coded as a list of names, each as a str. To generate the widgets we iterate this list, passing the names into to the OnOffWidget constructor. This creates a new OnOffWidget with the name as a QLabel (see the widget definition above). This same name will be used for searching later.

python
# List of names, widgets are stored in a dictionary by these keys.
widget_names = [
    "Heater", "Stove", "Living Room Light", "Balcony Light",
    "Fan", "Room Light", "Oven", "Desk Light",
    "Bedroom Heater", "Wall Switch"
]
self.widgets = []

# Iterate the names, creating a new OnOffWidget for
# each one, adding it to the layout and
# and storing a reference in the self.widgets list
for name in widget_names:
    item = OnOffWidget(name)
    self.controlsLayout.addWidget(item)
    self.widgets.append(item)

Once the widget is created it is added to our layout and also appended to our widget list in self.widgets. We can iterate this list later to perform our search.

In addition to the OnOffWidgets that we've added to our controlsLayout we also need to add a spacer. Without a spacer the widgets in the window will spread out to take up all the available space, rather than remaining compact and consistently sized.

The following spacer is set to a 1x1 default dimension, with the y-dimension set as expanding so it will expand vertically to fill all available space.

python
spacer = QSpacerItem(1, 1, QSizePolicy.Minimum, QSizePolicy.Expanding)
self.controlsLayout.addItem(spacer)
self.controls.setLayout(self.controlsLayout)

As we're adding this to a QVBoxLayout the x dimension of the spacer is irrelevant.

Next we setup a scrolling area, adding the self.controls widget to it, and setting the vertical scrollbar to always on, and the horizontal to always off.

python
# Scroll Area Properties.
self.scroll = QScrollArea()
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.scroll.setWidgetResizable(True)
self.scroll.setWidget(self.controls)

If you're unfamiliar with adding scrolling regions to your applications you can take a look at our earlier QScrollArea tutorial.

Finally, we assemble our widgets for the view. The container widget holds our entire layout (and will be set using .setCentralWidget on the window). To this we first add our QLineEdit search bar, and the scroll widget.

python
# Search bar.
self.searchbar = QLineEdit()

# Add the items to VBoxLayout (applied to container widget)
# which encompasses the whole window.
container = QWidget()
containerLayout = QVBoxLayout()
containerLayout.addWidget(self.searchbar)
containerLayout.addWidget(self.scroll)

Running the code you'll see the following window, with a search bar on top and a list of our custom OnOffWidget widgets below.

Search Bar and Widget list Search Bar and Widget list

However, if you try typing in the box you'll notice that the search is not working and none of the widgets are filtered. In the next part we'll step through the process of adding search functionality, along with autocomplete hints, to the app.

Adding search functionality

Finally, we get to the main objective of this article — adding the search functionality! This can be accomplished with a single method, along with some small tweaks to our OnOffWidget class. Make the following additions to the MainWindow class —

python
class MainWindow(QWidget):

    def __init__(self, *args, **kwargs):
        super().__init__()

        # ... rest of __init__ ommitted for clarity.
        # Search bar.
        self.searchbar = QLineEdit()
        self.searchbar.textChanged.connect(self.update_display)
        # ... rest of __init__ omitted for clarity.


    # ... other MainWindow methods ommitted for clarity.

    def update_display(self, text):

        for widget in self.widgets:
            if text.lower() in widget.name.lower():
                widget.show()
            else:
                widget.hide()

As shown, the searchbar.textChanged signal can be attached in the __init__ after the creation of the searchbar. This connects the QLineEdit.textChanged signal to our custom update_display method. The textChanged signal fires every time the text in the box changes, and sends the current text to any connected slots.

We then create a method update_display which performs the following —

  1. Iterate through each OnOffWidget in self.widgets
  2. Determine whether the (lowercased) name of the widget widget.name contains the (lowercased) text entered by the user in the search bar.
  3. If there is a match (name contains the text), show the widget by calling widget.show(); if it doesn't match, hide the widget by calling widget.hide()

By lowercasing both the widget name and the the search string we do a case-insensitive search, making it easier to find our widgets.

If you think back to the OnOffWidget definition you'll remember that we assigned the name passed when creating the widget to self.name, it is this value we're checking now on each of our widgets as widget.name.

Now, as you type text in the box, the current text is sent to update_widgets, the widgets are then iterated, matched and shown or hidden as appropriate. Or, at least they would be, if those methods were defined…. we'll do that next.

Toggling widget visibility

Open customwidgets.py add the following two methods to the OnOffWidget class.

python
    def show(self):
        """
        Show this widget, and all child widgets.
        """
        for w in [self, self.lbl, self.btn_on, self.btn_off]:
            w.setVisible(True)

    def hide(self):
        """
        Hide this widget, and all child widgets.
        """
        for w in [self, self.lbl, self.btn_on, self.btn_off]:
            w.setVisible(False)

Each Qt widget has a .setVisible method which can be used to toggle that widget's visibility. However, compound or nested widgets can only become invisible when all their child widgets are also invisible.

In our case, that means even if we call .setVisible(False) on our OnOffWidget the widget will not become invisible, until the nested .lbl, .btn_on and .btn_off are also .setVisible(False). These custom .show and .hide methods handle this for us, by setting self (the current widget) and all child widgets to visible, or invisible, respectively.

With these methods in place, if you now run the complete application you should see the text search and filtering working as expected.

Adding text prediction

Being able to search to find widgets is a handy shortcut, but we can make things even more convenient for the user by adding text completion to speed up the search. This is particularly handy when you have a number of widgets with similar names. With prediction it is possible to jump to the correct widgets by typing only a few letters and then selecting one of the suggested options with the keyboard.

Qt has support for text completion built in, through the QCompleter class. Add the code below to the __init__ block of the MainWindow after the search bar is created as self.searchbar. The basic completer requires only 2 lines of code, one to create the completer, and a second to attach it to the QLineEdit. Here we've also set the search case sensitivity mode to case insensitive to match the search.

python
        # Adding Completer.
        self.completer = QCompleter(widget_names)
        self.completer.setCaseSensitivity(Qt.CaseInsensitive)
        self.searchbar.setCompleter(self.completer)

The complete app.py and customwidgets.py code is shown below.

python
# app.py
import sys
from PyQt5.QtWidgets import (
    QWidget, QLineEdit, QLabel, QPushButton, QScrollArea, QMainWindow,
    QApplication, QHBoxLayout, QVBoxLayout, QSpacerItem, QSizePolicy, QCompleter
)
from PyQt5.QtCore import Qt
from customwidgets import OnOffWidget


class MainWindow(QMainWindow):

    def __init__(self, *args, **kwargs):
        super().__init__()

        self.controls = QWidget()  # Controls container widget.
        self.controlsLayout = QVBoxLayout()   # Controls container layout.

        # List of names, widgets are stored in a dictionary by these keys.
        widget_names = [
            "Heater", "Stove", "Living Room Light", "Balcony Light",
            "Fan", "Room Light", "Oven", "Desk Light",
            "Bedroom Heater", "Wall Switch"
        ]
        self.widgets = []

        # Iterate the names, creating a new OnOffWidget for
        # each one, adding it to the layout and
        # and storing a reference in the self.widgets dict
        for name in widget_names:
            item = OnOffWidget(name)
            self.controlsLayout.addWidget(item)
            self.widgets.append(item)

        spacer = QSpacerItem(1, 1, QSizePolicy.Minimum, QSizePolicy.Expanding)
        self.controlsLayout.addItem(spacer)
        self.controls.setLayout(self.controlsLayout)

        # Scroll Area Properties.
        self.scroll = QScrollArea()
        self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
        self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.scroll.setWidgetResizable(True)
        self.scroll.setWidget(self.controls)

        # Search bar.
        self.searchbar = QLineEdit()
        self.searchbar.textChanged.connect(self.update_display)

        # Adding Completer.
        self.completer = QCompleter(widget_names)
        self.completer.setCaseSensitivity(Qt.CaseInsensitive)
        self.searchbar.setCompleter(self.completer)

        # Add the items to VBoxLayout (applied to container widget)
        # which encompasses the whole window.
        container = QWidget()
        containerLayout = QVBoxLayout()
        containerLayout.addWidget(self.searchbar)
        containerLayout.addWidget(self.scroll)

        container.setLayout(containerLayout)
        self.setCentralWidget(container)

        self.setGeometry(600, 100, 800, 600)
        self.setWindowTitle('Control Panel')

    def update_display(self, text):

        for widget in self.widgets:
            if text.lower() in widget.name.lower():
                widget.show()
            else:
                widget.hide()


app = QApplication(sys.argv)
w = MainWindow()
w.show()
sys.exit(app.exec_())

And the customwidgets.py file below.

python
# customwidgets.py
from PyQt5.QtWidgets import (QWidget, QLabel, QPushButton,
                             QHBoxLayout)


class OnOffWidget(QWidget):

    def __init__(self, name):
        super().__init__()

        self.name = name
        self.is_on = False

        self.lbl = QLabel(self.name)
        self.btn_on = QPushButton("On")
        self.btn_off = QPushButton("Off")

        self.hbox = QHBoxLayout()
        self.hbox.addWidget(self.lbl)
        self.hbox.addWidget(self.btn_on)
        self.hbox.addWidget(self.btn_off)

        self.btn_on.clicked.connect(self.on)
        self.btn_off.clicked.connect(self.off)

        self.setLayout(self.hbox)

        self.update_button_state()

    def show(self):
        """
        Show this widget, and all child widgets.
        """
        for w in [self, self.lbl, self.btn_on, self.btn_off]:
            w.setVisible(True)

    def hide(self):
        """
        Hide this widget, and all child widgets.
        """
        for w in [self, self.lbl, self.btn_on, self.btn_off]:
            w.setVisible(False)

    def off(self):
        self.is_on = False
        self.update_button_state()

    def on(self):
        self.is_on = True
        self.update_button_state()

    def update_button_state(self):
        """
        Update the appearance of the control buttons (On/Off)
        depending on the current state.
        """
        if self.is_on == True:
            self.btn_on.setStyleSheet("background-color: #4CAF50; color: #fff;")
            self.btn_off.setStyleSheet("background-color: none; color: none;")
        else:
            self.btn_on.setStyleSheet("background-color: none; color: none;")
            self.btn_off.setStyleSheet("background-color: #D32F2F; color: #fff;")

Conclusion

By adding a search bar we've made it easier for users to navigate and filter through lists of controls. This same approach could be used to filter through a list of status panels, or graphs, or any other Qt widgets. This allows you to build complex dashboards without the interface becoming overwhelming for the user.

As a next step you could experiment with adding "favorite" widgets, with the ability to mark particular widgets to appear at the top. You'll need to re-sort the widgets when the favorite state is updated, but otherwise things stay much the same.

Create GUI Applications with Python & Qt5 by Martin Fitzpatrick — (PyQt5 Edition) The hands-on guide to making apps with Python — Over 10,000 copies sold!

More info Get the book

Well done, you've finished this tutorial! Mark As Complete
[[ user.completed.length ]] completed [[ user.streak+1 ]] day streak

Creating searchable widget dashboards was written by John Lim .

John is a developer from Kuala Lumpur, Malaysia who works as a Senior R&D Engineer.