pythonasynchronouspython-asynciopyqt6

PyQt6 how to update 96 widgets simutaneously as fast as possible?


This is about my GUI application.

enter image description here

What you are seeing is the window I have created to let the user customize the style of the application. I don't know if it looks good, but I did my best to make it look the best according to my aesthetics.

The window is split into 9 areas, each area is used to change the style of a group of widgets that are related. The preview of the widgets is on the left in each area, on the right of each area is the scroll-area containing widgets that change a certain aspect of the style.

The available options for customization, or the attributes of the widgets styles that can be changed, are the border-style, border-color, text-color, and background color if the widget uses flat background, else the start color of the gradient and the middle color of the gradient if the background uses a gradient. The gradient has 3 stops, the first and the last stops use the same color.

These attributes can be changed independently for each state of all possible states the widget can be in.

There are two types of widgets that change the styles. One type changes the color, the other changes border-style. The border-style is changed via a QComboBox, and the colors can be changed using both a QLineEdit and QColorDialog, the dialog is called by clicking the associated button.

Each group controls a key in the underlining dictionary that stores the style configurations, when the state of the group is changed, the corresponding key is changed and the style is compiled using a class and then the window is updated.

When the start button is clicked, its text will be changed to stop and many widgets in the preview area will be updated every 125 milliseconds. And the animation for the board is a live tic-tac-toe game between two AIs I wrote. Pressing the pause button and it becomes the resume button, and the animation pauses. Pressing the resume button and the animation resumes. Pressing the stop button and everything stops and is reset.

Pressing the randomize button and a random style is generated and all 96 groups that change the style is updated at once, and the style is applied, like so:

enter image description here

enter image description here

enter image description here

It works, the problem is updating all these widgets simultaneously causes stuttering, the window stops responding for a few seconds and then the window is updated. I want to eliminate the lagging.

Due to the size of the application I won't post the full code here. Also because the problem arises when 96 widgets are updated simultaneously I won't provide a minimal reproducible example. The application isn't complete and isn't working as intended therefore I cannot post it on Code Review just yet, in truth it is almost complete and I will post it on Code Review when it is.

I will show some fragments of code related to this issue, you won't be able to run the code, I uploaded the complete project to Google Drive so that you can run it, link. Everything I have implemented so far is completely working, there are no bugs.

import asyncio
import qasync
import random
import sys
from concurrent.futures import ThreadPoolExecutor

class ColorGetter(Box):
    instances = []

    def __init__(self, widget: str, key: str, name: str):
        super().__init__()
        ColorGetter.instances.append(self)
        self.config = CONFIG[widget] if widget else CONFIG
        self.key = key
        self.name = name
        self.set_color(self.config[key])
        self.init_GUI()
        self.button.clicked.connect(self._show_color)
        self.picker.accepted.connect(self.pick_color)
        self.color_edit.returnPressed.connect(self.edit_color)

    def init_GUI(self):
        self.color_edit = ColorEdit(self.color_text)
        self.button = Button(self.color_text)
        self.vbox = make_vbox(self)
        self.vbox.addWidget(Label(self.name))
        self.vbox.addWidget(self.color_edit)
        self.vbox.addWidget(self.button)
        self.picker = ColorPicker()

    def set_color(self, text: str):
        self.color = [int(text[a:b], 16) for a, b in ((1, 3), (3, 5), (5, 7))]

    @property
    def color_text(self):
        r, g, b = self.color
        return f"#{r:02x}{g:02x}{b:02x}"

    def _show_color(self):
        self.picker.setCurrentColor(QColor(*self.color))
        self.picker.show()

    def _update(self):
        self.button.setText(self.color_text)
        self.config[self.key] = self.color_text
        GLOBALS["Window"].update_style()

    def pick_color(self):
        self.color = self.picker.currentColor().getRgb()[:3]
        self.color_edit.setText(self.color_text)
        self.color_edit.color = self.color_text
        self._update()

    def edit_color(self):
        text = self.color_edit.text()
        self.color_edit.color = text
        self.set_color(text)
        self.color_edit.clearFocus()
        self._update()

    def sync_config(self):
        color_html = self.config[self.key]
        self.color = [int(color_html[a:b], 16) for a, b in ((1, 3), (3, 5), (5, 7))]
        self.color_edit.setText(color_html)
        self.color_edit.color = color_html
        self.button.setText(color_html)


class BorderStylizer(Box):
    instances = []

    def __init__(self, widget: str, key: str, name: str):
        super().__init__()
        BorderStylizer.instances.append(self)
        self.config = CONFIG[widget] if widget else CONFIG
        self.key = key
        self.name = name
        self.borderstyle = self.config[key]
        self.init_GUI()

    def init_GUI(self):
        self.vbox = make_vbox(self)
        self.vbox.addWidget(Label(self.name))
        self.combobox = ComboBox(BORDER_STYLES)
        self.vbox.addWidget(self.combobox)
        self.combobox.setCurrentText(self.borderstyle)
        self.combobox.currentTextChanged.connect(self._update)

    def _update(self):
        self.borderstyle = self.combobox.currentText()
        self.config[self.key] = self.borderstyle
        GLOBALS["Window"].update_style()

    def sync_config(self):
        self.borderstyle = self.config[self.key]
        self.combobox.setCurrentText(self.borderstyle)


async def sync_config():
    loop = asyncio.get_event_loop()
    with ThreadPoolExecutor(max_workers=64) as executor:
        await asyncio.gather(
            *(
                loop.run_in_executor(executor, instance.sync_config)
                for instance in ColorGetter.instances + BorderStylizer.instances
            )
        )


def random_style():
    for entry in CONFIG.values():
        for k in entry:
            entry[k] = (
                random.choice(BORDER_STYLES)
                if k == "borderstyle"
                else f"#{random.randrange(16777216):06x}"
            )

    asyncio.run(sync_config())

    GLOBALS["Window"].update_style()
    GLOBALS["Window"].qthread.change.emit()


if __name__ == "__main__":
    app = QApplication(sys.argv)
    app.setStyle("Fusion")
    if sys.platform == "win32":
        asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())

    loop = qasync.QEventLoop(app)
    asyncio.set_event_loop(loop)

I tried to eliminate the lagging by making the update run asynchronously, and it doesn't work very well.

What is a better solution?


Solution

  • I solved the problem. Thanks to the comment of @musicamante.

    So the problem is really simple, it is just that every time random_style is called, every combobox's text will be changed, and the text change causes a new additional update_style call of the window, for every combobox.

    So I just disconnected the .currentTextChanged signal of every combobox before doing the change and reconnect afterwards, like so:

        def sync_config(self):
            self.borderstyle = self.config[self.key]
            self.combobox.currentTextChanged.disconnect(self._update)
            self.combobox.setCurrentText(self.borderstyle)
            self.combobox.currentTextChanged.connect(self._update)
    

    And I have tried to do it synchronously, I have found no noticeable lag, and so I got rid of asyncio loop.

    def random_style():
        for entry in CONFIG.values():
            for k in entry:
                entry[k] = (
                    random.choice(BORDER_STYLES)
                    if k == "borderstyle"
                    else f"#{random.randrange(16777216):06x}"
                )
    
        for instance in ColorGetter.instances + BorderStylizer.instances:
            instance.sync_config()
    
        GLOBALS["Window"].update_style()
        GLOBALS["Window"].qthread.change.emit()