Presented for critique are a pair of classes which automate Sizer Creation and Layout in wxPython.
import wx
from wx.lib.combotreebox import ComboTreeBox
from wx.lib.agw.floatspin import FloatSpin
wx.ComboTreeBox = ComboTreeBox
wx.FloatSpin = FloatSpin
class FormDialog(wx.Dialog):
def __init__(self, parent, id = -1, panel = None, title = "Unnamed Dialog",
modal = False, sizes = (-1, -1), refid = None):
wx.Dialog.__init__(self, parent, id, title,
style = wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER)
if panel is not None:
self._panel = panel(self, refid)
self._panel.SetSizeHints(*sizes)
ds = wx.GridBagSizer(self._panel._gap, self._panel._gap)
ds.Add(self._panel, (0, 0), (1, 1), wx.EXPAND | wx.ALL, self._panel._gap)
ds.Add(wx.StaticLine(self), (1, 0), (1, 1), wx.EXPAND | wx.RIGHT | wx.LEFT, self._panel._gap)
self.bs = self.CreateButtonSizer(self._panel._form.get('Buttons', wx.OK | wx.CANCEL))
ds.Add(self.bs, (2, 0), (1, 1), wx.ALIGN_RIGHT | wx.ALL, self._panel._gap)
ds.AddGrowableCol(0)
ds.AddGrowableRow(0)
self.SetSizerAndFit(ds)
self.Center()
self.Bind(wx.EVT_BUTTON, self._panel.onOk, id = wx.ID_OK)
self.Bind(wx.EVT_BUTTON, self._panel.onClose, id = wx.ID_CANCEL)
focused = self._panel._form.pop('Focus', None)
if focused:
self._panel.itemMap[focused].SetFocus()
if modal:
self.ShowModal()
else:
self.Show()
class Form(wx.Panel):
def __init__(self, parent = None, refid = None, id = -1, gap = 3, sizes = (-1, -1)):
wx.Panel.__init__(self, parent, id)
self.SetSizeHints(*sizes)
self._gap = gap
self.itemMap = {}
self.refid = refid
if not hasattr(self, 'q'):
self.q = getattr(self.GrandParent, 'q', None)
if hasattr(self, '_form'):
# Before building verify that several required elements exist in the form
# definition object.
self.loadDefaults()
self._build()
self._bind()
def _build(self):
"""
The Build Method automates sizer creation and element placement by parsing
a properly constructed object.
"""
# The Main Sizer for the Panel.
panelSizer = wx.BoxSizer(wx.VERTICAL)
# Parts is an Ordered Dictionary of regions for the form.
for container, blocks in self._form['Parts'].iteritems():
flags, sep, display = container.rpartition('-') #@UnusedVariable
if 'NC' in flags:
for block in blocks:
element, proportion = self._parseBlock(block)
panelSizer.Add(element, proportion, flag = wx.EXPAND | wx.ALL, border = self._gap)
else:
box = wx.StaticBox(self, -1, display)
sizer = wx.StaticBoxSizer(box, wx.VERTICAL)
for block in blocks:
element, proportion = self._parseBlock(block)
sizer.Add(element, proportion, flag = wx.EXPAND | wx.ALL)
if 'G' in flags:
sizerProportion = 1
else:
sizerProportion = 0
panelSizer.Add(sizer, sizerProportion, flag = wx.EXPAND | wx.ALL, border = self._gap)
continue
self.SetSizerAndFit(panelSizer)
def _bind(self): pass
def _parseBlock(self, block):
"""
The form structure is a list of rows (blocks) in the form. Each row
consists of a single element, a row of elements, or a sub-grid of
elements. These are represented by dictionaries, tuples, or lists,
respectively and are each processed differently.
"""
proportion = 0
if isinstance(block, list):
item = self.makeGrid(block)
elif isinstance(block, tuple):
item = self.makeRow(block)
elif isinstance(block, dict):
proportion = block.pop('proportion', 0)
item = self.makeElement(block)
return item, proportion
def makeElement(self, object):
"""
In the form structure a dictionary signifies a single element. A single
element is automatically assumed to expand to fill available horizontal
space in the form.
"""
sizer = wx.BoxSizer(wx.HORIZONTAL)
flags = object.pop('flags', wx.ALL)
element = self._makeWidget(object)
sizer.Add(element, 1, border = self._gap,
flag = wx.EXPAND | wx.ALIGN_CENTER_VERTICAL | flags)
return sizer
def makeRow(self, fields):
"""
In the form structure a tuple signifies a row of elements. These items
will be arranged horizontally without dependency on other rows. Each
item may provide a proportion property which can cause that element to
expand horizontally to fill space.
"""
sizer = wx.BoxSizer(wx.HORIZONTAL)
for field in fields:
proportion = field.pop('proportion', 0)
sizer.Add(self.makeElement(field), proportion,
flag = wx.ALIGN_CENTER_VERTICAL | wx.ALL)
return sizer
def makeGrid(self, rows):
"""
In the form structure a list signifies a grid of elements (equal width
columns, rows with similar numbers of elements, etc).
"""
sizer = wx.GridBagSizer(0, 0)
for row, fields in enumerate(rows):
for col, field in enumerate(fields):
flags = field.pop('flags', wx.ALL)
# Each item may specify that its row or column 'grow' or expand to fill
# the available space in the form.
rowGrowable, colGrowable = (field.pop('rowGrowable', False),
field.pop('colGrowable', False))
if rowGrowable:
sizer.AddGrowableRow(row)
if colGrowable:
sizer.AddGrowableCol(col)
span = field.pop('span', (1, 1))
colpos = field.pop('colpos', col)
rowpos = field.pop('rowpos', row)
element = self._makeWidget(field)
sizer.Add(element, (rowpos, colpos), span, border = self._gap,
flag = wx.ALIGN_CENTER_VERTICAL | flags)
return sizer
def _makeWidget(self, params):
"""
This function actually creates the widgets that make up the form. In most
cases these will be items from the wx libraries, though they may be
'custom' elements which require delayed instantiation by leveraging
lambdas.
"""
type = params.pop('type')
if type == 'Custom':
lookup = params.pop('lookup')
element = self._form[lookup](self)
self.itemMap[lookup] = element
else:
# StaticText items may carry a bold attribute - retrieve it for use later.
if type == 'StaticText':
bold = params.pop('bold', False)
# ComboBoxes and ListBoxes need to have choices.
if type in ('ComboBox', 'ListBox'):
params['choices'] = self._form['Options'].get(params['name'], [])
element = getattr(wx, type)(self, -1, **params)
if type == 'ComboTreeBox':
choices = self._form['Options'].get(params['name'], [])
for category, options in choices:
id = element.Append(category)
for option in options:
element.Append(option, parent = id)
element.GetTree().Expand(id)
# Require the user to use the browse buttons for File / Folder browsing.
if type in ('DirPickerCtrl', 'FilePickerCtrl'):
element.GetTextCtrl().SetEditable(False)
if params.has_key('name'):
# Populate the itemMap - facilitates element retrieval / event bindings.
self.itemMap[params['name']] = element
# Default value assignment. Must unfortunately do a dance to check
# element type - some require ints / floats, while others are ok with
# strings.
value = self._form['Defaults'].get(params['name'], '')
if hasattr(element, 'SetValue'):
if type == 'SpinCtrl':
if value == '':
value = 0
element.SetValue(int(value))
elif type == 'FloatSpin':
if value == '':
value = 0
element.SetValue(float(value))
elif type in ('CheckBox', 'RadioButton'):
element.SetValue(bool(value))
else:
element.SetValue(unicode(value))
if type == 'ComboTreeBox':
element._text.SetInsertionPoint(0)
elif hasattr(element, 'SetPath'):
element.SetPath(value)
elif type != 'Button':
print element
# Check for elements we should disable at load time.
if params['name'] in self._form['Disabled']:
element.Enable(False)
# Check for a Validator and add it if required.
try:
validator = self._form['Validators'][params['name']]()
element.SetValidator(validator)
except KeyError: pass # No Validator Specified.
# Take the bold attribute into account for StaticText elements.
if type == 'StaticText' and bold:
font = element.GetFont()
font.SetWeight(wx.BOLD)
element.SetFont(font)
return element
def loadDefaults(self):
if 'Defaults' not in self._form: self._form['Defaults'] = {}
if 'Disabled' not in self._form: self._form['Disabled'] = []
if 'Validators' not in self._form: self._form['Validators'] = {}
self.loadOptions()
def loadOptions(self):
if 'Options' not in self._form: self._form['Options'] = {}
def onOk(self, evt):
self.onClose(evt)
def onClose(self, evt):
self.GetParent().Destroy()
The Form class is lengthy, and I appreciate the patience of anyone who's managed to read this far. The entire expectation is that Form should be subclassed like so:
class GeneralSettings(Form):
def __init__(self, parent, refid = None):
self._form = {
'Parts': OD([
('Log Settings', [
({'type': 'StaticText', 'label': 'Remove messages after'},
{'type': 'FloatSpin', 'name': 'interval', 'min_val': 1, 'max_val': 10, 'digits': 2, 'increment': 0.1, 'size': (55, -1), 'flags': wx.LEFT | wx.RIGHT},
{'type': 'ComboBox', 'name': 'unit', 'flags': wx.LEFT | wx.RIGHT, 'style': wx.CB_READONLY},
{'type': 'StaticText', 'label': 'Log Detail:'},
{'type': 'ComboBox', 'name': 'log-level', 'flags': wx.LEFT | wx.RIGHT, 'style': wx.CB_READONLY, 'proportion': 1})
]),
('Folder Settings', [
[({'type': 'StaticText', 'label': 'Spool Folder:'},
{'type': 'DirPickerCtrl', 'name': 'dir', 'style': wx.DIRP_USE_TEXTCTRL, 'colGrowable': True, 'flags': wx.EXPAND | wx.ALL}),
({'type': 'StaticText', 'label': 'Temp Folder:'},
{'type': 'DirPickerCtrl', 'name': 'temp', 'style': wx.DIRP_USE_TEXTCTRL, 'flags': wx.EXPAND | wx.ALL})]
]),
('Email Notifications', [
[({'type': 'StaticText', 'label': 'Alert Email To:'},
{'type': 'TextCtrl', 'name': 'alert-to', 'colGrowable': True, 'flags': wx.EXPAND | wx.ALL}),
({'type': 'StaticText', 'label': 'Alert Email From:'},
{'type': 'TextCtrl', 'name': 'alert-from', 'flags': wx.EXPAND | wx.ALL}),
({'type': 'StaticText', 'label': 'Status Email From:'},
{'type': 'TextCtrl', 'name': 'status-from', 'flags': wx.EXPAND | wx.ALL}),
({'type': 'StaticText', 'label': 'Alert Email Server:'},
{'type': 'TextCtrl', 'name': 'alert-host', 'flags': wx.EXPAND | wx.ALL}),
({'type': 'StaticText', 'label': 'Login:'},
{'type': 'TextCtrl', 'name': 'alert-login', 'flags': wx.EXPAND | wx.ALL}),
({'type': 'StaticText', 'label': 'Password:'},
{'type': 'TextCtrl', 'name': 'alert-password', 'style': wx.TE_PASSWORD, 'flags': wx.EXPAND | wx.ALL})]
]),
('Admin User', [
({'type': 'CheckBox', 'name': 'req-admin', 'label': 'Require Admin Rights to make changes.'},
{'type': 'Button', 'name': 'admin-button', 'label': 'Customize Permissions ... '})
]),
('User Interface Behavior', [
({'type': 'StaticText', 'label': 'Job Drag Options'},
{'type': 'ComboBox', 'name': 'jobdrop', 'flags': wx.LEFT | wx.RIGHT | wx.EXPAND})
])
]),
'Options': {
'unit': ['Hours', 'Days', 'Months'],
'log-level': ['Minimal', 'Low', 'High', 'Debug'],
'jobdrop': ['Copy Job to Queue', 'Move Job to Queue']
},
'Defaults': {
'interval': 3,
'log-level': 'Low',
'req-admin': False,
'unit': 'Days',
'printtasks': 5,
'jobdrop': 'Copy Job to Queue'
}
}
Form.__init__(self, parent, refid)
Once the form is subclassed and the 'structure' is defined - the OrderedDict located in self._form['Parts']
, you can open a dialog with the elements like this:
FormDialog(frame, panel = GeneralSettings, title = "General Settings")
There are a number of initial critiques that I already have for it:
- Complicated "Form Structure" definition. It is deeply nested at times.
- Uses type analysis - Lists represent a grid of elements broken up into one or more rows, Tuples are rows of elements, and Dictionaries are individual elements. This is traditionally non-pythonic.
- Combined Properties - in the dictionaries that represent individual elements there are directives that are removed and used for Sizer Creation. Specifically, things like 'colGrowable', 'proportion', etc. These are included in the widget definition and popped out for use when adding the element to the sizer during construction.
However, it provides the following benefits as well:
- Automated Sizers - no need to ever directly create a sizer, set one to expand, determine where they are parented, etc. This is all determined based on the aforementioned "Form Structure".
- Easy conditional inclusion of elements or removal based on feature criteria. In the General Settings example above, one might add this to conditional remove a region from the form:
if featureDisabled: del self._form['Parts']['Admin User']
. Because the sizers are auto-generated there is no need to conditionally exclude the creation of the containing sizers, elements themselves, etc (typically spread across several methods in other layout helpers). This all happens during the _build method. - Easy, direct access to form elements. All Widgets are added to a member variable
self.itemMap
, keyed by the Name field for the widget. Anything with a name can be accessed from any other method of the form. - Combined Properties - centralized declaration of all relevant attributes for each widget. You declare a widget using its keyword values, but also provide sizer directives such as proportion or position. I consider this both convenient and counter-intuitive to new developers, which is why it's listed both in pros and in cons.
There are a couple of additional Demos available.