Layout/Update problem in wxPython

Published on 2020-04-03.

When working on RLiterate I wanted to speed up drawing to make characters appear faster on the screen after a key press. The Update method seemed to be what I wanted:

Calling this method immediately repaints the invalidated area of the window and all of its children recursively (this normally only happens when the flow of control returns to the event loop).

However, using it turned out to be problematic. In this post I present a test program that demonstrates the problem and then discuss a solution.

Overview

The test program lays out a few widgets vertically. First a button that triggers a change in layout, then a text widget surrounded by two panels to more clearly indicate the size of the text widget. It looks like this:

Tests program.

When the button is clicked, the size of the text widget is increased followed by a call to Layout and Update on the frame. I would expect the frame to redraw immediately at this point, but it doesn't. At least not correctly.

Test program

The structure of the test program looks like this:

  1. testlayout.py
import wx
import time

class TestFrame(wx.Frame):

    <<TestFrame>>

class Text(wx.Panel):

    <<Text>>

if __name__ == "__main__":
    app = wx.App()
    frame = TestFrame()
    frame.Show()
    app.MainLoop()

If you prefer the read the complete test program, go to Complete test program.

The __init__ method of the test frame lays out the widgets using a vertical sizer and hooks up the click event.

  1. testlayout.py
  2. TestFrame
def __init__(self):
    wx.Frame.__init__(self, None)
    self.button = wx.Button(self, label="increase text size")
    self.button.Bind(wx.EVT_BUTTON, self._on_increase_size_click)
    self.top = wx.Panel(self, size=(-1, 20))
    self.top.SetBackgroundColour("yellow")
    self.text = Text(self)
    self.bottom = wx.Panel(self, size=(-1, 20))
    self.bottom.SetBackgroundColour("pink")
    self.Sizer = wx.BoxSizer(wx.VERTICAL)
    self.Sizer.Add(
        self.button,
        border=5, proportion=0, flag=wx.EXPAND|wx.ALL
    )
    self.Sizer.Add(
        self.top,
        border=5, proportion=0, flag=wx.EXPAND|wx.ALL
    )
    self.Sizer.Add(
        self.text,
        border=5, proportion=0, flag=wx.EXPAND|wx.ALL
    )
    self.Sizer.Add(
        self.bottom,
        border=5, proportion=0, flag=wx.EXPAND|wx.ALL
    )

The click event handler increases the size of the text widget and tries to do an immediate update. The sleep is there to see if the update is delayed or not.

  1. testlayout.py
  2. TestFrame
def _on_increase_size_click(self, event):
    print("")
    print("Click")
    self.text.increase_size()
    self.Layout()
    print("Update")
    self.Update()
    time.sleep(2)
    print("Update done")

The __init__ method of the text widget sets an initial size and hooks up paint and size events.

  1. testlayout.py
  2. Text
def __init__(self, parent):
    wx.Panel.__init__(self, parent)
    self.min_h = 0
    self.increase_size()
    self.Bind(wx.EVT_PAINT, self._on_paint)
    self.Bind(wx.EVT_SIZE, self._on_size)

The increase_size method increases the height and sets a new min size.

  1. testlayout.py
  2. Text
def increase_size(self):
    self.min_h += 10
    self.SetMinSize((-1, self.min_h))

The paint event handler draws a bunch of lines of text and also prints the rectangles that were invalidated.

  1. testlayout.py
  2. Text
def _on_paint(self, event):
    print("repaint text")
    upd = wx.RegionIterator(self.GetUpdateRegion())
    while upd.HaveRects():
        print("  {}".format(upd.GetRect()))
        upd.Next()
    dc = wx.PaintDC(self)
    y = 0
    for x in range(20):
        line = "line {}".format(x)
        dc.DrawText(line, 5, y)
        y += dc.GetTextExtent(line)[1]
    event.Skip()

The size event just prints the new size.

  1. testlayout.py
  2. Text
def _on_size(self, event):
    print("resize text {}".format(event.GetSize()))
    event.Skip()

Results

Here is the output of a button click:

Click
resize text (396, 20)
Update
repaint text
  (0, 0, 396, 10)
Update done
repaint text
  (0, 0, 396, 20)

So in practice, nothing is changed on the screen until after the sleep call.

It seems to me that the Layout call depends on something happening by following events. Adding a Refresh call immediately after Layout does not seem to have any effect.

Why does the immediate repaint don't get the correct size?

Discussion

I asked about this problem in the wxPython discussion forum and got the following response from Robin Dunn:

Almost all of time using Update is not needed, and sometimes it can be a little detrimental. For example, on OSX there is a pipeline of operations to move display details from all of the running applications to the screen. This is highly optimized and works to keep the screen updated and appearing very smooth and crisp and keeping the applications efficient. When an application does something like drawing to a DC without a paint event, or forcing a paint event at the wrong moment, then that pipeline is interrupted to do that drawing and then has to be restarted again to redo processing what was interrupted before.
So, in other words, in almost all cases using Refresh or RefreshRect when needed, and letting the drawing happen in naturally occurring paint events is usually the best choice. The platforms are already highly optimized for this and have been fine tuned for decades to make it the best it can be.

So it seems like Update is platform dependant and it's best to just don't use it.

My solution for a faster redraw instead became to only draw the part where the cursor is first, and draw everything else a few milliseconds later if there are no additional input events. For example, if a title is edited in the workspace, the table of contents might have to be redrawn as well. But it can wait, since the focus is not on it right now.

As a bonus, I found this wiki page that was quite useful: WhenAndHowToCallLayout.

Complete test program

import wx
import time

class TestFrame(wx.Frame):

    def __init__(self):
        wx.Frame.__init__(self, None)
        self.button = wx.Button(self, label="increase text size")
        self.button.Bind(wx.EVT_BUTTON, self._on_increase_size_click)
        self.top = wx.Panel(self, size=(-1, 20))
        self.top.SetBackgroundColour("yellow")
        self.text = Text(self)
        self.bottom = wx.Panel(self, size=(-1, 20))
        self.bottom.SetBackgroundColour("pink")
        self.Sizer = wx.BoxSizer(wx.VERTICAL)
        self.Sizer.Add(
            self.button,
            border=5, proportion=0, flag=wx.EXPAND|wx.ALL
        )
        self.Sizer.Add(
            self.top,
            border=5, proportion=0, flag=wx.EXPAND|wx.ALL
        )
        self.Sizer.Add(
            self.text,
            border=5, proportion=0, flag=wx.EXPAND|wx.ALL
        )
        self.Sizer.Add(
            self.bottom,
            border=5, proportion=0, flag=wx.EXPAND|wx.ALL
        )

    def _on_increase_size_click(self, event):
        print("")
        print("Click")
        self.text.increase_size()
        self.Layout()
        print("Update")
        self.Update()
        time.sleep(2)
        print("Update done")

class Text(wx.Panel):

    def __init__(self, parent):
        wx.Panel.__init__(self, parent)
        self.min_h = 0
        self.increase_size()
        self.Bind(wx.EVT_PAINT, self._on_paint)
        self.Bind(wx.EVT_SIZE, self._on_size)

    def increase_size(self):
        self.min_h += 10
        self.SetMinSize((-1, self.min_h))

    def _on_paint(self, event):
        print("repaint text")
        upd = wx.RegionIterator(self.GetUpdateRegion())
        while upd.HaveRects():
            print("  {}".format(upd.GetRect()))
            upd.Next()
        dc = wx.PaintDC(self)
        y = 0
        for x in range(20):
            line = "line {}".format(x)
            dc.DrawText(line, 5, y)
            y += dc.GetTextExtent(line)[1]
        event.Skip()

    def _on_size(self, event):
        print("resize text {}".format(event.GetSize()))
        event.Skip()

if __name__ == "__main__":
    app = wx.App()
    frame = TestFrame()
    frame.Show()
    app.MainLoop()

Site proudly generated by Hakyll.