Friday, March 20, 2020

Learn PyQt: MooseAche

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

QtWebEngineWidgets is not included in the main PyQt5 repository. If you see errors when running this relating to this module, you can install it using pip install PyQtWebEngine

The full source code for MooseAche 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 MooseAche with:

python3 browser.py

Read on for a walkthrough of how the code works.

The browser widget

The core of our browser is the QWebView which we import from PyQt5. 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 PyQt.

from PyQt5.QtCore import *
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtWebEngineWidgets import *

import sys

class MainWindow(QMainWindow):

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

        self.browser = QWebEngineView()
        self.browser.setUrl(QUrl("http://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.

To convert this bare-bones browser into something usable we can add some controls, as a series of QActions on a QToolbar. We add these definitions to the __init__ block of the QMainWindow.

navtb = QToolBar("Navigation")
navtb.setIconSize( QSize(16,16) )
self.addToolBar(navtb)

back_btn = QAction( QIcon(os.path.join('icons','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.

next_btn = QAction( QIcon(os.path.join('icons','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(os.path.join('icons','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(os.path.join('icons','home.png')), "Home", self)
home_btn.setStatusTip("Go home")
home_btn.triggered.connect( self.navigate_home )
navtb.addAction(home_btn)

While forward, back and reload can use built-in slots to perform their actions, 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.

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

Any decent web browser also needs an URL bar, and some way to stop the navigation.

self.httpsicon = QLabel() # Yes, really!
self.httpsicon.setPixmap( QPixmap( os.path.join('icons','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(os.path.join('icons','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 as a slot on the QWebEngineView itself, 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.

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:

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.

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.

def update_urlbar(self, q):

    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)

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 <title></title> tag in the currently loaded web page.

def update_title(self):
    title = self.browser.page().title()
    self.setWindowTitle("%s - Mozarella Ashbadger" % title)

File operations

A File menu was added with self.menuBar().addMenu("&File") assigning the F key as a Alt-shortcut. Once we have the menu object, we can can add QAction objects to it 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 method.

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

open_file_action = QAction( QIcon( os.path.join('icons','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( os.path.join('icons','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 method for opening a file uses the built-in
`QFileDialog.getOpenFileName()` method 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.

```python
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 from self.browser.page().toHtml() and write it to the selected filename. Again we use standard Python functions for the file handler.

def save_file(self):
    filename, _ = QFileDialog.getSaveFileName(self, "Save Page As", "",
                    "Hypertext Markup Language (*.htm *html);;"
                    "All files (*.*)")

    if filename:
        html = self.browser.page().toHtml()
        with open(filename, 'w') as f:
            f.write(html)

Help

Finally, to complete the standard interface we can add a Help menu. We add two custom slot methods to handle the display of the dialog, and to load the 'browser page' with more information.

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

about_action = QAction( QIcon( os.path.join('icons','question.png') ), "About Mozarella Ashbadger", self)
about_action.setStatusTip("Find out more about Mozarella Ashbadger") # Hungry!
about_action.triggered.connect( self.about )
help_menu.addAction(about_action)

navigate_mozarella_action = QAction( QIcon( os.path.join('icons','lifebuoy.png') ), "Mozarella Ashbadger Homepage", self)
navigate_mozarella_action.setStatusTip("Go to Mozarella Ashbadger Homepage")
navigate_mozarella_action.triggered.connect( self.navigate_mozarella )
help_menu.addAction(navigate_mozarella_action)

The first method navigate_mozzarella opens up a page with more information on the browser, the second creates and executes a custom QDialog class AboutDialog.

def navigate_mozarella(self):
    self.browser.setUrl( QUrl("https://martinfitzpatrick.name/create-simple-gui-applications") )

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.

class AboutDialog(QDialog):

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

        QBtn = QDialogButtonBox.Ok  # No cancel
        self.buttonBox = QDialogButtonBox(QBtn)
        self.buttonBox.accepted.connect(self.accept)
        self.buttonBox.rejected.connect(self.reject)

        layout = QVBoxLayout()

        title = QLabel("Mozarella Ashbadger")
        font = title.font()
        font.setPointSize(20)
        title.setFont(font)

        layout.addWidget(title)

        logo = QLabel()
        logo.setPixmap( QPixmap( os.path.join('icons','ma-icon-128.png') ) )
        layout.addWidget(logo)

        layout.addWidget( QLabel("Version 23.35.211.233232") )
        layout.addWidget( QLabel("Copyright 2015 Mozarella Inc.") )

        for i in range(0, layout.count() ):
            layout.itemAt(i).setAlignment( Qt.AlignHCenter )

        layout.addWidget(self.buttonBox)

        self.setLayout(layout)

Further ideas

If you're looking for a browser which supports tabbed browsing, check out Mozzarella Ashbadger. This is based on this same code, but with the addition of tabs, and using signal-redirection to route behaviours based on the active tabs.



from Planet Python
via read more

No comments:

Post a Comment

TestDriven.io: Working with Static and Media Files in Django

This article looks at how to work with static and media files in a Django project, locally and in production. from Planet Python via read...