0

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. 
khayni
  • 11
  • 1
  • 4
  • please provide a [MrE] – eyllanesc Jan 02 '21 at 19:08
  • The fact that you copied the pyuic file somewhere else doesn't change anything: those files must *not* be modified, and your program should always be on *another* file that only uses the pyuic files as imports. There are many reasons for which they should *never* be modified, not only because the ui might be modified. Editing and using the ui classes for the program logic is considered **bad** practice also because it usually leads to unexpected problems and bugs that can be difficult to track. – musicamante Jan 02 '21 at 19:18
  • Alright, thanks for the comment @musicamante. How does modifying a generated ui file differ from coding your own, though? – khayni Jan 02 '21 at 19:34
  • @khayni please share the .csv – eyllanesc Jan 02 '21 at 19:36
  • @khayni it differs *a lot*. The class generated by pyuic is a simple python object class, which doesn't give direct access to ui elements and widget methods when implemented as you did. This makes it very hard to use the fundamental methods and implementations of the QWidget created upon, exactly like in your case. For instance, if you subclassed a QDialog instead (as suggested in the guide about [using Designer](https://www.riverbankcomputing.com/static/Docs/PyQt5/designer.html)) you would have been able to directly detect the close event just by implementing `closeEvent` in the subclass. – musicamante Jan 02 '21 at 19:43
  • @musicamante okay, thanks for the comprehensive explanation! Would you recommend that I create my own QWidget instead of trying to salvage my current code? – khayni Jan 02 '21 at 19:46
  • @khayni recreate the file with pyuic, then create a new script and reimplement the logic in there based on what is described in the link about designer given before. Obviously, it will be different than your actual case, as you will need to better work with signals and slots, which might seem more difficult, but it actually is the most straightforward and suggested method, as it better complies with the Qt workflow: you will not use a for loop (at least, not in the main logic), but will probably keep a list of the questions/answers and follow them with the signal workflow. – musicamante Jan 02 '21 at 19:49
  • @musicamante Alright. Thanks for the advice and the link; I really appreciate it! Also, now that my issue has been alleviated, should I delete the post? I'm new to SO but I don't think that I can mark comments as solutions to my problem. – khayni Jan 02 '21 at 19:55
  • @khayni You can leave it as it is right now, somebody might decide to add a more detailed answer. – musicamante Jan 02 '21 at 20:10
  • @musicamante will do. Thanks again! – khayni Jan 02 '21 at 20:17

1 Answers1

1

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_())
eyllanesc
  • 235,170
  • 19
  • 170
  • 241