Real Time Change of Widgets?
How to update the UI while in a loop

Heads up! You've already completed this tutorial.

Bentraje wrote

Hi,

I'm trying to create a simple class recitation app where it randomly picks from the list of students. It works, but I want it to change in real time, so there's a bit of suspense who is being picked.

You can see in the illustration video below that the console updates the names in real time but the QtWidget does not: https://www.dropbox.com/s/rwcbhhj58tevshl/py003_pyqt_real_time_update_widget.mp4?dl=0

Here is the working code so far:

python
import sys
import os

from PySide2.QtCore import Qt
from PySide2.QtWidgets import QApplication, QMainWindow, QSpinBox, QWidget, QPushButton, QTextEdit, QVBoxLayout, QHBoxLayout, QLineEdit, QLabel
from PySide2.QtGui import QPixmap


from time import sleep, perf_counter
import time
import random


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("Class Recitation")

        # Widget
        root_img_path = 'img'
        self.image_list = []

        for root, dirs, files in os.walk(root_img_path):
            for f in files:

                img_path = os.path.join(root_img_path, f)
                first_name =  f.split('_')[0]
                last_name = f.split('_')[1]
                self.image_list.append( (img_path, first_name + " " + last_name ) )

        # creating label
        self.label = QLabel(self)

        # loading image
        #self.pixmap = QPixmap('img/Eijirou_Kirishima_Portrait.png')
        self.pixmap = QPixmap(self.image_list[0][0])

        # adding image to label
        self.label.setPixmap(self.pixmap)

        self.name_label = QLabel()
        self.name_label.setText(self.image_list[0][1])

        self.pick_btn = QPushButton("Pick")
        self.pick_btn.setObjectName("Pick")
        self.pick_btn.clicked.connect(self.random_pick)


        # Layout Creations

        hbox = QHBoxLayout()

        # hbox.addWidget(self.search_button)
        # hbox.addWidget(self.search_bar)

        vbox = QVBoxLayout()
        vbox.addWidget(self.label)
        vbox.addWidget(self.name_label)
        vbox.addWidget(self.pick_btn)

        # vbox.addWidget(self.result_text_edit)

        layout = QVBoxLayout()
        layout.addLayout(hbox)
        layout.addLayout(vbox)

        widget = QWidget()
        widget.setLayout(layout)

        self.setCentralWidget(widget)

    def random_pick(self):

        choice_list = self.image_list
        time_started = time.perf_counter()
        counter = 0

        for x in range(0,10000):

            random_pick = random.choice(choice_list)
            self.name_label.setText(random_pick[1])
            self.label.setPixmap(random_pick[0])
            print (random_pick[1])
            sleep(counter)
            current_time = time.perf_counter()

            time_elapse = current_time - time_started
            counter += 0.0001 * (2**4)

            if time_elapse >= 5:
                break

        return random_pick


    def change_name(self):

        self.name_label.setText("Name Changed")

app = QApplication(sys.argv)

window = MainWindow()
window.show()
app.exec_()

Is there a way around this?

Thanks


Martin Fitzpatrick

Hi Bentraje welcome to the forum!

The problem is the random_pick method.

First some background -- In GUI applications the update of the UI is handled by an event loop, and a queue of things to do. The event loop takes items form the queue, does them, then takes the next, and so on. Things are done in order, and only one thing is done at a time.

When you update the text of a widget, with self.name_label.setText(random_pick[1]) that update happens immediately (the value in the widget changes) but the widget is not redrawn. Instead a "redraw" request is put onto the event queue, and that will happen once the event loop gets to it.

Why doesn't the event loop get to it in your case? Because your method is blocking the loop. In your code you've linked pressing the button to this method as follows

python
self.pick_btn.clicked.connect(self.random_pick)

When the button is pressed it generates a signal, which you've correctly connected to your method. But when the event loop gets to that signal and calls your method, it has to wait for the method to finish before doing anything else. And "anything else" in this case includes redrawing the widgets you've updated.

The end result is you update the widget multiple times, but it never redraws, so you can't see it being updated.

How to solve it?

There are a few ways. Firstly, you can call QApplication.processEvents() in your loop, to make Qt process the outstanding events (including the outstanding redraws). I wouldn't recommend this, as it's better to structure your application to use the event loop, rather than try and work around it -- but it's there as a quick and dirty solution.

Another option is to run this selection process in a thread. This would stop it blocking your main GUI thread, and allow you to keep the code more or less the same (aside from creating the workers). See a tutorial on multithreading here.

A third option is to use a QTimer to trigger the random selection and another to stop it. Remove the loop code from random_pick and have it make just one choice. Then add a new method which starts the two timers. I think this is the approach I would prefer in this case.

Something like the following (untested)

python
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        # snip

        self.pick_btn = QPushButton("Pick")
        self.pick_btn.setObjectName("Pick")
        self.pick_btn.clicked.connect(self.start_selection)

    def start_selection(self):
        # Repeating timer, calls random_pick over and over.
        self.picktimer = QTimer()
        self.picktimer.setInterval(500)
        self.picktimer.timeout.connect(self.random_pick)
        self.picktimer.start()

        # Single oneshot which stops the selection after 5 seconds
        QTimer.singleShot(5000, self.stop_selection)

    def stop_selection(self):
        # Stop the random selection
        self.picktimer.stop()

        # The current pick is in self.current_selection
        print("Current selection is: ", self.current_selection)


    def random_pick(self):

        random_pick = random.choice(self.image_list)
        self.name_label.setText(random_pick[1])
        self.label.setPixmap(random_pick[0])
        print (random_pick[1])
        # Store the current selection, so we have it at the end in stop_selection.
        self.current_selection = random_pick

Bentraje

Thanks for the response. It works as expected. Just to confirm, if I have to loop (other than based on time) in Qt, I guess I'll have to use the threading/process feature right?


Martin Fitzpatrick

Great, glad it did the trick!

Just to confirm, if I have to loop (other than based on time) in Qt, I guess I’ll have to use the threading/process feature right?

It depends on how long the loop lasts. The key thing to remember is that whenever your code is running the UI is blocked. But you can get away with doing a lot before it becomes noticeable.

If you have any long-running tasks that can't be broken down into smaller units, then yes you would ideally put these in a thread. The timer approach works well for anything that can be broken down into smaller steps -- like your random selection example, it's a series of very quick steps, that you want to happen regularly, but the subsequent steps don't rely on one another so can be easily split.


Bentraje

Ah gotcha. Thanks for the clarification!

Over 10,000 developers have bought Create GUI Applications with Python & Qt!

To support developers in [[ countryRegion ]] I give a [[ localizedDiscount[couponCode] ]]% discount with the code [[ couponCode ]] — Enjoy!

For [[ activeDiscount.description ]] I'm giving a [[ activeDiscount.discount ]]% discount with the code [[ couponCode ]] — Enjoy!

Well done, you've finished this tutorial! Mark As Complete
[[ user.completed.length ]] completed [[ user.streak+1 ]] day streak