Maybe this is a bit too broad a question and too much code for this forum, but I'll give it a shot...
OS: Win7x64 6.1.7601
py: 2.7.2 (default, Jun 12 2011, 14:24:46) [MSC v.1500 64 bit (AMD64)]
wx: 2.8-msw-unicode
My question is more of a general nature than in regards to a specific problem to resolve:
Am I making this more difficult than it needs to be, and is there a better way to handle a combination of navigation and validation events?
After searching far and wide for examples of code to manage both navigation between controls and validation of user input, I finally cobled together the following ... and it works exactly as designed, surprisingly enough. I'm just not sure if I'm doing things the "hard way" and if there is a simpler, more elegant way of handling what I'm trying to accomplish.
I set out with the following criteria:
Navigation...
- NavKeyEvt must ONLY move between input controls (TextCtrl, ComboBox, etc.), not non-input action controls (Button, CheckBox, etc.)
- Mouse click and SetFocus() events should be handled in a similar fashion to NavKeyEvt in that focus is always returned to an input control after completing event handling of non-input controls
- Navigation can be:
- bounded: forward nav stops at last input control; backward nav stops at first input control
- unbounded: forward nav from last input control moves to first input control; backward nav from first input control moves to last input control
- Input control can be:
- required: cannot navigate away from input control until a valid value is entered; control background color set according to status of value; bell sounds on attempt to nav away from control with invalid value
- not required: can navigate away from input control if value is empty, but cannot navigate away if invalid value is entered, and background color is set according to status of value
Valdation...
- Character validation must occur on each keypress to allow user to enter only characters that are valid for the particular input control
- Value validation must occur on each navigation event (NavKeyEvt, MouseEvt, or SetFocus()) if value has changed or not been previously validated
- TextCtrl background color must change depending upon validation state of input:
- sysColor: empty/not validated/not required/not focused
- pink: empty/invalid/required/not focused
- yellow: empty/not validated/focused
- green: valid (regardless of focus or requirement)
I've redacted the code as much as possible to show only the relevant sections of validation and navigation, but even this is getting pretty long. Sorry about that.
# IMPORT STATEMENTS ------------------------------------------------------------
import os, sys
import wx
import re
# CLASS DEFINITIONS ------------------------------------------------------------
class InValidator(wx.PyValidator):
""" Validates data from input fields """
def __init__ (self):
wx.PyValidator.__init__(self)
self.Bind(wx.EVT_CHAR, self.onChar)
# Validation types...
self.FILE = 0
self.PATH = 1
# Define valid character regex...
self.valid_file_path = re.compile(r'[^*?"<>|]') # None of these chars
# Define compiled, verbose regex for field value validation...
# File validation...
# Example: c:\test\test.txt
self.valid_file = re.compile(r"""
^ # Search at beginning of string literal
[a-z] # Begins with drive letter a-z
{1} # Only 1 occurrence of this value
: # Followed by a ':'
{1} # Only 1 occurrence of this value
(/|\\) # Followed by a '\' or '/'
{1} # Only 1 occurrence of this value
[^*:?"<>|] # Followed by any except special characters
+ # 1 or more occurrences of these values
(?<!\\) # '\' must not occur right before ...
(\.) # ... a period '.' (dot)...
{1} # Only 1 occurrence of this value
[^*:?"<>|/\\] # (*) Followed by any except special characters
+ # (*) 1 or more occurrences of these values
# txt # Uncomment and change to specify file extension
# {1} # Uncomment if specifying a file extension
$ # ... at the end of the string literal
# Case is ignored ...
""", re.VERBOSE | re.IGNORECASE)
# If a specific valid_file extention is required, comment out the two
# lines with a (*) before the comments, uncomment the two lines below
# those, and change 'txt' to the desired file extension.
# Path validation...
# Example: c:\test\test
self.valid_path = re.compile(r"""
^ # Search at beginning of string literal
[a-z] # Begins with drive letter a-z
{1} # Only 1 occurrence of this value
: # Followed by a ':'
{1} # Only 1 occurrence of this value
(/|\\) # Followed by a '\' or '/'
{1} # Only 1 occurrence of this value
[^*:?"<>|] # Followed by any except special characters
* # 0 or more occurrences of these values
$ # ... at the end of the string literal
# Case is ignored ...
""", re.VERBOSE | re.IGNORECASE)
def Clone (self):
""" Required Validator method """
return self.__class__()
def Validate (self, window):
""" Validate entire control input """
valid = False
# Find the owner of self.curFocus, self.ctrlList, self.inputList, etc...
owner = window.GetTopLevelParent()
control = owner.oldCtrl
value = control.GetValue()
name = control.GetName()
index = owner.ctrlNames.index(name)
flag = owner.inputList[index][3]
if len(value) == 0:
# Update statusWin with "Value is required"
pass
elif flag == self.FILE and self.valid_file.match(value):
if self._isFormatted(value, flag):
valid = True
elif flag == self.PATH and self.valid_path.match(value):
# Check that path exists, ask to create if not
if self._isFormatted(value, flag):
valid = True
return valid
# End Validate() method...
def onChar (self, event):
""" Validate control character input """
key = event.GetKeyCode()
control = event.GetEventObject()
name = control.GetName()
owner = control.GetTopLevelParent()
self.oldCtrl = control
index = owner.ctrlNames.index(name)
flag = owner.inputList[index][3]
valid = False
# Define edit function key presses...
editKeys = (key == wx.WXK_DELETE
or key == wx.WXK_NUMPAD_DELETE
or key == wx.WXK_BACK)
# If MacOS platform...
if 'wxMac' in wx.PlatformInfo:
if event.CmdDown() and key == ord('c'):
key = WXK_CTRL_C
elif event.CmdDown() and key == ord('v'):
key = WXK_CTRL_V
try:
# Attempt to validate against regex...
char = chr(key)
valid = ((flag == self.FILE and self.valid_file_path.search(char))
or (flag == self.PATH and self.valid_file_path.search(char)))
# If valid, allow character in control and reset to not validated...
if valid or editKeys:
owner.inputList[index][2] = False
control.SetBackgroundColour('Yellow')
control.Refresh()
event.Skip()
# If not valid character, do not allow and alert...
elif not wx.Validator_IsSilent():
wx.Bell()
except:
# Key is < 32 or > 255...
event.Skip()
return
# End onChar() method
def TransferToWindow (self):
return True
# End TransferToWindow() method
def TransferFromWindow (self):
return True
# End TransferFromWindow() method
def _isFormatted (self, value, flag):
"""
Check if value is formatted properly.
Modify if/else code blocks to perform more complex validation such as
checking if a file is in the correct format.
"""
if flag == self.FILE:
valid = os.path.isfile(value)
elif flag == self.PATH:
path = os.path.realpath(value)
valid = os.path.isdir(path)
else:
valid = False
return valid
# End _isFormatted() private method
# End InValidator class
class genControl:
""" Factory function to create controls based on user input. """
def __init__ (self, parent, *args, **kwargs):
# Default Method parameters...
self.NAME = kwargs.get('NAME', 'Enter Name')
self.VER = kwargs.get('VER', 'Enter version')
self.COPY = kwargs.get('COPY', 'Enter copyright')
self.ABOUT = kwargs.get('ABOUT', 'Enter about statement')
self.URL = kwargs.get('URL', 'Enter URL')
self.DEV = kwargs.get('DEV', 'Enter developer name(s)')
self.navBound = kwargs.get('NAV_BOUND', False)
self.defValid = kwargs.get('defValid', r'^[^*\\/:?"<>|]+$')
self.defFormat = kwargs.get('defFormat', r'_, <, >, V, S')
self.defExclude = kwargs.get('defExclude', r':*?"<>|')
self.defInclude = kwargs.get('defInclude', r'')
self.newDir = kwargs.get('newDir', False)
self.startDir = kwargs.get('startDir', '.')
self.fileMask = kwargs.get('fileMask', '*.*')
self.fileMode = kwargs.get('fileMode', wx.OPEN)
self.fileTarget = kwargs.get('fileTarget', '')
self.pathTarget = kwargs.get('pathTarget', '')
self.openFolder = kwargs.get('openFolder', False)
# Control background colors...
self.valBGC = kwargs.get('valBGC', 'Green') # Valid
self.selBGC = kwargs.get('selBGC', 'Yellow') # Selected, not valid
self.invBGC = kwargs.get('invBGC', 'Pink') # Invalid
self.sysBGC = wx.SystemSettings_GetColour(wx.SYS_COLOUR_WINDOW)
# Default Method variables...
self.ctrlList = [] # List of input control references
self.ctrlNames = [] # List of input control names
self.inputList = [] # List of input control parameter lists
# [name, value, valid, valType, required]
# Control types...
self.TEXTCONTROL = 0 # wx.TextCtrl
self.STATICTEXT = 1 # wx.StaticText
self.BUTTON = 2 # wx.Button
self.CHECKBOX = 3 # wx.CheckBox
# Validation types...
self.FILE = 0 # Use valid_file regex
self.PATH = 1 # Use valid_path regex
# inputList indices (self.inputList[INTx])...
self.NAME = 0 # STRING, control name
self.VALUE = 1 # STRING, INT, FLOAT, last value
self.VALID = 2 # BOOL, value is validated
self.TYPE = 3 # STRING, validation type to use
self.REQ = 4 # BOOL, value is required
# BUILD CONTROL METHOD -----------------------------------------------------
def buildControl(self, parent, ctrlType, *args, **kwargs):
""" Build control based on 'controlType' definition. """
# buildControl Method specific params...
tip = kwargs.get('tip', 'Enter value')
events = kwargs.get('events', [])
required = kwargs.get('required', False)
valType = kwargs.get('valType', self.FILE)
# Standard wxpython control params...
id = kwargs.get('id', wx.ID_ANY)
value = kwargs.get('value', '')
pos = kwargs.get('pos', wx.DefaultPosition)
size = kwargs.get('size', wx.DefaultSize)
style = kwargs.get('style', wx.TE_PROCESS_TAB)
validator = kwargs.get('validator', wx.DefaultValidator)
name = kwargs.get('name', 'ControlName')
if ctrlType == self.STATICTEXT:
control = wx.StaticText(parent, id, value, pos, size, style, name)
elif ctrlType == self.TEXTCONTROL:
control = wx.TextCtrl(parent, id, value, pos, size, style,
validator, name)
elif ctrlType == self.BUTTON:
style = wx.CENTRE
ctrlEvent = wx.EVT_BUTTON
control = wx.Button(parent, id, value, pos, size, style,
validator, name)
elif ctrlType == self.CHECKBOX:
ctrlEvent = wx.EVT_CHECKBOX
control = wx.CheckBox(parent, id, value, pos, size, style,
validator, name)
# Set control tool tip...
control.SetToolTipString(tip)
# Create ctrlNames, ctrlList, and inputList for input fields...
if ctrlType == self.TEXTCONTROL:
# Append to ctrlNames, ctrlList, and inputList...
valid = False
ctrlArgs = [name, value, valid, valType, required]
self.ctrlNames.append(name)
self.ctrlList.append(control)
self.inputList.append(ctrlArgs)
# Bind events to button and checkbox controls...
elif ctrlType == self.BUTTON or ctrlType == self.CHECKBOX:
for event in events:
control.Bind(ctrlEvent, event)
# Bind common events to all controls...
control.Bind(wx.EVT_SET_FOCUS, self.onSetFocus)
control.Bind(wx.EVT_RIGHT_DOWN, self.onRtBtn)
control.Bind(wx.EVT_LEFT_DOWN, self.onLtBtn)
control.Bind(wx.EVT_NAVIGATION_KEY, self.onNavKey)
return control
# End buildControl() method
# ON EVENT METHODS ---------------------------------------------------------
def onSetFocus (self, event):
""" When focus is gained on new control... """
newCtrl = event.GetEventObject()
try:
# Check if app has just been started...
self.oldCtrl
except:
# App just started, set self.oldCtrl to first text control...
self.oldCtrl = self.ctrlList[0]
try:
# Check if newCtrl is a text control...
index = self.ctrlList.index(newCtrl)
valid = self.inputList[index][self.VALID]
if not valid:
# Set newCtrl color to yellow...
newCtrl.SetBackgroundColour(self.selBGC)
except:
# newCtrl is not a text control, skip event...
event.Skip()
# End onSetFocus() method
def onRtBtn (self, event):
""" When user clicks right mouse button... """
control = event.GetEventObject()
title = 'Tool Tip'
message = control.GetToolTip().GetTip()
style = wx.OK
dialog = wx.MessageDialog(self, message, title, style)
result = dialog.ShowModal()
if result == wx.ID_OK:
dialog.Destroy()
# End onRtBtn() method
def onLtBtn (self, event):
""" When user clicks left mouse button... """
newCtrl = event.GetEventObject()
oldCtrl = self.FindFocus()
#oldCtrl = self.oldCtrl
oldFocus = oldCtrl.GetName()
newFocus = newCtrl.GetName()
if not oldFocus == newFocus:
self.onNavigate(event, oldFocus, newFocus)
# End onLtBtn() method
def onNavKey (self, event):
""" When user clicks TAB, SHIFT+TAB, or RETURN key... """
navDir = event.GetDirection()
control = self.FindFocus()
oldFocus = control.GetName()
index = self.ctrlNames.index(oldFocus)
# ctrlNames indices...
firstIdx = 0
lastIdx = len(self.ctrlNames) - 1
# TAB key pressed...
if navDir == event.IsForward:
index += 1
# SHIFT+TAB keys pressed...
elif navDir == event.IsBackward:
index -= 1
# Ignore all other nav keys (ALT+TAB, etc.)...
else:
return
# If navigation is bound, stops at firstIdx and lastIdx...
if self.navBound and (index > lastIdx or index < firstIdx):
return
# If navigation is not bound, loops between firstIdx and lastIdx...
else:
# Move index from lastIdx to firstIdx (forward nav)...
if index > lastIdx:
index = firstIdx
# Move index from firstIdx to lastIdx (backwards nav)...
elif index < firstIdx:
index = lastIdx
newFocus = self.ctrlNames[index]
self.onNavigate(event, oldFocus, newFocus)
# End onNavKey() method
def onNavigate (self, event, oldFocus, newFocus):
""" When user navigates to new control... """
# ctrlNames indices...
firstIdx = 0
lastIdx = len(self.ctrlNames) - 1
try:
# If oldCtrl is a TextCtrl, get its values...
oldCtrl = self.FindWindowByName(oldFocus)
oldIdx = self.ctrlNames.index(oldFocus)
newVal = oldCtrl.GetValue()
oldVal = self.inputList[oldIdx][self.VALUE]
oldValid = self.inputList[oldIdx][self.VALID]
required = self.inputList[oldIdx][self.REQ]
except:
# BUGFIX: If oldCtrl is a non-TextCtrl, escape navigation logic...
# Only occurs if there is a bug in non-TextCtrl event handling...
event.Skip()
return
try:
# Is newFocus in control list...
newIdx = self.ctrlNames.index(newFocus)
newCtrl = self.ctrlList[newIdx]
"""
# If navigating backwards...
if ((oldIdx > newIdx # From later to earlier control...
and oldIdx != firstIdx # and oldCtrl is not first ctrl...
and oldIdx != lastIdx # and oldCtrl is not last ctrl...
and not oldValid) # and oldVal is not yet validated
or (oldIdx == lastIdx # -or- oldCtrl is first control...
and newIdx < lastIdx # and newCtrl is later control...
and (not oldValid # and oldVal is not yet validated...
or not required))): # or oldCtrl is not required
oldCtrl.SetBackgroundColour(self.sysBGC)
oldCtrl.Refresh()
"""
isValid = self._isValid(oldCtrl, newFocus)
if not isValid and (newVal or required):
# If oldVal is not valid and a value is entered or required...
return
#elif isValid or (not isValid and not required):
else:
# If oldVal is valid, or is not valid and is not required...
self.oldCtrl = newCtrl
newCtrl.SetFocus()
except:
# If not in control list...
self._isValid(oldCtrl, newFocus)
event.Skip()
# End onNavigate() method
def onBrowseFile (self, event):
""" Going to browse for file... """
control = self.FindWindowByName(self.fileTarget)
# If previous controls are valid or not required...
if self._preValid(control):
control.SetFocus()
current = control.GetValue()
directory = os.path.split(current)
if os.path.isdir(current):
directory = current
current = ''
elif directory and os.path.isdir(directory[0]):
current = directory[1]
directory = directory[0]
else:
directory = self.startDir
current = ''
tip = 'Select a file...'
dialog = wx.FileDialog(self, tip, directory, current,
self.fileMask, self.fileMode)
result = dialog.ShowModal()
dialog.Destroy()
if result == wx.ID_OK:
control.SetValue(dialog.GetPath())
# End onBrowseFile() method
def onBrowseDir (self, event):
""" Going to browse for directory... """
control = self.FindWindowByName(self.pathTarget)
# If previous controls are valid or not required...
if self._preValid(control):
control.SetFocus()
style = 0
if not self.newDir:
style |= wx.DD_DIR_MUST_EXIST
self.changeCallback = None
message = 'Select a Folder'
dialog = wx.DirDialog(self, message, self.startDir, style)
result = dialog.ShowModal()
dialog.Destroy()
if result == wx.ID_OK:
control.SetValue(dialog.GetPath())
# End onBrowseDir() method
def onCheck (self, event):
""" Checkbox selected """
self.oldCtrl.SetFocus()
checkBox = event.GetEventObject()
if checkBox.IsChecked():
self.openFolder = True
else:
self.openFolder = False
# End onCheck() method
def onCloseWindow (self, event = None):
""" On closing the app window (Frame) """
win = wx.Window_FindFocus()
if win != None:
# Note: you really have to use wx.wxEVT_KILL_FOCUS
# instead of wx.EVT_KILL_FOCUS here:
win.Disconnect(-1, -1, wx.wxEVT_KILL_FOCUS)
self.Destroy()
# End onCloseWindow() method
# PRIVATE METHODS ----------------------------------------------------------
def _isValid (self, control, newFocus = None):
""" Check if control is valid """
newVal = control.GetValue()
index = self.ctrlList.index(control)
oldVal = self.inputList[index][self.VALUE]
valid = self.inputList[index][self.VALID]
required = self.inputList[index][self.REQ]
# If value has changed or not been validated...
if newVal != oldVal or not valid:
validate = control.GetParent().Validate()
# Check if new value is valid...
if validate:
self.inputList[index][self.VALUE] = newVal
self.inputList[index][self.VALID] = True
valid = True
# New value is not valid...
else:
self.inputList[index][self.VALID] = False
valid = False
# If newVal is valid...
if valid:
control.SetBackgroundColour(self.valBGC)
# If newVal is not valid and not required...
elif not newVal and not required:
control.SetBackgroundColour(self.sysBGC)
# If newVal is not valid and required...
else:
control.SetBackgroundColour(self.invBGC)
if newFocus in self.ctrlNames:
wx.Bell()
control.Refresh()
return valid
# End _isValid() private method
def _preValid (self, control):
""" Check if previous input control is required and invalid """
newFocus = control.GetName()
index = self.ctrlList.index(control)
valid = True
# Check validation of all previous input controls...
for oldCtrl in self.ctrlList[:index]:
oldIdx = self.ctrlList.index(oldCtrl)
required = self.inputList[oldIdx][self.REQ]
# If oldCtrl value is required and invalid, cancel event...
if not self._isValid(oldCtrl, newFocus) and required:
oldCtrl.SetFocus()
valid = False
break
return valid
#End _preValid() private method
# END genControl CLASS ---------------------------------------------------------
class myTestFrame(wx.Frame, genControl):
""" Create GUI for pyARCsquared app """
def __init__ (self, parent, *args, **kwargs):
wx.Frame.__init__(self, parent, wx.ID_ANY, 'myTestFrame')
genControl.__init__(self, parent)
self.Bind(wx.EVT_CLOSE, self.onCloseWindow)
self.fileTarget = 'inFileInput'
self.pathTarget = 'outPathInput'
# Build panel and border sizer...
myTestPanel = wx.Panel(self)
myTestBorder = wx.BoxSizer(wx.VERTICAL)
myTestBorder.Add(myTestPanel, 0, wx.EXPAND)
inFileLabel = self.buildControl(
myTestPanel,
self.STATICTEXT,
value = 'FILE NAME:',
size = (85, -1),
style = wx.ALIGN_RIGHT,
name = 'inFileLabel',
tip = 'Enter or browse for file'
)
inFileInput = self.buildControl(
myTestPanel,
self.TEXTCONTROL,
required = True,
size = (295, -1),
style = wx.ALIGN_LEFT,
validator = InValidator(),
valType = self.FILE,
name = 'inFileInput',
tip = 'Type input filename or click Browse to select a file',
)
inFileBrowse = self.buildControl(
myTestPanel,
self.BUTTON,
value = 'Browse File',
name = 'inFileBrowse',
tip = 'Browse to select an input file',
size = (80, -1),
events = [self.onBrowseFile]
)
# Build outPath controls...
outPathLabel = self.buildControl(
myTestPanel,
self.STATICTEXT,
value = 'OUTPUT PATH:',
size = (85, -1),
style = wx.ALIGN_RIGHT,
name = 'outPathLabel',
tip = 'Enter or browse for output path'
)
outPathInput = self.buildControl(
myTestPanel,
self.TEXTCONTROL,
size = (295, -1),
style = wx.ALIGN_LEFT,
validator = InValidator(),
valType = self.PATH,
name = 'outPathInput',
tip = 'Type output path or click Browse to select a folder',
)
outPathBrowse = self.buildControl(
myTestPanel,
self.BUTTON,
value = 'Browse Path',
name = 'outPathBrowse',
tip = 'Browse to select an output folder',
size = (80, -1),
events = [self.onBrowseDir]
)
inFileGrid = wx.FlexGridSizer(1, 3, 5, 5)
outPathGrid = wx.FlexGridSizer(1, 3, 5, 5)
inFileGrid.AddMany([
(inFileLabel, 0,
wx.ALIGN_RIGHT | wx.ALIGN_CENTER_VERTICAL),
(inFileInput, 0,
wx.EXPAND | wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL),
(inFileBrowse)
])
outPathGrid.AddMany([
(outPathLabel, 0,
wx.ALIGN_RIGHT | wx.ALIGN_CENTER_VERTICAL),
(outPathInput, 0,
wx.EXPAND | wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL),
(outPathBrowse)
])
myBoxSizer = wx.BoxSizer(wx.VERTICAL)
myBoxSizer.Add(inFileGrid, 0, wx.EXPAND | wx.ALL, 5)
myBoxSizer.Add(outPathGrid, 0, wx.EXPAND | wx.ALL, 5)
myTestPanel.SetSizerAndFit(myBoxSizer)
# Layout Frame Panel...
self.SetAutoLayout(True)
self.SetSizerAndFit(myTestBorder)
self.Layout()
# End myTestFrame class
class myTestApp (wx.App):
""" The wx.App for myTestApp """
def OnInit (self):
wx.InitAllImageHandlers()
frame = myTestFrame(None)
frame.Center(True)
frame.Show(True)
self.SetTopWindow(frame)
return True
# End myTestApp class
# Run myTestApp...
if __name__ == '__main__':
app = myTestApp(False)
wx.SystemOptions.SetOptionInt('msw.window.no-clip-children', 1)
app.MainLoop()