A collection of wxPython components and utilities.
wxdo.aslong
: A system for delegating long-running tasks to a background thread.wxdo.deep_object_list
: A widget for editing a list of arbitrary content.wxdo.wxqueue
: Aqueue.Queue
variant for sending work from a worker thread to the GUI thread.wxdo.sizers
: Sizer utilities.wxdo.workerthread
: Background worker thread manager.
$ pip install wxdo
Source code: https://github.com/AndersMunch/wxdo
aslong is for creating wxPython event handlers that are long-running, yet don't block the user interface.
It uses async to achieve this, but not asyncio. That means event handlers
can use ordinary blocking code, like time.sleep(...)
, requests.get(anURL)
,
`databasecursor.execute('SELECT ...')
and such, and not their asyncio equivalents.
Event handlers are written as coroutine functions decorated with the
aslong.task
decorator. Other than that they look just like regular event
handler methods, and as far as the body of the function is just regular event
handling code, nothing special happens.
Then you call await aslong.bg()
, and from that point on, the code is no
longer running in the UI thread. It has been teleported to a background thread,
and the UI is once again responsive, even though the event handler is still
running.
While on the background thread, time consuming work can be done, without affecting the UI.
Then you call await aslong.ui()
, and from that point on, the code is no
longer running in the background thread. It has been teleported back to the UI
thread, and the results of the long-running work can be entered into the
wxPython GUI components.
Is is also possible to use aslong.ui
and aslong.bg
as async context managers.
Writing
async with aslong.bg:
modify_ui()
is roughly equivalent to writing
await aslong.ui()
try:
modify_ui()
finally:
await aslong.bg()
The long-running task is associated with the object for which the event handler is defined.
That is to say, e.g. if self is a panel, and you write:
def __init__(self):
but = wx.Button(self, -1, "Press me")
but.Bind(wx.EVT_BUTTON, self.OnButton)
@aslong.task
async def OnButton(self, event):
...
then a background worker thread is created (when necessary) for self, the panel (not for the button), when the event handler is called. All event handlers on the same panel share the same thread. You can start multiple long-running tasks: If an event with a long-running task is triggered while another long-running task is still running, then they take turns running their background code, so that only one tasks background code is running at any time.
Background code can run concurrently, though: The background code with one associated wx object runs concurrently with the background code for a different associated wx object, as they each have their own background worker thread.
When a wxPython object with associated long-running tasks is destroyed, any tasks that are still running in the background are left hanging.
If they don't use any context managers and don't use any try..finally, then they
will just quitely stop when the current section of background work is done. If
they do, then Python will complain with a mysterious error: RuntimeError:
coroutine ignored GeneratorExit
. That's Python saying that there was some
cleanup work left to do, and it didn't get done, because a coroutine was
abandoned early.
To avoid abandoning coroutines, use aslong.cleanup(self)
in the close or
destroy event of the associated wx object, to run still-running tasks to
completion. Termination is done by await aslong.ui()
raising an
InterruptedError
exception. finally
sections and context manager exits
will then be executed as part of normal stack unwinding.
During cleanup, all code is run on the UI thread (the thread that you called
aslong.cleanup
from).
See the sample aslong_multi.py
for how to call cleanup
.
While the injected InterruptError
speeds up the cleanup, it does mean that
the long-running work that was in progress is not finished.
You may opt to trap the InterruptedError exception in the event handler, and so insist that the rest of the work is done. E.g. something like this:
for thing in many_things:
try:
await aslong.ui()
except InterruptedError:
pass # I won't be interrupted!
else:
self.label.SetValue("Still working")
await aslong.bg()
process(thing)
Just understand that the UI will then block until it's all done. And keep in mind that if the wxPython panel/frame/whatever is in the process of being destroyed, then calling methods on that may fail.
Close and destroy events cannot be aslong long-running tasks.
When running on the background thread, the usual wxPython restrictions on threaded code applies: Code on a background thread must not interact with wxPython components.
More so, the wx.Event object that was passed to an event handler must only be used in the initial part of the event handler, before the first visit to the background thread. After that, the C++ event object may have been destroyed.
All event handlers associated with the same wxPython object use the same
background worker thread, and background jobs are run sequentially on that
thread. That gives you a little bit of thread-safety, but not a lot: Other
event handlers on other wxPython objects have their own thread that runs
independently. Background code should use locks, threading.Lock
and
threading.RLock
, to safeguard shared resources, just like any other threaded
code. UI code, on the other hand, never runs concurrently with other UI code --
there is only ever one UI thread, but you may still need to use locks, if
they're touching anything that a background thread for a different task may also
touch.
It is safe to hold a threading.Lock
lock while teleporting between UI and
background, but do not teleport while holding a threading.RLock
. Your task
may deadlock against itself, or worse.
If locks are held, then running aslong.cleanup()
is important: Without it,
event handlers are not guaranteed to run to completion, which means that locks
may be held that are never released, causing deadlocks.
If any libraries are in use that are somehow tied to a specific thread, like Windows COM objects, then aslong long-running tasks should not be used, unless the thread is question is the GUI thread. Although there is only one background thread at any time, an idle background thread is eventually closed, and a new, different, thread is created on demand.
This is a wxPython list editing widget, where the list elements can be anything that can be placed into a sizer - windows and complex sizers hierarchies alike.
List items can be added, deleted and reordered.
Lists can be heterogenous, i.e. list items in the same list can use different controls and look very different from one another.
Creating a list uses three objects:
- A
DeepObjectList
: The list control itself, which is a wx.Panel subclass. - An object of a
DeepObjectList_Parameters
subclass, which configures what the list looks like. - An object of a
DeepObjectItemEditor
subclass, which configures what a single list element looks like.
First, make a DeepObjectList_Parameters subclass, and implement two key methods: CreateObject and CreateItemEditor.
CreateObject creates a new value of the type you're editing a list of. The returned value can be any type of Python object: a number, a list, a datetime.datetime, a pathlib.Path, or your own class that you just wrote, whatever you like.
The second method, CreateItemEditor
, returns a DeepObjectItemEditor
which
in turn creates the wxPython controls needed to display and edit the value.
So if for example you are editing a list of strings, CreateObject
could simply
return an empty string. Let's make a slightly more complicated example: A list
of enumeration values that uses a wx.Choice
to edit the values.
from enum import Enum, auto
import wx
from wxdo.deep_object_list import DeepObjectList, DeepObjectList_Parameters, DeepObjectItemEditor
class Colour(Enum):
unknown = auto()
red = auto()
green = auto()
blue = auto()
class Parameters(DeepObjectList_Parameters):
def CreateObject(self, parent):
return Colour.unknown
def CreateItemEditor(self, value):
return Colour_ItemEditor()
The value parameter to CreateItemEditor
is the value of the list item to be
edited, allowing you to create different editors for different types of values.
In this case, there's only one type of value, so it isn't used.
The next step is the editor object. It needs to implement Create
and Destroy
methods, to create and destroy wxPython controls, and SetValue
/GetValue
methods,
to set and get a list item value.
class Colour_ItemEditor(DeepObjectItemEditor):
def Create(self):
# self.parent is the appropriate parent to use for wxPython windows.
self._choice = wx.Choice(self.parent, -1, choices=list(Colour.__members__.keys()))
# Return a list of somethings that can be added to a sizer.
return [self._choice]
def SetValue(self, value):
position = list(Colour.__members__.values()).index(value)
self._choice.SetSelection(position)
def GetValue(self):
position = self._choice.GetSelection()
return list(Colour.__members__.values())[position]
def Destroy(self):
self._choice.Destroy()
There are additional methods that can be overriden to fine-tune the behaviour of
the list. See the DeepObjectList_Parameters
class in the code.
Finally, put the whole thing together:
params = Parameters()
the_list = DeepObjectList(parent, -1, params)
some_sizer.Add(the_list, 1, wx.EXPAND)
# the_list.GetValue() and the_list.SetValue() are ready to use.
The list supports reordering: Click on a hand icon and drag it on top of a different hand icon, and then the list item is moved up or down to that position. This can also be done using the keyboard: press space or return on the hand icon, or click without dragging, and then the icon changes to arrows and the up/down arrow keys can then be used to move the list item.
The green [+] icon adds a new list item just before this one. There is a lone [+] icon at the bottom to append to the list.
The red [x] icon deletes the list item. By default it does not ask for
confirmation, but you can override DeepObjectList_Parameters.ConfirmErase
to
change that.
Methods | |
---|---|
SetValue | Set a list of Python objects as the value of the list widget. |
GetValue | Get the value of the list widget as a list of Python objects. |
Append | Add an item to the bottom of the list and scroll to it. |
Extend | Add multiple items to the bottom of the list and scroll to it. |
SetLayoutCallback | Set a callback for when content changes size, and the full list needs to be re-layouted. |
SetTexts | Customise user interface texts. |
GetItemEditors | Peek at the which DeepObjectItemEditor's are currently on-screen. |
Do not inherit from this class, use as is. Adaptation takes place in a
DeepObjectList_Parameters
subclass.
Make a subclass and implement CreateObject
and CreateItemEditor
methods.
The rest of the methods are optional, override as necessary.
Methods | |
---|---|
CreateObject | Called when the user pressed [+] to add an item. |
ConfirmErase | Called to confirm when the user pressed [-] to delete an item. |
CreateItemEditor | Create an item editor - an instance of a DeepObjectItemEditor subclass - to handle a list item. |
GetColumnTitles | Override to add column titles. |
GetEraseAllowed | To remove the destroy buttons, override to return False. |
GetAddAllowed | To remove the add [+] buttons, override to return False. |
GetReorderAllowed | To disable moving items up and down, override to return False. |
For each DeepObjectList
there is exactly one DeepObjectList_Parameters
to configure it. However, there can be more than one item editor type: Each
list item being edited gets its own DeepObjectItemEditor
object, and they
can be of different types to support a list with different kinds of elements.
The recursive.py example file demonstrates how.
An item editor doesn't have to be fixed size. It is possible to use controls
that change size, like e.g. a wx.lib.expando.ExpandoTextCtrl
.
For this to work, the list has to be notified when the height changes. This is
done by calling DeepObjectItemEditor.LayoutCallback
. Or alternatively, by
intercepting DeepObjectItemEditor.SetLayoutCallback
, as is done in the
recurse.py
sample.
Controls are by default added with the flag=wx.ALL
sizer option, but not
wx.EXPAND
.
To use controls that expand to use the available width, override the Add
parameters by returning a dict of Add parameters instead of a control from
DeepObjectItemEditor.Create
.
That is, instead of returning a simple control like this:
def Create(self):
self.edit = wx.TextCtrl(self.parent, -1)
return [self.edit]
return a dict with an override value for flag
:
def Create(self):
self.edit = wx.TextCtrl(self.parent, -1)
return [dict(window=self.edit, flag=wx.ALL|wx.EXPAND)]
If you don't want the DeepObjectList
to take up space when it contains few
or no items, then you may want to re-layout the panel or frame that it's on when
item are added to or removed from the list.
DeepObjectList.SetLayoutCallback
achieves this. When the vertical space needed for this list changes,
then it will call a callback set with DeepObjectList.SetLayoutCallback
.
This can be as simple as using the top-level window's wx.Window.Layout
method:
aDeepObjectList.SetLayoutCallback(myFrame.Layout)
Make a new subclass of DeepObjectItemEditor
and implement these methods.
Methods | |
---|---|
Create | Create wxPython components for editing an item. |
Destroy | Destroy the wxPython components created by Create . |
SetValue | Set the value of being edited. |
GetValue | Read back the edited value. |
SetLayoutCallback | Provides a callable for the editor to use when changing size. |
NotifyPosition | Called when the list position has changed. |
GetItemEditors | Get a list[ItemEditor] with current content of the list. |
Create
returns a list of things to .Add
to a sizer. That can be a single
wx.Window
or that can be the wx.Sizer
at the root of sizer hierarchy.
Often the list only contains a single element. If multiple elements are
returned, then they become columns of the list. The elements are placed in
`wx.GridBagSizer
columns, so that columns from different item editors line up.
SetLayoutCallback
is only required if the wx control changes size during
editing. If it does, it should callback the callback passed to it using
SetLayoutCallback
after the size has changed, to let outer layers know that a
Layout
may be necessary to adjust the positions of other controls around it.
Implement NotifyPosition
to be informed of what position in the list the editor's at.
This is useful for changing the appearance to match the background colour for the position.
Takes two parameters index
, a 0-based index, and bgcol
, the background
colour. Remember that the background colour alternates for even and odd indexes,
so when the editor is moved up or down the list, the background colour should
change to match.
The WxQueue
class is a queue.Queue
subclass designed for sending data
from a worker thread to a function that can change the GUI state accordingly.
The queue is associated with a wx.Window
. Work items can put into the queue
from any arbitrary thread. In the GUI thread the items are then popped one by
one, and an handler function is called with the item. This handler function can
then update the GUI, since it's running on the GUI thread.
WxQueue.__init__ takes three parameters
- wxevthandler: The
wx.Window
that the queue is anchored to. Only oneWxQueue
can be anchored to any window.- onreceiveitem: A callback function that takes two parameters: The
wx.Window
and the next item popped from the queue. Runs on the GUI thread.- maxsize: Parameter for
queue.Queue.__init__
. 0 means unbounded queue.
Use the put
and put_nowait
methods, as described in the queue.Queue
documentation.
There's no need to pop manually from the queue. Just let the onreceiveitem
callback handle that.
This module is mostly an implementation detail for wxdo.wxqueue. It's a self-closing background thread that work items can be posted to.
The queue can be explicitly unbound from the wx.Window
, along with the
callback, using the Unbind
method, if for some reason you no longer want it
to receive queued items. It can then be rebound to a different wx.Window
using the BindReceiveItem
method.
There is usually no need to do that, though.
When the wx.Window
is destroyed, remaining items in the queue are left
unprocessed, ensuring that the onreceiveitem
callback is never called when
the wx
objects it's updating no longer exist.
The sizers module contains a few utility functions to work with sizers.
When wx.Window
objects are moved around, the tab order gets messed up.
This function restored the tab order for all windows in a sizer hierarchy to the
natural left-to-right, top-to-bottom order.
wxdo is copyright Flonidan A/S (https://www.flonidan.dk/) and released under the MIT license.
Written by Anders Munch ([email protected]).
Additional credits: None yet, but contributions welcome.