6

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.

eyllanesc
  • 235,170
  • 19
  • 170
  • 241
dan070
  • 346
  • 1
  • 4
  • 18
  • Your code only shows how you have built the GUI, it shows nothing of the main problem that you mention, you talk about Process and you do not show how you are using it, if you want us to help you, you must provide a [mvce]. – eyllanesc Nov 29 '17 at 19:49
  • Sorry, thought the explanation would be enough. Added the mission code. – dan070 Nov 29 '17 at 20:01

3 Answers3

12

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_())
eyllanesc
  • 235,170
  • 19
  • 170
  • 241
  • 1
    I tested this and it did not work for me (?!). I put ` import time while True: time.sleep(1) print(1)` in the slot function and it is still blocking the gui. – tbrodbeck Jan 10 '21 at 20:51
1

Maybe useful to some: This is the same example as the one from eyllanesc, but with some fixes so that it works on PySide6.

import sys
import time
from PySide6.QtWidgets import *
from PySide6.QtGui import *
from PySide6.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"
        # Sleep for 5 seconds
        time.sleep(5)

    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.LineWrapMode.NoWrap)

        self.insertPlainText("")

    @Slot(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 = QGuiApplication.primaryScreen().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_())

Pux
  • 421
  • 3
  • 18
0

A gui app will always need it's own blocking loop, so you are right in that you can go to either threads or process. However I believe that once you are in the Qt world you also have to use the provided tools for spawning.

Try PyQt5.QtCore.QProcess or PyQt5.QtCore.QThread.

I'm sure that you can find an example in the wild that suits you.

ahed87
  • 1,240
  • 10
  • 10