Tabbed web browsing

Use signal redirection to add a multi-tab interface

PyQt5 Tutorial Mozzarella Ashbadger

Heads up! You've already completed this tutorial.

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.

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

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().__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.

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.

Packaging Python Applications with PyInstaller by Martin Fitzpatrick — This step-by-step guide walks you through packaging your own Python applications from simple examples to complete installers and signed executables.

More info Get the book

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

Tabbed web browsing was written by Martin Fitzpatrick .

Martin Fitzpatrick has been developing Python/Qt apps for 8 years. Building desktop applications to make data-analysis tools more user-friendly, Python was the obvious choice. Starting with Tk, later moving to wxWidgets and finally adopting PyQt.