Read the free tutorial below or

Get the Video

Tabbed web browsing
Use signal redirection to add a multi-tab interface

Mozzarella Ashbadger course

In the previous parts of this tutorial we built our own custom web browser using PyQt5 widgets. Starting from a basic app skeleton we've extended it to add support a simple UI, help dialogs and file operations. However, one big feature is missing -- tabbed browsing.

Tabbed browsing was a revolution when it first arrived, but is now an expected feature of web browsers. Being able to keep multiple documents open in the same window makes it easier to keep track of things as you work. In this tutorial we'll take our existing browser application and implement tabbed browsing in it.

This is a little tricky, making use of signal redirection to add additional data about the current tab to Qt's built-in signals. If you get confused, take a look back at that tutorial.

Mozarella Ashbadger (Tabbed)

The full source code for Mozzarella Ashbadger is available in the 15 minute apps repository. You can download/clone to get a working copy, then install requirements using:

bash
pip3 install -r requirements.txt

You can then run Mozzarella Ashbadger with:

bash
python3 browser_tabbed.py

Read on for a walkthrough of how to convert the existing browser code to support tabbed browsing.

Creating a QTabWidget

Adding a tabbed interface to our browser is simple using a QTabWidget. This provides a simple container for multiple widgets (in our case QWebEngineView widgets) with a built-in tabbed interface for switching between them.

Two customisations we use here are .setDocumentMode(True) which provides a Safari-like interface on Mac, and .setTabsClosable(True) which allows the user to close the tabs in the application.

We also connect QTabWidget signals tabBarDoubleClicked, currentChanged and tabCloseRequested to custom slot methods to handle these behaviours.

python
class MainWindow(QMainWindow):

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

        self.tabs = QTabWidget()
        self.tabs.setDocumentMode(True)
        self.tabs.tabBarDoubleClicked.connect( self.tab_open_doubleclick )
        self.tabs.currentChanged.connect( self.current_tab_changed )
        self.tabs.setTabsClosable(True)
        self.tabs.tabCloseRequested.connect( self.close_current_tab )

        self.setCentralWidget(self.tabs)

The three slot methods accept an i (index) parameter which indicates which tab the signal resulted from (in order).

We use a double-click on an empty space in the tab bar (represented by an index of -1 to trigger creation of a new tab. For removing a tab, we use the index directly to remove the widget (and so the tab), with a simple check to ensure there are at least 2 tabs — closing the last tab would leave you unable to open a new one.

The current_tab_changed handler uses a self.tabs.currentWidget() construct to access the widget (QWebEngineView browser) of the currently active tab, and then uses this to get the URL of the current page. This same construct is used throughout the source for the tabbed browser, as a simple way to interact with the current browser view.

python
    def tab_open_doubleclick(self, i):
        if i == -1: # No tab under the click
            self.add_new_tab()

    def current_tab_changed(self, i):
        qurl = self.tabs.currentWidget().url()
        self.update_urlbar( qurl, self.tabs.currentWidget() )
        self.update_title( self.tabs.currentWidget() )

    def close_current_tab(self, i):
        if self.tabs.count() < 2:
            return

        self.tabs.removeTab(i)

The code for adding a new tab is is follows:

python
    def add_new_tab(self, qurl=None, label="Blank"):

        if qurl is None:
            qurl = QUrl('')

        browser = QWebEngineView()
        browser.setUrl( qurl )
        i = self.tabs.addTab(browser, label)

        self.tabs.setCurrentIndex(i)

Signal & Slot changes

While the setup of the QTabWidget and associated signals is simple, things get a little trickier in the browser slot methods.

Whereas before we had a single QWebEngineView now there are multiple views, all with their own signals. If signals for hidden tabs are handled things will get all mixed up. For example, the slot handling a loadCompleted signal must check that the source view is in a visible tab and only act if it is.

We can do this using a little trick for sending additional data with signals. Below is an example of doing this when creating a new QWebEngineView in the add_new_tab function.

python
    def add_new_tab(self, qurl=None, label="Blank"):

        if qurl is None:
            qurl = QUrl('')

        browser = QWebEngineView()
        browser.setUrl( qurl )
        i = self.tabs.addTab(browser, label)

        self.tabs.setCurrentIndex(i)

        # More difficult! We only want to update the url when it's from the
        # correct tab
        browser.urlChanged.connect( lambda qurl, browser=browser:
            self.update_urlbar(qurl, browser) )

        browser.loadFinished.connect( lambda _, i=i, browser=browser:
            self.tabs.setTabText(i, browser.page().title()) )

As you can see, we set a lambda as the slot for the urlChanged signal, accepting the qurl parameter that is sent by this signal. We add the recently created browser object to pass into the update_urlbar function.

Now, whenever the urlChanged signal fires update_urlbar will receive both the new URL and the browser it came from. In the slot method we can then check to ensure that the source of the signal matches the currently visible browser — if not, we simply discard the signal.

python
    def update_urlbar(self, q, browser=None):

        if browser != self.tabs.currentWidget():
            # If this signal is not from the current tab, ignore
            return

        if q.scheme() == 'https':
            # Secure padlock icon
            self.httpsicon.setPixmap( QPixmap( os.path.join('icons','lock-ssl.png') ) )

        else:
            # Insecure padlock icon
            self.httpsicon.setPixmap( QPixmap( os.path.join('icons','lock-nossl.png') ) )

        self.urlbar.setText( q.toString() )
        self.urlbar.setCursorPosition(0)

This same technique is used to handle all other signals which we can receive from web views, and which need to be redirected. This is a good pattern to learn and practice as it gives you a huge amount of flexibility when building PyQt5 applications.

Create GUI Applications with Python & Qt5
The easy way to create desktop applications

The complete guide to building GUI applications with PyQt5. From the basics of creating a desktop window to the key features you need to build real apps.

Downloadable ebook (PDF, ePub) & Complete Source code

To support developers in [[ countryRegion ]] I give a [[ localizedDiscount[couponCode] ]]% discount with the code [[ couponCode ]] — Enjoy!

For [[ activeDiscount.description ]] I'm giving a [[ activeDiscount.discount ]]% discount with the code [[ couponCode ]] — Enjoy!

What's next?

Feel free to continue experimenting with the browser, adding features and tweaking things to your liking. Some ideas you might want to consider trying out --

  • Add support for Bookmarks/Favorites, either in the menus or as a "Bookmarks Bar"
  • Add a download manager using threads to download in the background and display progress
  • Customize how links are opened, see our quick tip on opening links in new windows
  • Implement real SSL verification (check the certificate)

Remember that the full source code for Mozzarella Ashbadger is available.

Complete Mark As Complete Finish

Continue reading

Simple threading in PyQt/PySide apps with .start() of QThreadPool  PySide

In PyQt version 5.15.0 and PySide 6.2.0, the .start() method of QThreadPool was extended to take a Python function, a Python method, or a PyQt/PySide slot, besides taking only a QRunnable object. This simplifies running Python code in the background, avoiding the hassle of creating a QRunnable object for each task. For more information about creating a QRunnable object for multithreading, see the multithreading tutorial. The .start() method schedules the execution of a function/method/slot on a separate thread using QThreadPool, so it avoids blocking the main GUI thread of your app. Therefore, if you have one or more long-running tasks that need to be completed or be running in the background, pass them to .start() and be done. We'll build a simple demo app that simulates a long-running task to show how .start() can move a user-defined Python function/method or a PyQt/PySide slot onto a separate thread. But first, let’s begin with a flawed approach. Blocking the GUI Our demo app is a sheep counter that counts upwards from 1. While this is happening, you can press a button to pick a sheep. And since picking a sheep is hard, it takes some time to complete. This is how our demo app looks like. Make sure you're using PyQt 5.15.0+ or PySide 6.2.0+; otherwise, the demo app won’t work for you. PySide6 PyQt6 PyQt5 python import time from PySide6.QtCore import Slot, QTimer from PySide6.QtWidgets import ( QLabel, QWidget, QMainWindow, QPushButton, QVBoxLayout, QApplication, ) class MainWindow(QMainWindow): def __init__(self, *args, **kwargs): super(MainWindow, self).__init__(*args, **kwargs) self.setFixedSize(250, 100) self.setWindowTitle("Sheep Picker") self.sheep_number = 1 self.timer = QTimer() self.picked_sheep_label = QLabel() self.counted_sheep_label = QLabel() self.layout = QVBoxLayout() self.main_widget = QWidget() self.pick_sheep_button = QPushButton("Pick a sheep!") self.layout.addWidget(self.counted_sheep_label) self.layout.addWidget(self.pick_sheep_button) self.layout.addWidget(self.picked_sheep_label) self.main_widget.setLayout(self.layout) self.setCentralWidget(self.main_widget) self.timer.timeout.connect(self.count_sheep) self.pick_sheep_button.pressed.connect(self.pick_sheep) self.timer.start() @Slot() def count_sheep(self): self.sheep_number += 1 self.counted_sheep_label.setText(f"Counted {self.sheep_number} sheep.") @Slot() def pick_sheep(self): self.picked_sheep_label.setText(f"Sheep {self.sheep_number} picked!") time.sleep(5) # This function represents a long-running task! if __name__ == "__main__": app = QApplication([]) main_window = MainWindow() main_window.show() app.exec() python import time from PyQt6.QtCore import pyqtSlot, QTimer from PyQt6.QtWidgets import ( QLabel, QWidget, QMainWindow, QPushButton, QVBoxLayout, QApplication, ) class MainWindow(QMainWindow): def __init__(self, *args, **kwargs): super(MainWindow, self).__init__(*args, **kwargs) self.setFixedSize(250, 100) self.setWindowTitle("Sheep Picker") self.sheep_number = 1 self.timer = QTimer() self.picked_sheep_label = QLabel() self.counted_sheep_label = QLabel() self.layout = QVBoxLayout() self.main_widget = QWidget() self.pick_sheep_button = QPushButton("Pick a sheep!") self.layout.addWidget(self.counted_sheep_label) self.layout.addWidget(self.pick_sheep_button) self.layout.addWidget(self.picked_sheep_label) self.main_widget.setLayout(self.layout) self.setCentralWidget(self.main_widget) self.timer.timeout.connect(self.count_sheep) self.pick_sheep_button.pressed.connect(self.pick_sheep) self.timer.start() @pyqtSlot() def count_sheep(self): self.sheep_number += 1 self.counted_sheep_label.setText(f"Counted {self.sheep_number} sheep.") @pyqtSlot() def pick_sheep(self): self.picked_sheep_label.setText(f"Sheep {self.sheep_number} picked!") time.sleep(5) # This function represents a long-running task! if __name__ == "__main__": app = QApplication([]) main_window = MainWindow() main_window.show() app.exec() python import time from PyQt5.QtCore import pyqtSlot, QTimer from PyQt5.QtWidgets import ( QLabel, QWidget, QMainWindow, QPushButton, QVBoxLayout, QApplication, ) class MainWindow(QMainWindow): def __init__(self, *args, **kwargs): super(MainWindow, self).__init__(*args, **kwargs) self.setFixedSize(250, 100) self.setWindowTitle("Sheep Picker") self.sheep_number = 1 self.timer = QTimer() self.picked_sheep_label = QLabel() self.counted_sheep_label = QLabel() self.layout = QVBoxLayout() self.main_widget = QWidget() self.pick_sheep_button = QPushButton("Pick a sheep!") self.layout.addWidget(self.counted_sheep_label) self.layout.addWidget(self.pick_sheep_button) self.layout.addWidget(self.picked_sheep_label) self.main_widget.setLayout(self.layout) self.setCentralWidget(self.main_widget) self.timer.timeout.connect(self.count_sheep) self.pick_sheep_button.pressed.connect(self.pick_sheep) self.timer.start() @pyqtSlot() def count_sheep(self): self.sheep_number += 1 self.counted_sheep_label.setText(f"Counted {self.sheep_number} sheep.") @pyqtSlot() def pick_sheep(self): self.picked_sheep_label.setText(f"Sheep {self.sheep_number} picked!") time.sleep(5) # This function represents a long-running task! if __name__ == "__main__": app = QApplication([]) main_window = MainWindow() main_window.show() app.exec() When you run the demo app and press the Pick a sheep! button, you’ll notice that for 5 seconds, the GUI is completely unresponsive. That's not good. The delay in GUI responsiveness comes from the line time.sleep(5) which pauses execution of Python code for 5 seconds. This was added to simulate a long-running task. We can, however, improve that by threading, as you’ll see later on. Feel free to experiment by increasing the length of the delay – pass a number greater than 5 to .sleep() – and you may notice that your operating system starts complaining about the demo app not responding. Run a task on a separate thread So, how can we improve the responsiveness of our demo app? This is where the extended .start() method of QThreadPool comes in! First, we need to import QThreadPool, so let’s do that. PySide6 PyQt6 PyQt5 python from PySide6.QtCore import QThreadPool python from PyQt6.QtCore import QThreadPool python from PyQt5.QtCore import QThreadPool Next, we need to create a QThreadPool instance. Let’s add python self.thread_manager = QThreadPool() to the __init__ block of the MainWindow class. Now, let’s create a pick_sheep_safely() slot. It will use the .start() method to call the long-running pick_sheep() slot and move it from the main GUI thread onto a separate thread. PySide PyQt python @Slot() def pick_sheep_safely(self): self.thread_manager.start(self.pick_sheep) # This is where the magic happens! python @pyqtSlot() def pick_sheep_safely(self): self.thread_manager.start(self.pick_sheep) # This is where the magic happens! Also, make sure that you connect the pick_sheep_safely() slot with the pressed signal of self.pick_sheep_button. So, in the __init__ block of the MainWindow class, you should have python self.pick_sheep_button.pressed.connect(self.pick_sheep_safely) And if you followed along, the code of our improved demo app should now be: PySide6 PyQt6 PyQt5 python import time from PySide6.QtCore import Slot, QThreadPool, QTimer from PySide6.QtWidgets import ( QLabel, QWidget, QMainWindow, QPushButton, QVBoxLayout, QApplication, ) class MainWindow(QMainWindow): def __init__(self, *args, **kwargs): super(MainWindow, self).__init__(*args, **kwargs) self.setFixedSize(250, 100) self.setWindowTitle("Sheep Picker") self.sheep_number = 1 self.timer = QTimer() self.picked_sheep_label = QLabel() self.counted_sheep_label = QLabel() self.layout = QVBoxLayout() self.main_widget = QWidget() self.thread_manager = QThreadPool() self.pick_sheep_button = QPushButton("Pick a sheep!") self.layout.addWidget(self.counted_sheep_label) self.layout.addWidget(self.pick_sheep_button) self.layout.addWidget(self.picked_sheep_label) self.main_widget.setLayout(self.layout) self.setCentralWidget(self.main_widget) self.timer.timeout.connect(self.count_sheep) self.pick_sheep_button.pressed.connect(self.pick_sheep_safely) self.timer.start() @Slot() def count_sheep(self): self.sheep_number += 1 self.counted_sheep_label.setText(f"Counted {self.sheep_number} sheep.") @Slot() def pick_sheep(self): self.picked_sheep_label.setText(f"Sheep {self.sheep_number} picked!") time.sleep(5) # This function doesn't affect GUI responsiveness anymore... @Slot() def pick_sheep_safely(self): self.thread_manager.start(self.pick_sheep) # ...since .start() is used! if __name__ == "__main__": app = QApplication([]) main_window = MainWindow() main_window.show() app.exec() python import time from PyQt6.QtCore import pyqtSlot, QThreadPool, QTimer from PyQt6.QtWidgets import ( QLabel, QWidget, QMainWindow, QPushButton, QVBoxLayout, QApplication, ) class MainWindow(QMainWindow): def __init__(self, *args, **kwargs): super(MainWindow, self).__init__(*args, **kwargs) self.setFixedSize(250, 100) self.setWindowTitle("Sheep Picker") self.sheep_number = 1 self.timer = QTimer() self.picked_sheep_label = QLabel() self.counted_sheep_label = QLabel() self.layout = QVBoxLayout() self.main_widget = QWidget() self.thread_manager = QThreadPool() self.pick_sheep_button = QPushButton("Pick a sheep!") self.layout.addWidget(self.counted_sheep_label) self.layout.addWidget(self.pick_sheep_button) self.layout.addWidget(self.picked_sheep_label) self.main_widget.setLayout(self.layout) self.setCentralWidget(self.main_widget) self.timer.timeout.connect(self.count_sheep) self.pick_sheep_button.pressed.connect(self.pick_sheep_safely) self.timer.start() @pyqtSlot() def count_sheep(self): self.sheep_number += 1 self.counted_sheep_label.setText(f"Counted {self.sheep_number} sheep.") @pyqtSlot() def pick_sheep(self): self.picked_sheep_label.setText(f"Sheep {self.sheep_number} picked!") time.sleep(5) # This function doesn't affect GUI responsiveness anymore... @pyqtSlot() def pick_sheep_safely(self): self.thread_manager.start(self.pick_sheep) # ...since .start() is used! if __name__ == "__main__": app = QApplication([]) main_window = MainWindow() main_window.show() app.exec() python import time from PyQt5.QtCore import pyqtSlot, QThreadPool, QTimer from PyQt5.QtWidgets import ( QLabel, QWidget, QMainWindow, QPushButton, QVBoxLayout, QApplication, ) class MainWindow(QMainWindow): def __init__(self, *args, **kwargs): super(MainWindow, self).__init__(*args, **kwargs) self.setFixedSize(250, 100) self.setWindowTitle("Sheep Picker") self.sheep_number = 1 self.timer = QTimer() self.picked_sheep_label = QLabel() self.counted_sheep_label = QLabel() self.layout = QVBoxLayout() self.main_widget = QWidget() self.thread_manager = QThreadPool() self.pick_sheep_button = QPushButton("Pick a sheep!") self.layout.addWidget(self.counted_sheep_label) self.layout.addWidget(self.pick_sheep_button) self.layout.addWidget(self.picked_sheep_label) self.main_widget.setLayout(self.layout) self.setCentralWidget(self.main_widget) self.timer.timeout.connect(self.count_sheep) self.pick_sheep_button.pressed.connect(self.pick_sheep_safely) self.timer.start() @pyqtSlot() def count_sheep(self): self.sheep_number += 1 self.counted_sheep_label.setText(f"Counted {self.sheep_number} sheep.") @pyqtSlot() def pick_sheep(self): self.picked_sheep_label.setText(f"Sheep {self.sheep_number} picked!") time.sleep(5) # This function doesn't affect GUI responsiveness anymore... @pyqtSlot() def pick_sheep_safely(self): self.thread_manager.start(self.pick_sheep) # ...since .start() is used! if __name__ == "__main__": app = QApplication([]) main_window = MainWindow() main_window.show() app.exec() When you press the Pick a sheep! button now, the pick_sheep() slot is executed on a separate thread and no longer blocks the main GUI thread. The sheep counting goes on, and the GUI remains responsive – even though our demo app still has to complete a long-running task in the background. Try increasing the length of the delay now – for example, time.sleep(10) – and notice that it doesn’t affect the GUI anymore. Conclusion And that’s it! I hope you’ll find the extended .start() method of QThreadPool helpful in any of your PyQt/PySide GUI apps that have long-running tasks to be executed in the background. More