2

I'm working on an asset management app for our company. It's a standalone Python3 app using PySide2 and talking to our database backend. One of the views I'm writing is supposed to be a HTML5-style responsive gallery: assets are displayed as thumbnails, on mouse-over they display extra information, and on click they initiate an action (eg opening the asset in the appropriate app).

What's the best way to implement this in PySide2/PyQt5?

Since I'd feel comfortable implementing and styling something like this in HTML5, I'm inclined to do it with QWebEngineView and dynamically generate HTML and CSS in python, then use QWebEngineView.setHtml() to display it.

Is this a good way to do it inside a PySide2 app, that doesn't use HTML otherwise? Are there more Qt-ish ways to achieve a dynamic, responsive, style-able gallery?

If I would use QWebEngineView, how would I intercept the user clicking on one of the HTML elements? I found this question, which sounds like it could be a solution for this: Capture server response with QWebEngineView . Is there a simpler solution?

eyllanesc
  • 235,170
  • 19
  • 170
  • 241
maxdownunder
  • 5,629
  • 3
  • 19
  • 21

2 Answers2

1

Qt offers many alternatives for what you want (They are not complete solutions since you do not clearly indicate what you require):


The implementation of the mouse-over effect will not be implemented because I am not an expert in frontend, but I will focus on communication between the parties.


To communicate Python information to JS, you can do it with the runJavaScript() method of QWebEnginePage and/or with QWebChannel, and the reverse part with QWebChannel (I don't rule out the idea that QWebEngineUrlRequestInterceptor could be an alternative solution but in this case the previous solutions they are simpler). So in this case I will use QWebChannel.

The idea is to register a QObject that sends the information through signals (in this case JSON), by the side of javascript parsing the JSON and creating dynamic HTML, then before any event such as the click call a slot of the QObject.

Considering the above, the solution is:

├── index.html
├── index.js
└── main.py
import json
from PySide2 import QtCore, QtGui, QtWidgets, QtWebEngineWidgets, QtWebChannel


class GalleryManager(QtCore.QObject):
    dataChanged = QtCore.Signal(str)

    def __init__(self, parent=None):
        super().__init__(parent)
        self._data = []
        self._is_loaded = False

    @QtCore.Slot(str)
    def make_action(self, identifier):
        print(identifier)

    @QtCore.Slot()
    def initialize(self):
        self._is_loaded = True
        self.send_data()

    def send_data(self):
        if self._is_loaded:
            self.dataChanged.emit(json.dumps(self._data))

    @property
    def data(self):
        return self._data

    @data.setter
    def data(self, d):
        self._data = d
        self.send_data()


if __name__ == "__main__":
    import os
    import sys

    # sys.argv.append("--remote-debugging-port=8000")

    app = QtWidgets.QApplication(sys.argv)

    current_dir = os.path.dirname(os.path.realpath(__file__))

    view = QtWebEngineWidgets.QWebEngineView()
    channel = QtWebChannel.QWebChannel(view)
    gallery_manager = GalleryManager(view)
    channel.registerObject("gallery_manager", gallery_manager)
    view.page().setWebChannel(channel)

    def on_load_finished(ok):
        if not ok:
            return
        data = []
        for i, path in enumerate(
            (
                "https://source.unsplash.com/pWkk7iiCoDM/400x300",
                "https://source.unsplash.com/aob0ukAYfuI/400x300",
                "https://source.unsplash.com/EUfxH-pze7s/400x300",
                "https://source.unsplash.com/M185_qYH8vg/400x300",
                "https://source.unsplash.com/sesveuG_rNo/400x300",
                "https://source.unsplash.com/AvhMzHwiE_0/400x300",
                "https://source.unsplash.com/2gYsZUmockw/400x300",
                "https://source.unsplash.com/EMSDtjVHdQ8/400x300",
                "https://source.unsplash.com/8mUEy0ABdNE/400x300",
                "https://source.unsplash.com/G9Rfc1qccH4/400x300",
                "https://source.unsplash.com/aJeH0KcFkuc/400x300",
                "https://source.unsplash.com/p2TQ-3Bh3Oo/400x300",
            )
        ):
            d = {"url": path, "identifier": "id-{}".format(i)}
            data.append(d)
        gallery_manager.data = data

    view.loadFinished.connect(on_load_finished)

    filename = os.path.join(current_dir, "index.html")
    view.load(QtCore.QUrl.fromLocalFile(filename))
    view.resize(640, 480)
    view.show()

    sys.exit(app.exec_())
<!DOCTYPE html>
<html>

<head>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">

    <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>

    <script src="qrc:///qtwebchannel/qwebchannel.js"></script>

    <script type="text/javascript" src="index.js"> </script>

</head>

<body>
    <div class="container">
        <h1 class="font-weight-light text-center text-lg-left mt-4 mb-0">Thumbnail Gallery</h1>
        <hr class="mt-2 mb-5">
        <div id="container" class="row text-center text-lg-left">
        </div>
    </div>
</body>
</html>
var gallery_manager = null;

new QWebChannel(qt.webChannelTransport, function (channel) {
    gallery_manager = channel.objects.gallery_manager;

    gallery_manager.dataChanged.connect(populate_gallery);
    gallery_manager.initialize();
});


function populate_gallery(data) {
    const container = document.getElementById('container');
    // clear
    while (container.firstChild) {
        container.removeChild(container.firstChild);
    }
    // parse json
    var d = JSON.parse(data);
    // fill data
    for (const e of d) {
        var identifier = e["identifier"];
        var url = e["url"];
        var div_element = create_div(identifier, url) 
        container.appendChild(div_element);
    }

}

function create_div(identifier, url){
    var html = `
        <div class="d-block mb-4 h-100">
            <img class="img-fluid img-thumbnail" src="${url}" alt="">
        </div>
        `
    var div_element = document.createElement("div");
    div_element.className = "col-lg-3 col-md-4 col-6"
    div_element.innerHTML = html;
    div_element.addEventListener('click', function (event) {
        gallery_manager.make_action(identifier);
    });
    return div_element;
}
eyllanesc
  • 235,170
  • 19
  • 170
  • 241
  • Thanks @eyllanesc for the nice and detailed answer! So far, I've always done either PySide/PyQt apps, or WebApps. The idea of being able to use HTML5 elements inside my PySide apps is exciting! Thanks for explaining QWebChannel, I had not come across it before! – maxdownunder Oct 09 '19 at 06:05
0

Loving the answer from @eyllanesc using QWebChannel! I had not come across this and hadn't dared to dream about having AJAX-style communication between a PySide app and a WebView! Loving it!

Here's a less flexible/elaborate alternative I had come up with meanwhile, using QWebEnginePage.acceptNavigationRequest(). I much prefer the QWebChannel answer, but others might find this option helpfull as well.

from PySide2.QtWebEngineWidgets import QWebEngineView, QWebEnginePage



class MyPage(QWebEnginePage):
    def acceptNavigationRequest(self, url, type, isMainFrame):
        print(url, type, isMainFrame)
        if url.toString().startswith('app://'):
            print('intercepted click, do stuff')
            return False
        return True



def createHtml():
    html = """
    <html>
        <head>
            <style>
            .item {
                position: relative;
            }
            .animgif {
                display: none;
                position: absolute;
                top: 0;
                left: 0;
            }
            .item:hover .animgif {
                display: block;
            }
            </style>
        </head>
        <body>
            <a href="app://action/click?id=1234">
                <div class="item">
                    <img class="thumb" src="file://server01/path/to/thumbnail.png">
                    <img class="animgif" src="file://server/path/to/thumbnail.gif">
                </div>
            </a>
        </body>
    </html>
    """
    return html




if __name__ == '__main__':
    import sys
    from PySide2 import QtWidgets

    app = QtWidgets.QApplication(sys.argv)

    page = MyPage()

    view = QWebEngineView()
    view.setPage(page)

    html = createHtml()
    baseUrl = "file://server01/"
    page.setHtml(html, baseUrl=baseUrl)

    view.show()

    sys.exit(app.exec_())

The idea is to create html dynamically and use page.setHtml(html) to load it on the view. In this example the createHtml() function is rudimentary, but shows the intention. Subclassing QWebEnginePage allows you to override acceptNavigationRequest(), which allows you to intercept clicks and decide what to do with them. In this example I chose to use and detect a protocol 'app://' and act on in accordingly.

One more note is that in our case, all files/images/ect live on the local file system. To avoid a cross-origin security exception, I had to provide baseUrl in setHtml() and set it to the same file path that the files are hosted on.

html = createHtml()
baseUrl = "file://server01/"
page.setHtml(html, baseUrl=baseUrl)
maxdownunder
  • 5,629
  • 3
  • 19
  • 21