#!/usr/bin/python # TODO # - sorting # - on-idle #------------------------------------------------------------ from wxPython.wx import * import gettext, string, types, time #_ = gettext.gettext _ = lambda x: x import gmLog """ A class, extending wxTextCtrl, which has a drop-down pick list, automatically filled based on the inital letters typed. Based on the interface of Richard Terry's Visual Basic client This is based on seminal work by Ian Haywood """ __author__ = "Karsten Hilbert " __version__ = "$Revision$" __log__ = gmLog.gmDefLog gmpw_true = (1==1) gmpw_false = (1==0) #------------------------------------------------------------ # generic base class #------------------------------------------------------------ class cMatchProvider: """Base class for match providing objects. Match sources might be: - database tables - flat files - previous input - config files - in-memory list created on the fly """ __threshold = {} no_matches = {'ID':1, 'label':_('*no matching items found*'), 'weight':1} word_separators = tuple(string.punctuation + string.whitespace) #-------------------------------------------------------- def __init__(self): self.enableMatching() self.enableLearning() self.setThresholds() self.setWordSeparators() #-------------------------------------------------------- # actions #-------------------------------------------------------- def getMatches(self, aFragment = None): """Return matches according to aFragment and matching thresholds. FIXME: design decision: we don't worry about data source changes during the lifetime of a MatchProvider FIXME: sort according to weight FIXME: append _("*get all items*") on truncation FIXME: maybe we should just return empty if not matches found thus showing no list """ # do we return matches at all ? if not self.__deliverMatches: return (gmpw_false, []) # sanity check if aFragment == None: __log__.Log(gmLog.lErr, 'Cannot find matches without a fragment.') raise ValueError, 'Cannot find matches without a fragment.' # user explicitely wants all matches # FIXME: should "*" be hardcoded ? if aFragment == "*": return (gmpw_true, self.getAllMatches()) # order is important ! if len(aFragment) >= self.__threshold['substring']: result = self.getMatchesBySubstr(aFragment) return result elif len(aFragment) >= self.__threshold['word']: result = self.getMatchesByWord(aFragment) return result elif len(aFragment) >= self.__threshold['phrase']: result = self.getMatchesByPhrase(aFragment) return result else: return (gmpw_false, []) #-------------------------------------------------------- def getAllMatches(self): pass #-------------------------------------------------------- def getMatchesByPhrase(self, aFragment): pass #-------------------------------------------------------- def getMatchesByWord(self, aFragment): pass #-------------------------------------------------------- def getMatchesBySubstr(self, aFragment): pass #-------------------------------------------------------- def increaseScore(self, anItem): """Increase the score/weighting for a particular item due to it being used.""" pass #-------------------------------------------------------- def learn(self, anItem, aContext): """Add this item to the match source so we can find it next time around. - aContext can be used to denote the context where to use this item for matching - it is typically used to select a context sensitive item list during matching """ pass #-------------------------------------------------------- def forget(self, anItem, aContext): """Remove this item from the match source if possible.""" pass #-------------------------------------------------------- # configuration #-------------------------------------------------------- def setThresholds(self, aPhrase = 1, aWord = 3, aSubstring = 5): """Set match location thresholds. - the fragment passed to getMatches() must contain at least this many characters before it triggers a match search at: 1) phrase_start - start of phrase (first word) 2) word_start - start of any word within phrase 3) in_word - _inside_ any word within phrase """ # sanity checks if aSubstring < aWord: __log__.Log(gmLog.lErr, 'Setting substring threshold (%s) lower than word-start threshold (%s) does not make sense. Retaining original thresholds (%s:%s, respectively).' % (aSubstring, aWord, self.__threshold['substring'], self.__threshold['word'])) return (1==0) if aWord < aPhrase: __log__.Log(gmLog.lErr, 'Setting word-start threshold (%s) lower than phrase-start threshold (%s) does not make sense. Retaining original thresholds (%s:%s, respectively).' % (aSubstring, aWord, self.__threshold['word'], self.__threshold['phrase'])) return (1==0) # now actually reassign thresholds self.__threshold['phrase'] = aPhrase self.__threshold['word'] = aWord self.__threshold['substring'] = aSubstring return (1==1) #-------------------------------------------------------- def setWordSeparators(self, separators = None): # sanity checks if type(separators) != types.StringType: __log__.Log(gmLog.lErr, 'word separators argument is of type %s, expected type string' % type(separators)) return None if separators == "": __log__.Log(gmLog.lErr, 'Not defining any word separators does not make sense ! Falling back to default (%s).' % string.punctuation + string.whitespace) return None self.word_separators = tuple(separators) #-------------------------------------------------------- def disableMatching(self): """Don't search for matches. Useful if a slow network database link is detected, for example. """ self.__deliverMatches = gmpw_false #-------------------------------------------------------- def enableMatching(self): self.__deliverMatches = gmpw_true #-------------------------------------------------------- def disableLearning(self): """Immediately stop learning new items.""" self.__learnNewItems = gmpw_false #-------------------------------------------------------- def enableLearning(self): """Immediately start learning new items.""" self.__learnNewItems = gmpw_true #------------------------------------------------------------ # usable instances #------------------------------------------------------------ class cMatchProvider_FixedList(cMatchProvider): """Match provider where all possible options can be held in a reasonably sized, pre-allocated list. """ def __init__(self, aSeq = None): """aSeq must be a list of dicts. Each dict must have the keys (ID, label, weight) """ if not (type(aSeq) == types.ListType) or (type(aSeq) == types.TupleType): print "aList must be a list or tuple" return None self.__currMatches = aSeq cMatchProvider.__init__(self) #-------------------------------------------------------- # internal matching algorithms # # if we end up here: # - aFragment will not be None # - we _do_ deliver matches (whether we find any is a different story) #-------------------------------------------------------- def getMatchesByPhrase(self, aFragment): """Return matches for aFragment at start of phrases.""" matches = [] # look for matches for item in self.__currMatches: if string.find(item['label'], aFragment) == 0: matches.append(item) # no matches found if len(matches) == 0: return (gmpw_false, []) else: return (gmpw_true, matches) #-------------------------------------------------------- def getMatchesByWord(self, aFragment): """Return matches for aFragment at start of words inside phrases.""" matches = [] # look for matches for item in self.__currMatches: pos = string.find(item['label'], aFragment) # at start of phrase if pos == 0: matches.append(item) # as a true substring elif pos > 0: # but use only if substring is at start of a word if (item['label'])[pos-1] in self.word_separators: matches.append(item) # no matches found if len(matches) == 0: return (gmpw_false, []) return (gmpw__true, matches) #-------------------------------------------------------- def getMatchesBySubstr(self, aFragment): """Return matches for aFragment as a true substring.""" matches = [] # look for matches for item in self.__currMatches: if string.find(item['label'], aFragment) != -1: matches.append(item) # no matches found if len(matches) == 0: return (gmpw_false, []) #return [self.no_matches] return (gmpw_true, matches) #-------------------------------------------------------- def getAllMatches(self): """Return all items.""" matches = [] for item in self.__currMatches: matches.append(item) # no matches found if len(matches) == 0: return (gmpw_false, []) #return [self.no_matches] return (gmpw_true, matches) #------------------------------------------------------------ def sort (list): """ convience function, implements mergesort on option list """ index = list[0] higher = [] lower = [] for i in list[1:]: if i[1] >= index[1]: higher.append (i) else: lower.append (i) r = [] if higher: r = sort (higher) r.append (index) if lower: r.extend (sort (lower)) return r #------------------------------------------------------------ class cPhraseWheel (wxTextCtrl): """Widget for smart guessing of user fields, after Richard Terry's interface. Inherits wxTextCtrl. """ def __init__ (self, parent, id_callback, id = -1, pos = wxDefaultPosition, size = wxDefaultSize, aMatchProvider = None): """ id_callback holds a refence to another Python function. This function is called when the user selects a value. This function takes a single parameter -- being the ID of the value so selected""" if aMatchProvider == None: __log__.Log(gmLog.lPanic, "Cannot work without a match provider object") return None if not isinstance(aMatchProvider, cMatchProvider): __log__.Log(gmLog.lPanic, "aMatchProvider must be a match provider object") return None self.__matcher = aMatchProvider wxTextCtrl.__init__ (self, parent, id, "", pos, size) self.SetBackgroundColour (wxColour (200, 100, 100)) self.parent = parent # set event handlers # 1) entered text changed EVT_TEXT (self, self.GetId(), self.__on_text_update) # 2) a key was released EVT_KEY_UP (self, self.__on_key_up) # 3) we are idling EVT_IDLE (self, self.__on_idle) # 4) evil user wants to resize widget EVT_SIZE (self, self.resize) self.id_callback = id_callback self.__picklist_win = wxPopupTransientWindow (parent, -1) self.panel = wxPanel(self.__picklist_win, -1) self.__picklist = wxListBox(self.panel, -1, style=wxLB_SINGLE | wxLB_NEEDED_SB) self.listhasfocus = 0 # whether list has focus #-------------------------------------------------------- def __updateMatches(self): """Get the matches for the currently typed input fragment.""" # FIXME: maybe some special handling of NONE etc needed # get all items currently matching result = self.__matcher.getMatches(self.GetValue()) (matched, self.__currMatches) = result # and refill our picklist with them self.__picklist.Clear() if matched: for item in self.__currMatches: self.__picklist.Append(item['label'], clientData = item['ID']) #-------------------------------------------------------- def __dropdown_picklist(self): """Display the pick list.""" print "dropping down pick list" self.listhasfocus = gmpw_true # give focus to list self.__picklist.SetSelection (0) # select first value # recalculate position pos = self.ClientToScreen ((0,0)) dim = self.GetSize () self.__picklist_win.Position(pos, (0, dim.height)) # and show it self.__picklist_win.Popup() #-------------------------------------------------------- # specific event handlers #-------------------------------------------------------- def OnSelected (self, n): """Gets called when user selected a list item.""" data = self.__picklist.GetClientData (n) # get data associated with selected item self.SetValue (self.__picklist.GetString (n)) # tell the input field to display that data self.__picklist_win.Dismiss() # dismiss the dropdown list window self.listhasfocus = gmpw_false # take focus away from list self.id_callback (data) # and tell our parents about the user's selection #-------------------------------------------------------- def __on_enter (self): """Called when the user pressed . FIXME: this might be exploitable for some nice statistics ... """ print "on " # if we are in the drop down list if self.listhasfocus: # get selected item selected = self.__picklist.GetSelection() # move back to input field self.listhasfocus = 0 # and tell it about the selected item self.OnSelected (selected) # if we are in the input field else: # how many matches for the current input do we have in the drop down ? nr_matches = self.__picklist.GetCount() # none if nr_matches == 0: wxBell() # warn user # FIXME: not quite sure here yet #self.__matcher.Learn() # invoke auto-learn ?? # just one elif nr_matches == 1: self.OnSelected (0) # well, that one would be selected then # more than one else: # drop down pick list self.__dropdown_picklist() #-------------------------------------------------------- def __on_down(self): print "on down" # if we are in the drop down list if self.listhasfocus: print "list focus:", self.listhasfocus selected = self.__picklist.GetSelection () # only move down if not at end of list if selected < (self.__picklist.GetCount() - 1): self.__picklist.SetSelection (selected+1) # if we are in the input field else: print "list focus:", self.listhasfocus self.__dropdown_picklist() #-------------------------------------------------------- # event handlers #-------------------------------------------------------- def __on_key_up (self, key): """Is called when a key is released.""" print "__on_key_up" # user moved down if key.GetKeyCode () == WXK_DOWN: self.__on_down() return # user pressed if key.GetKeyCode () == WXK_RETURN: self.__on_enter() return # if we are in the drop down list if self.listhasfocus: selected = self.__picklist.GetSelection () # user moved up if key.GetKeyCode () == WXK_UP: # select previous item if available if selected > 0: self.__picklist.SetSelection (selected-1) # or close list and move focus back to input field else: self.__picklist.SetSelection (0, 0) self.listhasfocus = 0 # FIXME: we need Page UP/DOWN, Pos1/End here # user typed anything else else: # go back to input field self.listhasfocus = 0 key.Skip () else: key.Skip() #-------------------------------------------------------- def __on_text_update (self, event): """Internal handler for EVT_TEXT (called when text has changed)""" # update matches according to current input self.__updateMatches() # we now have either: # - all possible items (with reasonable limits) if input was '*' # - all matching itens # - "*no matching items found*" # also, our picklist is refilled and sorted according to weight # FIXME: we might decide to also not display the list if no matches were found # currently we would display _("*no matching items found*") # OnIdle will use this to decide whether to refetch matches self.last_updated = time.time() # if empty string then kill list dropdown window if len(self.GetValue()) == 0: self.__picklist_win.Dismiss() # otherwise display the list # FIXME: we should _update_ the list window instead of redisplaying it else: # recalculate position pos = self.ClientToScreen ((0,0)) dim = self.GetSize () # FiXME: check for number of entries - shrink list windows self.__picklist_win.Position(pos, (0, dim.height)) # and show it self.__picklist_win.Popup() #-------------------------------------------------------- def __on_idle(self, event): pass #-------------------------------------------------------- def resize (self, event): sz = self.GetSize() self.__picklist.SetSize ((sz.width, sz.height*6)) # as wide as the textctrl, and 6 times the height self.panel.SetSize (self.__picklist.GetSize ()) self.__picklist_win.SetSize (self.panel.GetSize()) #-------------------------------------------------------- #-------------------------------------------------------- """ Sets the list of available options. options consists of a list of lists. Each item of of the form [ID, weighting, string], where ID is the SQL id field, weighting is the user weighting used to order the items on the list, and string is what appears in the listbox. This is intead to recieve directly the output of an appropriate SQL query. This cn be called multiple times (presumably in response to values in other boxes on the screen)""" # def SetValues (self, options): # self.options = options # self.__updateMatches() # if len (self.GetValue ()) > 0: # # check are entered value is on the new list. # if len (self.listvalues) == 0: # self.Clear () # clear text #-------------------------------------------------------- # MAIN #-------------------------------------------------------- if __name__ == '__main__': def clicked (data): print "Selected :%s" % data #-------------------------------------------------------- class TestApp (wxApp): def OnInit (self): items = [ {'ID':1, 'label':"Bloggs", 'weight':1}, {'ID':2, 'label':"Baker", 'weight':1}, {'ID':3, 'label':"Jones", 'weight':2}, {'ID':4, 'label':"Judson", 'weight':1}, {'ID':5, 'label':"Jacobs", 'weight':1}, {'ID':6, 'label':"Judson-Jacobs",'weight':1} ] mp = cMatchProvider_FixedList(items) frame = wxFrame (None, -4, "Test App", size=wxSize(900, 400), style=wxDEFAULT_FRAME_STYLE|wxNO_FULL_REPAINT_ON_RESIZE) ww = cPhraseWheel(frame, clicked, pos = (50, 50), size = (180, 30), aMatchProvider=mp) ww.resize (None) frame.Show (1) return 1 #-------------------------------------------------------- app = TestApp () app.MainLoop () #---------------------------------------------------------- # ideas #---------------------------------------------------------- #- only do lookups after user-dependant time has passed (timer) #- weighted ordering of match items #- typing "*" brings up the whole list #- display possible completion but highlighted for deletion #(- cycle through possible completions) #- pre-fill selection with SELECT ... LIMIT 25 #- weighing by incrementing counter - if rollover, reset all counters to percentage of self.value() #- ageing of item weight #- async threads for match retrieval #- on truncated results return item "..." -> selection forcefully retrieves all matches #- plugin for pattern matching/validation of input #- generators/yield() #---- # darn ! this clever hack won't work since we may have crossed a search location threshold #---- # #self.__prevFragment = "XXXXXXXXXXXXXXXXXX-very-unlikely--------------XXXXXXXXXXXXXXX" # #self.__prevMatches = [] # a list of tuples (ID, listbox name, weight) # # # is the current fragment just a longer version of the previous fragment ? # if string.find(aFragment, self.__prevFragment) == 0: # # we then need to search in the previous matches only # for prevMatch in self.__prevMatches: # if string.find(prevMatch[1], aFragment) == 0: # matches.append(prevMatch) # # remember current matches # self.__prefMatches = matches # # no matches found # if len(matches) == 0: # return [(1,_('*no matching items found*'),1)] # else: # return matches #---- # OnChar() - process a char event # OnIdle # ignore case ? # split input into words and match components against known phrases # -> accumulate weights # if no matches found (or below a certain limit) - revert to case-insensitive matching