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.
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:
pip3 install -r requirements.txt
You can then run Mozzarella Ashbadger with:
Read on for a walkthrough of how to convert the existing browser code to support tabbed browsing.
Adding a tabbed interface to our browser is simple using a
QTabWidget. This provides a simple container for
multiple widgets (in our case
with a built-in tabbed interface for switching between them.
Two customisations we use here are
provides a Safari-like interface on Mac, and
which allows the user to close the tabs in the application.
We also connect
tabCloseRequested to custom slot methods to handle these behaviours.
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
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.
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.
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:
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
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
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.
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.
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.