Search code examples
pythontkintertcl

Lifted ttk.Label widget can't redraw promptly?


Here is my minimum representative example (MRE) on how I create ttk.Button widgets with an image via a multithreaded approach. However, I am experiencing an issue with a task that occurs before the multithreading task. Whenever the self.label widget is lifted, it can't be redrawn promptly; a grey patch appears for a short period before self.label appears completely. Running self.update_idletasks() (see line 97) can't fix this issue. Only running self.update() can fix this issue (you have to uncomment line 98). However, some opined that the use of self.update() can be harmful. Is it possible to resolve this issue without using self.update()? If so, how? Please can you also explain why this issue happens? Thank you.

Issue demo:

Issue

Desired outcome demo: Desired outcome

MRE:

Please save any .jpg file you have into the same directory/folder as this script and rename it to testimage.jpg. How this GUI works? Click the Run button to start the multithreading. To rerun, you have to first click Reset button, thereafter click the Run button. DO NOT click Reset when threading is ongoing and vice versa.

# Python modules
import tkinter as tk
import tkinter.ttk as ttk
import concurrent.futures as cf
import queue
import threading
from itertools import repeat
import random
from time import sleep

# External modules
from PIL import Image, ImageTk


def get_thumbnail_c(gid: str, fid: str, fpath: str, psize=(100, 100)):
    # print(f"{threading.main_thread()=} {threading.current_thread()=}")
    with Image.open(fpath) as img:
        img.load()
    img.thumbnail(psize)
    return gid, fid, img


def get_thumbnails_concurrently_with_queue(
        g_ids: list, f_ids: list, f_paths: list, rqueue: queue.Queue,
        size: tuple):
    futures = []
    job_fn = get_thumbnail_c

    with cf.ThreadPoolExecutor() as vp_executor:
        for gid, fids, fpath in zip(g_ids, f_ids, f_paths):
            for gg, ff, pp in zip(repeat(gid, len(fids)), fids,
                                  repeat(fpath, len(fids))):
                job_args = gg, ff, pp, size
                futures.append(vp_executor.submit(job_fn, *job_args))
    for future in cf.as_completed(futures):
        rqueue.put(("thumbnail", future.result()))
        futures.remove(future)
        if not futures:
            print(f'get_thumbnails_concurrently has completed!')
            rqueue.put(("completed", ()))


class GroupNoImage(ttk.Frame):
    def __init__(self, master, gid, fids):
        super().__init__(master, style='gframe.TFrame')
        self.bns = {}
        self.imgs = {}
        for i, fid in enumerate(fids):
            self.bns[fid] = ttk.Button(self, text=f"{gid}-P{i}", compound="top",
                                       style="imgbns.TButton")
            self.bns[fid].grid(row=0, column=i, stick="nsew")

class App(ttk.PanedWindow):
    def __init__(self, master, **options):
        super().__init__(master, **options)
        self.master = master
        self.groups = {}
        self.rqueue = queue.Queue()

        self.vsf = ttk.Frame(self)
        self.add(self.vsf)

        self.label = ttk.Label(
            self, style="label.TLabel", width=7, anchor="c", text="ttk.Label",
            font=('Times', '70', ''))
        self.label.place(
            relx=0.5, rely=0.5, relwidth=.8, relheight=.8, anchor="center",
            in_=self.vsf)
        self.label.lower(self.vsf)

    def create_grpsframe(self):
        self.grpsframe = ttk.Frame(self.vsf, style='grpsframe.TFrame')
        self.grpsframe.grid(row=0, column=0, sticky="nsew")

    def run(self, event):
        self.create_grpsframe()
        gids = [f"G{i}" for i in range(50)]
        random.seed()
        fids = []
        for gid in gids:
            f_ids = []
            total = random.randint(2,10)
            for i in range(total):
                f_ids.append(f"{gid}-P{i}" )
            fids.append(f_ids)
        fpaths = ["testimage.jpg" for i in range(len(gids))]
        self.create_groups_concurrently(gids, fids, fpaths)

    def reset(self, event):
        self.grpsframe.destroy()
        self.groups.clear()

    def create_groups_concurrently(self, gids, fids, fpaths):
        print(f"\ncreate_groups_concurrently")

        self.label.lift(self.vsf)
        # self.update_idletasks()  # Can't fix self.label appearance issue
        # self.update()  # Fixed self.label appearance issue

        for i, (gid, f_ids) in enumerate(zip(gids, fids)):
            self.groups[gid] = GroupNoImage(self.grpsframe, gid, f_ids)
            self.groups[gid].grid(row=i, column=0, sticky="nsew")
            self.update_idletasks()
        # sleep(3)
        print(f"\nStart thread-queue")

        jthread = threading.Thread(
            target=get_thumbnails_concurrently_with_queue,
            args=(gids, fids, fpaths, self.rqueue, (100,100)),
            name="jobthread")
        jthread.start()
        self.check_rqueue()

    def check_rqueue(self):
        # print(f"\ndef _check_thread(self, thread, start0):")
        duration = 1  # millisecond
        try:
            info = self.rqueue.get(block=False)
            # print(f"{info=}")
        except queue.Empty:
            self.after(1, lambda: self.check_rqueue())
        else:
            match info[0]:
                case "thumbnail":
                    gid, fid, img = info[1]
                    print(f"{gid=} {fid=}")
                    grps = self.groups
                    grps[gid].imgs[fid] = ImageTk.PhotoImage(img)
                    grps[gid].bns[fid]["image"] = grps[gid].imgs[fid]
                    self.update_idletasks()
                    self.after(duration, lambda: self.check_rqueue())
                case "completed":
                    print(f'Completed')
                    self.label.lower(self.vsf)

class ButtonGroups(ttk.Frame):
    def __init__(self, master, **options):
        super().__init__(master, style='bnframe.TFrame', **options)
        self.master = master
        self.bnrun = ttk.Button(
            self, text="Run", width=10, style='bnrun.TButton')
        self.bnreset = ttk.Button(
            self, text="Reset", width=10, style='bnreset.TButton')

        self.columnconfigure(0, weight=1)
        self.columnconfigure(1, weight=1)
        self.bnrun.grid(row=0, column=0, sticky="nsew")
        self.bnreset.grid(row=0, column=1, sticky="nsew")


if __name__ == "__main__":
    root = tk.Tk()
    root.geometry('1300x600')
    root.columnconfigure(0, weight=1)
    root.rowconfigure(0, weight=1)

    ss = ttk.Style()
    ss.theme_use('default')
    ss.configure(".", background="gold")
    ss.configure("TPanedwindow", background="red")
    ss.configure('grpsframe.TFrame', background='green')
    ss.configure('gframe.TFrame', background='yellow')
    ss.configure('imgbns.TButton', background='orange')
    ss.configure("label.TLabel", background="cyan")
    ss.configure('bnframe.TFrame', background='white')
    ss.configure('bnrun.TButton', background='violet')
    ss.configure('bnreset.TButton', background='green')

    app = App(root)
    bns = ButtonGroups(root)
    app.grid(row=0, column=0, sticky="nsew")
    bns.grid(row=1, column=0, sticky="nsew")

    bns.bnrun.bind("<B1-ButtonRelease>", app.run)
    bns.bnreset.bind("<B1-ButtonRelease>", app.reset)

    root.mainloop()

Solution

  • Thank you @Thingamabobs. Your comment worked:

    why not lift the label and call your heavy computation with after to ensure the label is drawn correctly?

    My amendment is given below. Extra calls to the self.update_idletasks() and self.update() methods were not even needed. In so doing, tkinter's main event loop was also promptly serviced. Although I obtained the desired outcome by using the .after_idle() method for this MRE, I noticed that the .after() method gave a more robust outcome in my actual code.

    Amendments:

    def run(self, event):
        self.create_grpsframe()
        gids = [f"G{i}" for i in range(50)]
        random.seed()
        fids = []
        for gid in gids:
            f_ids = []
            total = random.randint(2,10)
            for i in range(total):
                f_ids.append(f"{gid}-P{i}" )
            fids.append(f_ids)
        fpaths = ["testimage.jpg" for i in range(len(gids))]
        self.label.lift(self.vsf)  # Put this statement here instead of in the self.create_groups_concurrently method.
        self.after_idle(self.create_groups_concurrently, gids, fids, fpaths)  # Then use the after_idle() method to call the method that start the threads.