Search code examples
pythonwxpythonwxwidgetssingle-threaded

wxpython StaticText.SetLabel doesn't update in a single-threaded GUI when .Wrap is used?


I have a long running python script that is currently terminal driven. I'm required to put a GUI front-end on the process to make it more user-friendly.

Currently the GUI and long-running-process both run in the same thread. (I plan to improve this in future by spawning a new thread for the long function). I've provided some callbacks to that long running thread so that it can occasionally update the GUI to help gauge its progress.

For some reason the callbacks are able to visually update the bitmaps, but not labels, even though .Refresh() and .Update() are called in both cases.

I've noticed that if I remove the call to wrap in on_user_action_label_wrap then it does update the labels as well as the bitmaps.

Questions

a) Why does the use of .Wrap effect the updating of the label?
b) **Is it possible to force update/repaint of the label in `on_user_action_label_wrap? Or really, just have the text be wrapped AND update? **

('Use multiple threads' is not an acceptable answer :) That will happen eventually, but for now I want to understand why this doesn't work )

Below is a SSCCE. I've run this (and my original code) on python 2.7, wxpython 3.0.2.0, wx-3.0-gtk2, gtk+ 2.24.30-1ubuntu1 and 2.24.25-3+deb8u1, and on both Ubuntu 16.04 and Raspbian. Same problem.

#!/usr/bin/env python
# coding=utf-8
from __future__ import absolute_import, division, print_function

import collections
import wx
from time import sleep

STATE_START = "Start!"
STATE_MIDDLE = "Middle!"
STATE_END = "End!"
STATE_STOP = "Stop!"

STATES = [STATE_START, STATE_MIDDLE, STATE_END, STATE_STOP]


class SingleThreadedKeyLoader(wx.Dialog):
    ICON_SIZE = (32, 32)

    def __init__(self, parent):
        wx.Dialog.__init__(self, parent, title="Do stuff",
                           size=wx.Size(800, 600),
                           style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER | wx.STAY_ON_TOP)
        self.SetSizeHintsSz(wx.DefaultSize, wx.DefaultSize)

        self.active_state_bmp = wx.ArtProvider.GetBitmap(wx.ART_GO_FORWARD,
                                                         wx.ART_CMN_DIALOG,
                                                         self.ICON_SIZE)
        self.done_state_bmp = wx.ArtProvider.GetBitmap(wx.ART_TICK_MARK,
                                                       wx.ART_CMN_DIALOG,
                                                       self.ICON_SIZE)
        if wx.NullBitmap in [self.active_state_bmp, self.done_state_bmp]:
            raise Exception("Failed to load icons")

        self._make_ui()

        self.current_state = STATE_START

    def _make_ui(self):

        dialog_sizer = wx.BoxSizer(wx.VERTICAL)

        top_half_sizer = wx.BoxSizer(wx.HORIZONTAL)

        #########
        # User action label
        label_sizer = wx.BoxSizer(wx.VERTICAL)

        self.panel = wx.Panel(self, wx.ID_ANY)
        self.user_action_label = wx.StaticText(self.panel,
                                               label="",
                                               style=wx.ALIGN_CENTRE)
        self.user_action_label.Wrap(100)  # DOESN't SEEM TO  WORK <---------
        self.user_action_label.Bind(wx.EVT_SIZE,
                                    self.on_user_action_label_wrap)
        label_sizer.Add(self.user_action_label, 1,
                        wx.EXPAND | wx.ALIGN_CENTER | wx.ALIGN_CENTER_HORIZONTAL | wx.ALIGN_CENTER_VERTICAL | wx.ALL,
                        5)

        self.panel.SetSizer(label_sizer)
        top_half_sizer.Add(self.panel, 1, wx.EXPAND, 5)
        #
        #########

        #########
        # combo box + log area. Just here to get in the way of long text
        log_area_sizer = wx.BoxSizer(wx.VERTICAL)

        self.log_textbox = wx.TextCtrl(self, value=wx.EmptyString,
                                       style=wx.TE_MULTILINE | wx.TE_READONLY)
        log_area_sizer.Add(self.log_textbox, 1,
                           wx.ALIGN_TOP | wx.ALL | wx.EXPAND, 5)

        self.log_combobox_choices = ["Debug", "Info", "Warning"]
        self.log_combobox = wx.ComboBox(self, value="Info",
                                        choices=self.log_combobox_choices)
        log_area_sizer.Add(self.log_combobox, 0,
                           wx.ALIGN_BOTTOM | wx.ALL | wx.EXPAND, 5)

        top_half_sizer.Add(log_area_sizer, 2, wx.EXPAND, 5)

        dialog_sizer.Add(top_half_sizer, 2, wx.EXPAND, 5)
        #
        #########

        #########
        # State tracking bitmaps. The image should be updated as long function
        # runs.
        state_tracker_sizer = wx.WrapSizer(wx.HORIZONTAL)
        self.bitmaps_od = self.generate_state_tracker(self,
                                                      state_tracker_sizer)
        dialog_sizer.Add(state_tracker_sizer, 1, wx.EXPAND, 5)
        #
        #########

        #########
        # Buttons! A, B, C are here just to check the text-wrap is working
        # in the box.
        button_sizer = wx.BoxSizer(wx.HORIZONTAL)

        self.button_a = wx.Button(self, label=u"A")
        self.button_a.Bind(wx.EVT_BUTTON, self.on_clicked_a)
        button_sizer.Add(self.button_a, 0, wx.ALL, 5)

        self.button_b = wx.Button(self, label=u"B")
        self.button_b.Bind(wx.EVT_BUTTON, self.on_clicked_b)
        button_sizer.Add(self.button_b, 0, wx.ALL, 5)

        self.button_c = wx.Button(self, label=u"C")
        self.button_c.Bind(wx.EVT_BUTTON, self.on_clicked_c)
        button_sizer.Add(self.button_c, 0, wx.ALL, 5)

        self.button_long_func = wx.Button(self, label=u"Long running func")
        self.button_long_func.Bind(wx.EVT_BUTTON, self.on_clicked_long_func)
        button_sizer.Add(self.button_long_func, 0, wx.ALL, 5)

        dialog_sizer.Add(button_sizer, 0, wx.EXPAND, 5)
        #
        #########

        self.SetSizer(dialog_sizer)
        self.Layout()

        self.Centre(wx.BOTH)

    @staticmethod
    def generate_state_tracker(parent, state_tracker_sizer):
        """
        Generates the staticbitmap and statictext.
        The bitmaps will change from arrows to ticks during the process
        """
        def state_label(i, state):
            return "{:>02}. {}".format(i + 1, str(state))

        f = parent.GetFont()
        dc = wx.WindowDC(parent)
        dc.SetFont(f)

        label_bitmaps = collections.OrderedDict()
        max_string_width = -1
        for i, state in enumerate(STATES):
            width, height = dc.GetTextExtent(state_label(i, state))
            max_string_width = max(max_string_width, width)

        for i, state in enumerate(STATES):
            state_sizer = wx.BoxSizer(wx.HORIZONTAL)

            bitmap_name = "state{}_{}".format(i, "bitmap")
            bitmap = wx.StaticBitmap(parent, bitmap=wx.NullBitmap,
                                     name=bitmap_name,
                                     size=SingleThreadedKeyLoader.ICON_SIZE)
            state_sizer.Add(bitmap, 0, wx.ALL, 5)
            label_bitmaps[bitmap_name] = bitmap
            label_bitmaps[state] = bitmap

            label_sizer = wx.BoxSizer(wx.HORIZONTAL)
            label = wx.StaticText(parent, label=state_label(i, state),
                                  size=wx.Size(max_string_width, -1))
            label_sizer.Add(label, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 0)
            state_sizer.Add(label_sizer, 1, wx.EXPAND, 5)

            state_tracker_sizer.Add(state_sizer, 1, wx.EXPAND, 5)

        return label_bitmaps

    def on_clicked_a(self, event):
        """ wrapping test: Sets the label to a small amount of text"""
        self.user_action_label.SetLabel("A this is a small label")

    def on_clicked_b(self, event):
        """ wrapping test: Sets the label to a medium amount of text"""
        self.user_action_label.SetLabel(
            "B B B This is a medium label. It is longer than a small label but shorter than the longer label")

    def on_clicked_c(self, event):
        """ wrapping test: Sets the label to a large amount of text"""
        self.user_action_label.SetLabel(
            "C C C C C C C This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. ")

    def my_set_state_callback(self, new_state):
        """
        Updates the lovely tickboxes.

        Used as a callback from the long running function.
        """
        if new_state == self.current_state:
            return

        print(self.current_state, "->", new_state)

        self.bitmaps_od[self.current_state].SetBitmap(self.done_state_bmp)
        self.bitmaps_od[new_state].SetBitmap(self.active_state_bmp)
        self.current_state = new_state

        self.Refresh()
        self.Update()

    def my_user_feedback_callback(self, message):
        """
        Updates the label that insturcts the user to do stuff

        Used as a callback from the long running function.
        """
        print("my_user_feedback_fn", message)
        self.user_action_label.SetLabel(message)

        self.Refresh()
        self.Update()

    def on_clicked_long_func(self, event):
        """ launches the long function on button press """

        self.button_a.Enable(False)
        self.button_b.Enable(False)
        self.button_c.Enable(False)
        self.log_combobox.Enable(False)
        self.button_long_func.Enable(False)

        self.bitmaps_od[STATE_START].SetBitmap(self.active_state_bmp)
        wx.CallAfter(my_long_running_func, self.my_set_state_callback,
                     self.my_user_feedback_callback)



    def on_user_action_label_wrap(self, event):
        """ Wraps the long text in the box on resize. """
        self.user_action_label.Wrap(self.panel.Size[0])    #<------------------------ problem?
        event.Skip() # ?


def my_long_running_func(set_state, user_feedback_fn):
    """
    This is a very long runing function that uses callbacks to update the
    main UI.
    """
    user_feedback_fn(
        "This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. This is a very long string. ")
    set_state(STATE_START)
    sleep(2)

    set_state(STATE_MIDDLE)
    sleep(2)
    user_feedback_fn("do the thing")

    # If this was the real thing then here we'd pause until the user obeyed
    # the instruction or something
    # wait_for_user_to_do_the_thing()

    set_state(STATE_END)
    sleep(0.5)

    user_feedback_fn("DONE")
    set_state(STATE_STOP)
    print("returning")
    return


app = wx.App(False)

x = SingleThreadedKeyLoader(None)
x.ShowModal()
x.Destroy()

app.MainLoop()

Solution

  • You are calling Refresh and Update for the dialog, but the dialog is not the thing being changed, it is the labels. In some cases the Update of the dialog will cause the child widgets to be repainted too, but not always. However there are more events that need to be processed in this example than just the painting of the new label, so you need to let the event loop run periodically so those other events can be processed too. When you do then the refresh of the label will also be taken care of. So in your example, replace the calls to Refresh and Update with a call to wx.Yield() and it should work better.