Search code examples
pythonpyqtpyqt5

Run Function in the Background and Update UI


I am using PyQt to make a GUI for a project.

Screenshot of GUI

After inputting a number and submitting it, I need to execute the function that would run in a background, otherwise the app freezes until the process is finished.

I also need to output logs in the dark box that are produced by the function.

This is the GUI code:

import sys
from PyQt5.QtWidgets import (
    QWidget, 
    QDesktopWidget, 
    QLineEdit, 
    QGridLayout, 
    QLabel,
    QFrame,
    QPushButton,
    QApplication,
    QTextEdit
)
from PyQt5.QtGui import (QTextCursor)
from bot.bot import (run, slack_notification)
from multiprocessing import Process, Pipe

class LogginOutput(QTextEdit):
    def __init__(self, parent=None):
        super(LogginOutput, self).__init__(parent)

        self.setReadOnly(True)
        self.setLineWrapMode(self.NoWrap)

        self.insertPlainText("")

    def append(self, text):
        self.moveCursor(QTextCursor.End)
        current = self.toPlainText()

        if current == "":
            self.insertPlainText(text)
        else:
            self.insertPlainText("\n" + text)

        sb = self.verticalScrollBar()
        sb.setValue(sb.maximum())

class App(QWidget):
    def __init__(self):
        super().__init__()

        self.init_ui()

    def init_ui(self):
        label = QLabel('Amount')
        amount_input = QLineEdit()
        submit = QPushButton('Submit', self)
        box = LogginOutput(self)

        submit.clicked.connect(lambda: self.changeLabel(box, amount_input))

        grid = QGridLayout()
        grid.addWidget(label, 0, 0)
        grid.addWidget(amount_input, 1, 0)
        grid.addWidget(submit, 1, 1)
        grid.addWidget(box, 2, 0, 5, 2)

        self.setLayout(grid)
        self.resize(350, 250)
        self.setWindowTitle('GetMeStuff Bot v0.1')
        self.show()

    def center(self):
        qr = self.frameGeometry()
        cp = QDesktopWidget().availableGeometry().center()
        qr.moveCenter(cp)
        self.move(qr.topLeft())

    def changeLabel(self, box, user_input):
        p = Process(target=run, args=(user_input.displayText(), box))
        p.start()
        user_input.clear()


if __name__ == '__main__':
    app = QApplication(sys.argv)
    widget = App()
    sys.exit(app.exec_())

And the run function:

def run(user_input, log):
    if user_input == "":
        log.append("Please enter a value\n")
    else:
        log.append("Test")

In order to run the function in the background, I have tried to use Process, but when I execute the append function, the GUI doesn't update.


Solution

  • The GUI should not be updated from another thread since Qt creates a loop where the application lives, although python provides many alternatives for works with threads, often these tools do not handle the logic of Qt so they can generate problems. Qt provides classes that perform this type of tasks with QThread (low-level), but this time I will use QRunnable and QThreadPool, I have created a class that behaves the same as Process:

    class ProcessRunnable(QRunnable):
        def __init__(self, target, args):
            QRunnable.__init__(self)
            self.t = target
            self.args = args
    
        def run(self):
            self.t(*self.args)
    
        def start(self):
            QThreadPool.globalInstance().start(self)
    

    Use:

    self.p = ProcessRunnable(target=run, args=(user_input.displayText(), box))
    self.p.start()
    

    Also as I said before, you should not update the GUI directly from another thread, a solution is to use signals, or in this case, for simplicity, use QMetaObject.invokeMethod:

    def run(user_input, log):
        text = ""
        if user_input == "":
            text = "Please enter a value\n"
        else:
            text = "Test"
    
        QMetaObject.invokeMethod(log,
                    "append", Qt.QueuedConnection, 
                    Q_ARG(str, text))
    

    To be invoked correctly this must be a slot, for this we use a decorator:

    class LogginOutput(QTextEdit):
        # ...
        @pyqtSlot(str)
        def append(self, text):
            self.moveCursor(QTextCursor.End)
            # ...
    

    The complete and workable example is in the following code

    import sys
    from PyQt5.QtWidgets import *
    from PyQt5.QtGui import *
    from PyQt5.QtCore import *
    
    class ProcessRunnable(QRunnable):
        def __init__(self, target, args):
            QRunnable.__init__(self)
            self.t = target
            self.args = args
    
        def run(self):
            self.t(*self.args)
    
        def start(self):
            QThreadPool.globalInstance().start(self)
    
    def run(user_input, log):
        text = ""
        if user_input == "":
            text = "Please enter a value\n"
        else:
            text = "Test"
    
        QMetaObject.invokeMethod(log,
                    "append", Qt.QueuedConnection, 
                    Q_ARG(str, text))
    
    class LogginOutput(QTextEdit):
        def __init__(self, parent=None):
            super(LogginOutput, self).__init__(parent)
    
            self.setReadOnly(True)
            self.setLineWrapMode(self.NoWrap)
            self.insertPlainText("")
    
        @pyqtSlot(str)
        def append(self, text):
            self.moveCursor(QTextCursor.End)
            current = self.toPlainText()
    
            if current == "":
                self.insertPlainText(text)
            else:
                self.insertPlainText("\n" + text)
    
            sb = self.verticalScrollBar()
            sb.setValue(sb.maximum())
    
    class App(QWidget):
        def __init__(self):
            super().__init__()
    
            self.init_ui()
    
        def init_ui(self):
            label = QLabel('Amount')
            amount_input = QLineEdit()
            submit = QPushButton('Submit', self)
            box = LogginOutput(self)
    
            submit.clicked.connect(lambda: self.changeLabel(box, amount_input))
    
            grid = QGridLayout()
            grid.addWidget(label, 0, 0)
            grid.addWidget(amount_input, 1, 0)
            grid.addWidget(submit, 1, 1)
            grid.addWidget(box, 2, 0, 5, 2)
    
            self.setLayout(grid)
            self.resize(350, 250)
            self.setWindowTitle('GetMeStuff Bot v0.1')
            self.show()
    
        def center(self):
            qr = self.frameGeometry()
            cp = QDesktopWidget().availableGeometry().center()
            qr.moveCenter(cp)
            self.move(qr.topLeft())
    
        def changeLabel(self, box, user_input):
            self.p = ProcessRunnable(target=run, args=(user_input.displayText(), box))
            self.p.start()
            user_input.clear()
    
    
    if __name__ == '__main__':
        app = QApplication(sys.argv)
        widget = App()
        sys.exit(app.exec_())