3
\$\begingroup\$

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:

  1. Complicated "Form Structure" definition. It is deeply nested at times.
  2. 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.
  3. 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:

  1. 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".
  2. 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.
  3. 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.
  4. 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.

\$\endgroup\$
2
\$\begingroup\$
wx.ComboTreeBox = ComboTreeBox
wx.FloatSpin = FloatSpin

Modifying the contents of another module is suspicious and likely to cause trouble. Your changes might not be there when another module is importing and could cause trouble.

  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))

Empty lines can be used to put related statements together. The benefit is lost when you do it for every statement.

  focused = self._panel._form.pop('Focus', None)

The _ in _form indicates that its for the internal use of the panel. So why are you accessing it here?

if not hasattr(self, 'q'):
  self.q = getattr(self.GrandParent, 'q', None)

What the fried monkey is q? It probably need a better name.

class GeneralSettings(Form):
  def __init__(self, parent, refid = None):
    self._form = {

Perhaps form should be a class attribute rather then an attribute. Then you don't have a define a constructor in this class whatsoever.

      'Parts': OD([

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},

Instead of dictionaries, how about objects?

     widgets.StaticText(label='Remove messages after'),
     widgets.ComboBox(name='unit', flags = wx.LEFT | wx.RIGHT, style = wx.CBREADONLY)

Then you can call methods on these objects to do all the work you are doing above. That way you can move all the widget specific data out of it. It should also make it easier to extend your system with new widgets. The system could also be extended to support things like OptionalSection, etc.

      'Defaults': {
        'interval': 3,
        'log-level': 'Low',
        'req-admin': False,
        'unit': 'Days',
        'printtasks': 5,
        'jobdrop': 'Copy Job to Queue'
      }

These are being defined far away from the rest of the widget. Why?

You seem to comparing the ease of use of this versus manually writing the code. However, if I compare it to the use of a gui builder its not clear that its helpful. i.e. all the code that you are saving is already written for me by the builder tool and so it just isn't helpful on that front.

\$\endgroup\$
  • \$\begingroup\$ OD is OrderedDict, imported and aliased. Missed that in the copy / paste. They're declared as attributes rather that class attributes because I wanted delayed instantiation (built when requested, not before hand). The same is true for storing dictionaries - the widgets have to be parented, and the parents don't exist when declaring the _form variable. Defaults are declared separately to facilitate loading them from some other data source. The loadDefaults method can be overridden to set custom values for self._form['Defaults']. Thanks for the feedback though! Much Appreciated. \$\endgroup\$ – g.d.d.c Aug 9 '11 at 22:07
  • \$\begingroup\$ @g.d.d.c, the objects you'd create wouldn't be the widgets themselves. They'd be objects you define which know how to build the widgets, move data to/from the widgets, etc. It seems to me that creation those object should be no more problematic then creating the lists and dictionaries. \$\endgroup\$ – Winston Ewert Aug 9 '11 at 22:17
  • \$\begingroup\$ I wanted to come back to this and thank you for your suggestions. I ended up cleaning up large parts of it, and implementing classes in place of the dictionaries like you suggested. Thanks so much! \$\endgroup\$ – g.d.d.c Jun 1 '12 at 17:08

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.