Search code examples
pythonpyqt5qtimerqeventloop

How to terminate a QEventLoop when a Qt Dialog window is closed


In order to have more hands-on experience with Python and creating GUIs, I decided to create a flashcards quiz app. Initially, I created a simple function that accepted a csv file name, shuffled the answers and the questions, and implemented a for loop to ask the user to input an answer for each question. I ran it through CMD, and it functioned perfectly.

Next, I used the Qt designer to create a simple Qt Dialog window with a textBrowser for ouput and a lineEdit for input. (Side note: I know that you are not supposed to modify the generated ui file, so I copied the code and saved it to a difference directory so I could work with it safely.) I put the quizzing function inside of the Dialog class, and have it called on the app's execution. However, in order to wait for user input to be entered, I needed to add a QEventLoop to the quizzing function that starts after the question is posed and quits when lineEdit.returnPressed is triggered. If I cycle through the entire deck of cards, the shuffle function gets completed and when I close the GUI (via the X button) the code stops regularly. But if I try closing the window between a question getting asked and being answered (while QEventLoop is running), the GUI closes but the function is still running, and the aboutToQuit detector I set up doesn't get triggered.

I'm pretty sure that this issue is because the quizzing function gets hung on executing the QEventLoop, and as of yet I have not found a successful way to register that GUI has closed and quit the QEventLoop without finishing the entire question/answer loop. Would having the window and the QEventLoop run synchronously fix my problem? Is there a way to prematurely break out of the QEventLoop in the case of an event like the function's window closing? Or should I be using a different process like QTimer here?

# If this helps, here's the code for the program. 

from PyQt5 import QtCore, QtWidgets
from PyQt5.QtWidgets import *
import csv
import random
import sys

class Ui_Dialog(QWidget):
    loop = QtCore.QEventLoop()

    def setupUi(self, Dialog):
        Dialog.setObjectName("Dialog")
        Dialog.resize(361, 163)

        self.lineEdit = QtWidgets.QLineEdit(Dialog)
        self.lineEdit.setGeometry(QtCore.QRect(20, 120, 321, 20))
        self.lineEdit.setObjectName("lineEdit")
        self.lineEdit.returnPressed.connect(self.acceptText)
        self.textBrowser = QtWidgets.QTextBrowser(Dialog)
        self.textBrowser.setGeometry(QtCore.QRect(20, 20, 321, 91))
        self.retranslateUi(Dialog)
        QtCore.QMetaObject.connectSlotsByName(Dialog)

    def retranslateUi(self, Dialog):
        _translate = QtCore.QCoreApplication.translate
        Dialog.setWindowTitle(_translate("Dialog", "Dialog"))

    def printText(self, contents):
        self.textBrowser.append(contents)

    def acceptText(self):
        input = self.lineEdit.text().strip().lower()
        self.loop.quit()
        return input

    def shuffleDeck(self, filename):
        # sets up values to be referenced later
        questions = []
        answers = []
        index = 0
        numright = 0
 
        # contains the entire reading, shuffling, and quizzing process
        with open(filename, encoding='utf-8') as tsv:
            reader = csv.reader(tsv, delimiter="\t")

            for row in reader:
                questions.append(row[0][::-1])
                answers.append(row[1].lower())
            seed = random.random()
            random.seed(seed)
            random.shuffle(questions)
            random.seed(seed)
            random.shuffle(answers)

            for question in questions:
                # handles input & output
                self.printText("What does " + question + " mean?")
                self.loop.exec_()
                guess = self.acceptText()
                self.textBrowser.append(guess)
                self.lineEdit.clear()

            # compares input to answer, returns correct/incorrect prompts accordingly
                if guess == answers[index]:
                    self.printText("You are right!")
                    index += 1
                    numright += 1
                else:
                    self.printText("You are wrong. The answer is " + str(answers[index]) + "; better luck next time!")
                    index += 1
            self.printText("You got " + str(round(numright / len(questions), 2) * 100) + "% (" + str(
            numright) + ") of the cards right.")

if __name__ == "__main__":
    app = QtWidgets.QApplication(sys.argv)
    Dialog = QtWidgets.QDialog()
    ui = Ui_Dialog()
    ui.setupUi(Dialog)
    Dialog.show()
    # I temporarily hardcoded a csv file
    ui.shuffleDeck("Decks/Animals.csv")
    # linear processing requires shuffleDeck to be completed before the window loops, right?
    sys.exit(app.exec_())

#An example of the csv text would be:
מאוד    VERY
עוד MORE
כמו כ   AS
שם  THERE
#I would have included only English characters, but this is the deck that I hardcoded in. 

Solution

  • As you have already been pointed out, you should not modify the code generated by pyuic, so you must regenerate the file using the following command:

    pyuic5 filename.ui -o design_ui.py -x
    

    On the other hand, it is not necessary or advisable to use QEventLoop since they can generate unexpected behaviors, in this case it is enough to use an iterator.

    You must also separate the logic from the GUI, for example create a data structure that relates the questions to the answers, and another class that provides and manages the tests.

    And finally you only have to implement the GUI logic handling the signals that are emitted when the user interacts.

    main.py

    import csv
    from dataclasses import dataclass
    import random
    import sys
    
    from PyQt5 import QtWidgets
    
    from design_ui import Ui_Dialog
    
    
    @dataclass
    class Test:
        question: str
        answer: str
    
        def verify(self, answer):
            return self.answer == answer
    
    
    class TestProvider:
        def __init__(self, tests):
            self._tests = tests
            self._current_test = None
            self.init_iterator()
    
        def init_iterator(self):
            self._test_iterator = iter(self.tests)
    
        @property
        def tests(self):
            return self._tests
    
        @property
        def number_of_tests(self):
            return len(self._tests)
    
        @property
        def current_test(self):
            return self._current_test
    
        def next_text(self):
            try:
                self._current_test = next(self._test_iterator)
            except StopIteration as e:
                return False
            else:
                return True
    
    
    class Dialog(QtWidgets.QDialog, Ui_Dialog):
        def __init__(self, parent=None):
            super().__init__(parent)
            self.setupUi(self)
    
            self.lineEdit.returnPressed.connect(self.handle_pressed)
            self.lineEdit.setEnabled(False)
    
        def load_tests_from_filename(self, filename):
            self._number_of_correct_answers = 0
            tests = []
            with open(filename, encoding="utf-8") as tsv:
                reader = csv.reader(tsv, delimiter="\t")
                for row in reader:
                    question, answer = row
                    test = Test(question, answer)
                    tests.append(test)
            seed = random.random()
            random.seed(seed)
            random.shuffle(tests)
            self._test_provider = TestProvider(tests)
            self.load_test()
            self.lineEdit.setEnabled(True)
    
        def load_test(self):
            if self._test_provider.next_text():
                self.print_text(
                    f"What does {self._test_provider.current_test.question} mean?"
                )
                return True
            return False
    
        def handle_pressed(self):
            if self._test_provider is None:
                return
            guess = self.lineEdit.text().strip().lower()
            self.textBrowser.append(guess)
            if self._test_provider.current_test.answer.strip().lower() == guess:
                self.print_text("You are right!")
                self._number_of_correct_answers += 1
            else:
                self.print_text(
                    f"You are wrong. The answer is {self._test_provider.current_test.answer}; better luck next time!"
                )
    
            self.lineEdit.clear()
            if not self.load_test():
                self.print_text(
                    f"You got {(round(self._number_of_correct_answers / self._test_provider.number_of_tests, 2) * 100)}% ({self._number_of_correct_answers}) of the cards right."
                )
                self.lineEdit.setEnabled(False)
    
        def print_text(self, text):
            self.textBrowser.append(text)
    
    
    if __name__ == "__main__":
        app = QtWidgets.QApplication(sys.argv)
        w = Dialog()
        w.load_tests_from_filename("Decks/Animals.csv")
        w.show()
        sys.exit(app.exec_())