본문 바로가기
PyQt5_

Mozzarella Ashbadger

by 자동매매 2023. 3. 16.

41. Mozzarella Ashbadger

Mozzarella Ashbadger is the latest revolution in web browsing! Go back and forward! Print! Save files! Get help! (you’ll need it). Any similarity to other browsers is entirely coincidental.

Figure 269. Mozzarella Ashbadger.

ë

This application makes use of features covered in Signals &

Slots, Extending Signals and Widgets.

The source code for Mozzarella Ashbadger is provided in two forms, one with tabbed browsing and one without. Adding tabs complicates the signal handling a little bit, so the tab-less version is covered first.

To create the browser we need to install an additional PyQt6 component — PyQtWebEngine. You can do this with pip from the command line as follows.

pip3 install pyqt6-webengine

Source code

The full source for the tab-less browser is included in the downloads for this book. The browser code has the name browser.py.

python3 browser.py

 Run it! Explore the Mozzarella Ashbadger interface and features before

moving onto the code.

The browser widget

The core of our browser is the QWebEngineView which we import from QtWebEngineWidgets. This provides a complete browser window, which handles the rendering of the downloaded pages. Below is the bare-minimum of code required to use web browser widget in PyQt6.

Listing 264. app/browser_skeleton.py

import sys

from PyQt6.QtCore import QUrl

from PyQt6.QtWebEngineWidgets import QWebEngineView

from PyQt6.QtWidgets import QApplication, QMainWindow

class MainWindow (QMainWindow):

def __init__ (self):

super().__init__()

self.browser = QWebEngineView()

self.browser.setUrl(QUrl("https://www.google.com"))

self.setCentralWidget(self.browser)

self.show()

app = QApplication(sys.argv)

window = MainWindow()

app. exec ()

If you click around a bit you’ll discover that the browser behaves as expected — links work correctly, and you can interact with the pages. However, you’ll also notice things you take for granted are missing — like an URL bar, controls or any sort of interface whatsoever. This makes it a little tricky to use.

Let’s convert this bare-bones browser into something a little more usable!

Paths

To make working with interface icons easier, we can start by defining a Working with Relative Paths. It defines a single folder location for data files icons and a method icon for creating paths for icons. This allows us to using Paths.icon() to load our icons for the browser interface.

Listing 265. app/paths.py

import os

class Paths :

base = os.path.dirname(__file__)

icons = os.path.join(base, "icons")

# File loaders.

@classmethod

def icon (cls, filename):

return os.path.join(cls.icons, filename)

Saved in the same folder as our browser, it can be imported as:

Listing 266. app/browser.py

from paths import Paths

Navigation

Now that is in place we can add some interface controls, using a series of QActions on a QToolbar. We add these definitions to the init block of the QMainWindow. We use our Paths.icon() method to load up the file using relative paths.

Listing 267. app/browser.py

navtb = QToolBar("Navigation")

navtb.setIconSize(QSize( 16 , 16 ))

self.addToolBar(navtb)

back_btn = QAction(

QIcon(Paths.icon("arrow-180.png")), "Back", self

)

back_btn.setStatusTip("Back to previous page")

back_btn.triggered.connect(self.browser.back)

navtb.addAction(back_btn)

The QWebEngineView includes slots for forward, back and reload navigation, which we can connect to directly to our action’s .triggered signals.

We use the same QAction structure for the remaining controls.

Listing 268. app/browser.py

next_btn = QAction(

QIcon(Paths.icon("arrow-000.png")), "Forward", self

)

next_btn.setStatusTip("Forward to next page")

next_btn.triggered.connect(self.browser.forward)

navtb.addAction(next_btn)

reload_btn = QAction(

QIcon(Paths.icon("arrow-circle-315.png")),

"Reload",

self,

)

reload_btn.setStatusTip("Reload page")

reload_btn.triggered.connect(self.browser.reload)

navtb.addAction(reload_btn)

home_btn = QAction(QIcon(Paths.icon("home.png")), "Home",

self)

home_btn.setStatusTip("Go home")

home_btn.triggered.connect(self.navigate_home)

navtb.addAction(home_btn)

Notice that while forward, back and reload can use built-in slots, the navigate home button requires a custom slot function. The slot function is defined on our QMainWindow class, and simply sets the URL of the browser to the Google homepage. Note that the URL must be passed as a QUrl object.

Listing 269. app/browser.py

def navigate_home (self):

self.browser.setUrl(QUrl("http://www.google.com"))

ë

Challenge

Try making the home navigation location configurable. You

could create a Preferences QDialog with an input field.

Any decent web browser also needs an URL bar, and some way to stop the navigation — either when it’s by mistake, or the page is taking too long.

Listing 270. app/browser.py

self.httpsicon = QLabel() # Yes, really!

self.httpsicon.setPixmap(QPixmap(Paths.icon("lock-

nossl.png")))

navtb.addWidget(self.httpsicon)

self.urlbar = QLineEdit()

self.urlbar.returnPressed.connect(self.navigate_to_url)

navtb.addWidget(self.urlbar)

stop_btn = QAction(

QIcon(Paths.icon("cross-circle.png")), "Stop", self

)

stop_btn.setStatusTip("Stop loading current page")

stop_btn.triggered.connect(self.browser.stop)

navtb.addAction(stop_btn)

As before the 'stop' functionality is available on the QWebEngineView, and we can simply connect the .triggered signal from the stop button to the existing slot. However, other features of the URL bar we must handle independently.

First we add a QLabel to hold our SSL or non-SSL icon to indicate whether the page is secure. Next, we add the URL bar which is simply a QLineEdit. To trigger the loading of the URL in the bar when entered (return key pressed) we connect to the .returnPressed signal on the widget to drive a custom slot function to trigger navigation to the specified URL.

Listing 271. app/browser.py

def navigate_to_url (self): # Does not receive the Url

q = QUrl(self.urlbar.text())

if q.scheme() == "":

q.setScheme("http")

self.browser.setUrl(q)

We also want the URL bar to update in response to page changes. To do this we can use the .urlChanged and .loadFinished signals from the QWebEngineView. We set up the connections from the signals in the init block as follows:

Listing 272. app/browser.py

self.browser.urlChanged.connect(self.update_urlbar)

self.browser.loadFinished.connect(self.update_title)

Then we define the target slot functions which for these signals. The first, to update the URL bar accepts a QUrl object and determines whether this is a http or https URL, using this to set the SSL icon.

q

This is a terrible way to test if a connection is 'secure'. To be

correct we should perform a certificate validation.

The QUrl is converted to a string and the URL bar is updated with the value. Note that we also set the cursor position back to the beginning of the line to prevent the QLineEdit widget scrolling to the end.

Listing 273. app/browser.py

def update_urlbar (self, q):

if q.scheme() == "https":

# Secure padlock icon

self.httpsicon.setPixmap(

QPixmap(Paths.icon("lock-ssl.png"))

)

else :

# Insecure padlock icon

self.httpsicon.setPixmap(

QPixmap(Paths.icon("lock-nossl.png"))

)

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

self.urlbar.setCursorPosition( 0 )

It’s also a nice touch to update the title of the application window with the title of the current page. We can get this via browser.page().title() which returns the contents of the tag in the currently loaded web page.

Listing 274. app/browser.py

def update_title (self):

title = self.browser.page().title()

self.setWindowTitle("%s - Mozzarella Ashbadger" % title)

File operations

A standard File menu with self.menuBar().addMenu("&File") is created assigning the F key as a Alt-shortcut (as normal). Once we have the menu object, we can can assign QAction objects to create the entries. We create two basic entries here, for opening and saving HTML files (from a local disk). These both require custom slot functions.

Listing 275. app/browser.py

file_menu = self.menuBar().addMenu("&File")

open_file_action = QAction(

QIcon(Paths.icon("disk--arrow.png")),

"Open file...",

self,

)

open_file_action.setStatusTip("Open from file")

open_file_action.triggered.connect(self.open_file)

file_menu.addAction(open_file_action)

save_file_action = QAction(

QIcon(Paths.icon("disk--pencil.png")),

"Save Page As...",

self,

)

save_file_action.setStatusTip("Save current page to file")

save_file_action.triggered.connect(self.save_file)

file_menu.addAction(save_file_action)

The slot function for opening a file uses the built-in QFileDialog.getOpenFileName() function to create a file-open dialog and get a name. We restrict the names by default to files matching *.htm or *.html.

We read the file into a variable html using standard Python functions, then use .setHtml() to load the HTML into the browser.

Listing 276. app/browser.py

def open_file (self):

filename, _ = QFileDialog.getOpenFileName(

self,

"Open file",

"",

"Hypertext Markup Language (*.htm *.html);;"

"All files (*.*)",

)

if filename:

with open(filename, "r") as f:

html = f.read()

self.browser.setHtml(html)

self.urlbar.setText(filename)

Similarly to save the HTML from the current page, we use the built-in QFileDialog.getSaveFileName() to get a filename. However, this time we get the HTML using self.browser.page().toHtml().

This is an asynchronous method, meaning that we do not receive the HTML immediately. Instead we must pass in a callback method which will receive the HTML once it is prepared. Here we create a simple writer function that handles it for us, using the filename from the local scope.

Listing 277. app/browser.py

def save_file (self):

filename, _ = QFileDialog.getSaveFileName(

self,

"Save Page As",

"",

"Hypertext Markup Language (*.htm *html);;"

"All files (*.*)",

)

if filename:

# Define callback method to handle the write.

def writer (html):

with open(filename, "w") as f:

f.write(html)

self.browser.page().toHtml(writer)

Printing

We can add a print option to the File menu using the same approach we used earlier. Again this needs a custom slot function to perform the print action.

Listing 278. app/browser.py

print_action = QAction(

QIcon(Paths.icon("printer.png")), "Print...", self

)

print_action.setStatusTip("Print current page")

print_action.triggered.connect(self.print_page)

file_menu.addAction(print_action)

# Create our system printer instance.

self.printer = QPrinter()

Qt provides a complete print framework which is based around QPrinter objects, on which you paint the pages to be printed. To start the process we open a QPrintDialog for the user. This allows them to choose the target printer and

configure the print.

We created the QPrinter object in our init and stored it as self.printer. In our print handler method we pass this printer to the QPrintDialog so it can be configured. If the dialog is accepted we pass the (now configured) printer object to self.browser.page().print to trigger the print.

Listing 279. app/browser.py

def print_page (self):

page = self.browser.page()

def callback (*args):

pass

dlg = QPrintDialog(self.printer)

dlg.accepted.connect(callback)

if dlg. exec () == QDialog.DialogCode.Accepted:

page. print (self.printer, callback)

Notice that .print also accepts a second parameter — a callback function which receives the result of the print. This allows you to show a notification that the print has completed, but here we’re just swallowing the callback silently.

Help

Finally, to complete the standard interface we can add a Help menu. This is defined as before, two two custom slot functions to handle the display of a About dialog, and to load the 'browser page' with more information.

Listing 280. app/browser.py

help_menu = self.menuBar().addMenu("&Help")

about_action = QAction(

QIcon(Paths.icon("question.png")),

"About Mozzarella Ashbadger",

self,

)

about_action.setStatusTip(

"Find out more about Mozzarella Ashbadger"

) # Hungry!

about_action.triggered.connect(self.about)

help_menu.addAction(about_action)

navigate_mozzarella_action = QAction(

QIcon(Paths.icon("lifebuoy.png")),

"Mozzarella Ashbadger Homepage",

self,

)

navigate_mozzarella_action.setStatusTip(

"Go to Mozzarella Ashbadger Homepage"

)

navigate_mozzarella_action.triggered.connect(

self.navigate_mozzarella

)

help_menu.addAction(navigate_mozzarella_action)

We define two methods to be used as slots for the Help menu signals. The first navigate_mozzarella opens up a page with more information on the browser (or in this case, this book). The second creates and executes a custom QDialog class AboutDialog which we will define next.

Listing 281. app/browser.py

def navigate_mozzarella (self):

self.browser.setUrl(QUrl("https://www.pythonguis.com/"))

def about (self):

dlg = AboutDialog()

dlg. exec ()

The definition for the about dialog is given below. The structure follows that seen earlier in the book, with a QDialogButtonBox and associated signals to handle user input, and a series of QLabels to display the application information and a logo.

The only trick here is adding all the elements to the layout, then iterate over them to set the alignment to the center in a single loop. This saves duplication for the individual sections.

Listing 282. app/browser.py

class AboutDialog (QDialog):

def __init__ (self):

super().__init__()

QBtn = QDialogButtonBox.StandardButton.Ok # No cancel

self.buttonBox = QDialogButtonBox(QBtn)

self.buttonBox.accepted.connect(self.accept)

self.buttonBox.rejected.connect(self.reject)

layout = QVBoxLayout()

title = QLabel("Mozzarella Ashbadger")

font = title.font()

font.setPointSize( 20 )

title.setFont(font)

layout.addWidget(title)

logo = QLabel()

logo.setPixmap(QPixmap(Paths.icon("ma-icon-128.png")))

layout.addWidget(logo)

layout.addWidget(QLabel("Version 23.35.211.233232"))

layout.addWidget(QLabel("Copyright 2015 Mozzarella Inc."))

for i in range( 0 , layout.count()):

layout.itemAt(i).setAlignment(Qt.AlignmentFlag

.AlignHCenter)

layout.addWidget(self.buttonBox)

self.setLayout(layout)

Tabbed Browsing

Figure 270. Mozzarella Ashbadger (Tabbed).

Source code

The full source for the tabbed browser is included in the downloads for this book. The browser code has the name browser_tabs.py.

 Run it! Explore the Mozzarella Ashbadger Tabbed Edition before moving

onto the code.

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 customizations we use here are .setDocumentMode(True) which provides a Safari-like interface on macOS, 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 behaviors.

Listing 283. app/browser_tabs.py

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.

Listing 284. app/browser_tabs.py

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)

Listing 285. app/browser_tabs.py

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.

We can do this using our trick for sending additional data with signals. In the tabbed browser we’re using the lambda style syntax to do this.

Below is an example of doing this when creating a new QWebEngineView in the add_new_tab function.

Listing 286. app/browser_tabs.py

# 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.

The result is, whenever this 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.

Listing 287. app/browser_tabs.py

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(Paths.icon("lock-ssl.png"))

)

else :

# Insecure padlock icon

self.httpsicon.setPixmap(

QPixmap(Paths.icon("lock-nossl.png"))

)

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

self.urlbar.setCursorPosition( 0 )

Going further

Explore the rest of the source code for the tabbed version of the browser paying particular attention to the user of self.tabs.currentWidget() and passing additional data with signals. This a good practical use case for what you’ve learnt, so experiment and see if you can break/improve it in interesting ways.

ë

Challenges

You might like to try adding some additional features —

  • Bookmarks (or Favorites) — you could store these in a simple text file, and show them in a menu.
  • Favicons — those little website icons, would look great on the tabs.
  • View source code — add a menu option to see the source code for the page.
  • Open in New Tab — add a right-click context menu, or keyboard shortcut, to open a link in a new tab.

'PyQt5_' 카테고리의 다른 글

Installing PyQt6  (0) 2023.03.16
Moonsweeper  (0) 2023.03.16
Using Custom Widgets in QtDesigner  (0) 2023.03.16
PyQt6 and PySide6 — What’s the difference?  (0) 2023.03.13
Translating C++ Examples to Python.  (0) 2023.03.13

댓글