Package Gnumed :: Package wxpython :: Module gmPhraseWheel
[frames] | no frames]

Source Code for Module Gnumed.wxpython.gmPhraseWheel

   1  """GNUmed phrasewheel. 
   2   
   3  A class, extending wx.TextCtrl, which has a drop-down pick list, 
   4  automatically filled based on the inital letters typed. Based on the 
   5  interface of Richard Terry's Visual Basic client 
   6   
   7  This is based on seminal work by Ian Haywood <ihaywood@gnu.org> 
   8  """ 
   9  ############################################################################ 
  10  __version__ = "$Revision: 1.136 $" 
  11  __author__  = "K.Hilbert <Karsten.Hilbert@gmx.net>, I.Haywood, S.J.Tan <sjtan@bigpond.com>" 
  12  __license__ = "GPL" 
  13   
  14  # stdlib 
  15  import string, types, time, sys, re as regex, os.path 
  16   
  17   
  18  # 3rd party 
  19  import wx 
  20  import wx.lib.mixins.listctrl as listmixins 
  21  import wx.lib.pubsub 
  22   
  23   
  24  # GNUmed specific 
  25  if __name__ == '__main__': 
  26          sys.path.insert(0, '../../') 
  27  from Gnumed.pycommon import gmTools 
  28   
  29   
  30  import logging 
  31  _log = logging.getLogger('macosx') 
  32   
  33   
  34  color_prw_invalid = 'pink' 
  35  color_prw_valid = None                          # this is used by code outside this module 
  36   
  37  default_phrase_separators = '[;/|]+' 
  38  default_spelling_word_separators = '[\W\d_]+' 
  39   
  40  # those can be used by the <accepted_chars> phrasewheel parameter 
  41  NUMERIC = '0-9' 
  42  ALPHANUMERIC = 'a-zA-Z0-9' 
  43  EMAIL_CHARS = "a-zA-Z0-9\-_@\." 
  44  WEB_CHARS = "a-zA-Z0-9\.\-_/:" 
  45   
  46   
  47  _timers = [] 
  48  #============================================================ 
49 -def shutdown():
50 """It can be useful to call this early from your shutdown code to avoid hangs on Notify().""" 51 global _timers 52 _log.info('shutting down %s pending timers', len(_timers)) 53 for timer in _timers: 54 _log.debug('timer [%s]', timer) 55 timer.Stop() 56 _timers = []
57 #------------------------------------------------------------
58 -class _cPRWTimer(wx.Timer):
59
60 - def __init__(self, *args, **kwargs):
61 wx.Timer.__init__(self, *args, **kwargs) 62 self.callback = lambda x:x 63 global _timers 64 _timers.append(self)
65
66 - def Notify(self):
67 self.callback()
68 #============================================================ 69 # FIXME: merge with gmListWidgets
70 -class cPhraseWheelListCtrl(wx.ListCtrl, listmixins.ListCtrlAutoWidthMixin):
71 - def __init__(self, *args, **kwargs):
72 try: 73 kwargs['style'] = kwargs['style'] | wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.SIMPLE_BORDER 74 except: pass 75 wx.ListCtrl.__init__(self, *args, **kwargs) 76 listmixins.ListCtrlAutoWidthMixin.__init__(self)
77 #--------------------------------------------------------
78 - def SetItems(self, items):
79 self.DeleteAllItems() 80 self.__data = items 81 pos = len(items) + 1 82 for item in items: 83 row_num = self.InsertStringItem(pos, label=item['label'])
84 #--------------------------------------------------------
85 - def GetSelectedItemData(self):
86 sel_idx = self.GetFirstSelected() 87 if sel_idx == -1: 88 return None 89 return self.__data[sel_idx]['data']
90 #--------------------------------------------------------
91 - def get_selected_item_label(self):
92 sel_idx = self.GetFirstSelected() 93 if sel_idx == -1: 94 return None 95 return self.__data[sel_idx]['label']
96 #============================================================ 97 # FIXME: cols in pick list 98 # FIXME: snap_to_basename+set selection 99 # FIXME: learn() -> PWL 100 # FIXME: up-arrow: show recent (in-memory) history 101 #---------------------------------------------------------- 102 # ideas 103 #---------------------------------------------------------- 104 #- display possible completion but highlighted for deletion 105 #(- cycle through possible completions) 106 #- pre-fill selection with SELECT ... LIMIT 25 107 #- async threads for match retrieval instead of timer 108 # - on truncated results return item "..." -> selection forcefully retrieves all matches 109 110 #- generators/yield() 111 #- OnChar() - process a char event 112 113 # split input into words and match components against known phrases 114 115 # make special list window: 116 # - deletion of items 117 # - highlight matched parts 118 # - faster scrolling 119 # - wxEditableListBox ? 120 121 # - if non-learning (i.e. fast select only): autocomplete with match 122 # and move cursor to end of match 123 #----------------------------------------------------------------------------------------------- 124 # darn ! this clever hack won't work since we may have crossed a search location threshold 125 #---- 126 # #self.__prevFragment = "XXXXXXXXXXXXXXXXXX-very-unlikely--------------XXXXXXXXXXXXXXX" 127 # #self.__prevMatches = [] # a list of tuples (ID, listbox name, weight) 128 # 129 # # is the current fragment just a longer version of the previous fragment ? 130 # if string.find(aFragment, self.__prevFragment) == 0: 131 # # we then need to search in the previous matches only 132 # for prevMatch in self.__prevMatches: 133 # if string.find(prevMatch[1], aFragment) == 0: 134 # matches.append(prevMatch) 135 # # remember current matches 136 # self.__prefMatches = matches 137 # # no matches found 138 # if len(matches) == 0: 139 # return [(1,_('*no matching items found*'),1)] 140 # else: 141 # return matches 142 #---- 143 #TODO: 144 # - see spincontrol for list box handling 145 # stop list (list of negatives): "an" -> "animal" but not "and" 146 #----- 147 #> > remember, you should be searching on either weighted data, or in some 148 #> > situations a start string search on indexed data 149 #> 150 #> Can you be a bit more specific on this ? 151 152 #seaching ones own previous text entered would usually be instring but 153 #weighted (ie the phrases you use the most auto filter to the top) 154 155 #Searching a drug database for a drug brand name is usually more 156 #functional if it does a start string search, not an instring search which is 157 #much slower and usually unecesary. There are many other examples but trust 158 #me one needs both 159 #----- 160 161 # FIXME: support selection-only-or-empty
162 -class cPhraseWheel(wx.TextCtrl):
163 """Widget for smart guessing of user fields, after Richard Terry's interface. 164 165 - VB implementation by Richard Terry 166 - Python port by Ian Haywood for GNUmed 167 - enhanced by Karsten Hilbert for GNUmed 168 - enhanced by Ian Haywood for aumed 169 - enhanced by Karsten Hilbert for GNUmed 170 171 @param matcher: a class used to find matches for the current input 172 @type matcher: a L{match provider<Gnumed.pycommon.gmMatchProvider.cMatchProvider>} 173 instance or C{None} 174 175 @param selection_only: whether free-text can be entered without associated data 176 @type selection_only: boolean 177 178 @param capitalisation_mode: how to auto-capitalize input, valid values 179 are found in L{capitalize()<Gnumed.pycommon.gmTools.capitalize>} 180 @type capitalisation_mode: integer 181 182 @param accepted_chars: a regex pattern defining the characters 183 acceptable in the input string, if None no checking is performed 184 @type accepted_chars: None or a string holding a valid regex pattern 185 186 @param final_regex: when the control loses focus the input is 187 checked against this regular expression 188 @type final_regex: a string holding a valid regex pattern 189 190 @param phrase_separators: if not None, input is split into phrases 191 at boundaries defined by this regex and matching/spellchecking 192 is performed on the phrase the cursor is in only 193 @type phrase_separators: None or a string holding a valid regex pattern 194 195 @param navigate_after_selection: whether or not to immediately 196 navigate to the widget next-in-tab-order after selecting an 197 item from the dropdown picklist 198 @type navigate_after_selection: boolean 199 200 @param speller: if not None used to spellcheck the current input 201 and to retrieve suggested replacements/completions 202 @type speller: None or a L{enchant Dict<enchant>} descendant 203 204 @param picklist_delay: this much time of user inactivity must have 205 passed before the input related smarts kick in and the drop 206 down pick list is shown 207 @type picklist_delay: integer (milliseconds) 208 """
209 - def __init__ (self, parent=None, id=-1, *args, **kwargs):
210 211 # behaviour 212 self.matcher = None 213 self.selection_only = False 214 self.selection_only_error_msg = _('You must select a value from the picklist or type an exact match.') 215 self.capitalisation_mode = gmTools.CAPS_NONE 216 self.accepted_chars = None 217 self.final_regex = '.*' 218 self.final_regex_error_msg = _('The content is invalid. It must match the regular expression: [%%s]. <%s>') % self.__class__.__name__ 219 self.phrase_separators = default_phrase_separators 220 self.navigate_after_selection = False 221 self.speller = None 222 self.speller_word_separators = default_spelling_word_separators 223 self.picklist_delay = 150 # milliseconds 224 225 # state tracking 226 self._has_focus = False 227 self.suppress_text_update_smarts = False 228 self.__current_matches = [] 229 self._screenheight = wx.SystemSettings.GetMetric(wx.SYS_SCREEN_Y) 230 self.input2match = '' 231 self.left_part = '' 232 self.right_part = '' 233 self.__static_tt = None 234 self.__static_tt_extra = None 235 self.__data = None 236 237 self._on_selection_callbacks = [] 238 self._on_lose_focus_callbacks = [] 239 self._on_set_focus_callbacks = [] 240 self._on_modified_callbacks = [] 241 242 try: 243 kwargs['style'] = kwargs['style'] | wx.TE_PROCESS_TAB 244 except KeyError: 245 kwargs['style'] = wx.TE_PROCESS_TAB 246 wx.TextCtrl.__init__(self, parent, id, **kwargs) 247 248 self.__non_edit_font = self.GetFont() 249 self.__color_valid = self.GetBackgroundColour() 250 global color_prw_valid 251 if color_prw_valid is None: 252 color_prw_valid = wx.SystemSettings_GetColour(wx.SYS_COLOUR_WINDOW) 253 254 self.__init_dropdown(parent = parent) 255 self.__register_events() 256 self.__init_timer()
257 #-------------------------------------------------------- 258 # external API 259 #--------------------------------------------------------
260 - def add_callback_on_selection(self, callback=None):
261 """ 262 Add a callback for invocation when a picklist item is selected. 263 264 The callback will be invoked whenever an item is selected 265 from the picklist. The associated data is passed in as 266 a single parameter. Callbacks must be able to cope with 267 None as the data parameter as that is sent whenever the 268 user changes a previously selected value. 269 """ 270 if not callable(callback): 271 raise ValueError('[add_callback_on_selection]: ignoring callback [%s], it is not callable' % callback) 272 273 self._on_selection_callbacks.append(callback)
274 #---------------------------------------------------------
275 - def add_callback_on_set_focus(self, callback=None):
276 """ 277 Add a callback for invocation when getting focus. 278 """ 279 if not callable(callback): 280 raise ValueError('[add_callback_on_set_focus]: ignoring callback [%s] - not callable' % callback) 281 282 self._on_set_focus_callbacks.append(callback)
283 #---------------------------------------------------------
284 - def add_callback_on_lose_focus(self, callback=None):
285 """ 286 Add a callback for invocation when losing focus. 287 """ 288 if not callable(callback): 289 raise ValueError('[add_callback_on_lose_focus]: ignoring callback [%s] - not callable' % callback) 290 291 self._on_lose_focus_callbacks.append(callback)
292 #---------------------------------------------------------
293 - def add_callback_on_modified(self, callback=None):
294 """ 295 Add a callback for invocation when the content is modified. 296 """ 297 if not callable(callback): 298 raise ValueError('[add_callback_on_modified]: ignoring callback [%s] - not callable' % callback) 299 300 self._on_modified_callbacks.append(callback)
301 #---------------------------------------------------------
302 - def SetData(self, data=None):
303 """ 304 Set the data and thereby set the value, too. 305 306 If you call SetData() you better be prepared 307 doing a scan of the entire potential match space. 308 309 The whole thing will only work if data is found 310 in the match space anyways. 311 """ 312 if self.matcher is None: 313 matched, matches = (False, []) 314 else: 315 matched, matches = self.matcher.getMatches('*') 316 317 if self.selection_only: 318 if not matched or (len(matches) == 0): 319 return False 320 321 for match in matches: 322 if match['data'] == data: 323 self.display_as_valid(valid = True) 324 self.suppress_text_update_smarts = True 325 wx.TextCtrl.SetValue(self, match['label']) 326 self.data = data 327 return True 328 329 # no match found ... 330 if self.selection_only: 331 return False 332 333 self.data = data 334 self.display_as_valid(valid = True) 335 return True
336 #---------------------------------------------------------
337 - def GetData(self, can_create=False, as_instance=False):
338 """Retrieve the data associated with the displayed string. 339 340 _create_data() must set self.data if possible (successful) 341 """ 342 if self.data is None: 343 if can_create: 344 self._create_data() 345 346 if self.data is not None: 347 if as_instance: 348 return self._data2instance() 349 350 return self.data
351 #---------------------------------------------------------
352 - def SetText(self, value=u'', data=None, suppress_smarts=False):
353 354 self.suppress_text_update_smarts = suppress_smarts 355 356 if data is not None: 357 self.suppress_text_update_smarts = True 358 self.data = data 359 if value is None: 360 value = u'' 361 wx.TextCtrl.SetValue(self, value) 362 self.display_as_valid(valid = True) 363 364 # if data already available 365 if self.data is not None: 366 return True 367 368 if value == u'' and not self.selection_only: 369 return True 370 371 # or try to find data from matches 372 if self.matcher is None: 373 stat, matches = (False, []) 374 else: 375 stat, matches = self.matcher.getMatches(aFragment = value) 376 377 for match in matches: 378 if match['label'] == value: 379 self.data = match['data'] 380 return True 381 382 # not found 383 if self.selection_only: 384 self.display_as_valid(valid = False) 385 return False 386 387 return True
388 #--------------------------------------------------------
389 - def set_context(self, context=None, val=None):
390 if self.matcher is not None: 391 self.matcher.set_context(context=context, val=val)
392 #---------------------------------------------------------
393 - def unset_context(self, context=None):
394 if self.matcher is not None: 395 self.matcher.unset_context(context=context)
396 #--------------------------------------------------------
398 # FIXME: use Debian's wgerman-medical as "personal" wordlist if available 399 try: 400 import enchant 401 except ImportError: 402 self.speller = None 403 return False 404 try: 405 self.speller = enchant.DictWithPWL(None, os.path.expanduser(os.path.join('~', '.gnumed', 'spellcheck', 'wordlist.pwl'))) 406 except enchant.DictNotFoundError: 407 self.speller = None 408 return False 409 return True
410 #--------------------------------------------------------
411 - def display_as_valid(self, valid=None):
412 if valid is True: 413 self.SetBackgroundColour(self.__color_valid) 414 elif valid is False: 415 self.SetBackgroundColour(color_prw_invalid) 416 else: 417 raise ValueError(u'<valid> must be True or False') 418 self.Refresh()
419 #-------------------------------------------------------- 420 # internal API 421 #-------------------------------------------------------- 422 # picklist handling 423 #--------------------------------------------------------
424 - def __init_dropdown(self, parent = None):
425 szr_dropdown = None 426 try: 427 #raise NotImplementedError # for testing 428 self.__dropdown_needs_relative_position = False 429 self.__picklist_dropdown = wx.PopupWindow(parent) 430 list_parent = self.__picklist_dropdown 431 self.__use_fake_popup = False 432 except NotImplementedError: 433 self.__use_fake_popup = True 434 435 # on MacOSX wx.PopupWindow is not implemented, so emulate it 436 add_picklist_to_sizer = True 437 szr_dropdown = wx.BoxSizer(wx.VERTICAL) 438 439 # using wx.MiniFrame 440 self.__dropdown_needs_relative_position = False 441 self.__picklist_dropdown = wx.MiniFrame ( 442 parent = parent, 443 id = -1, 444 style = wx.SIMPLE_BORDER | wx.FRAME_FLOAT_ON_PARENT | wx.FRAME_NO_TASKBAR | wx.POPUP_WINDOW 445 ) 446 scroll_win = wx.ScrolledWindow(parent = self.__picklist_dropdown, style = wx.NO_BORDER) 447 scroll_win.SetSizer(szr_dropdown) 448 list_parent = scroll_win 449 450 # using wx.Window 451 #self.__dropdown_needs_relative_position = True 452 #self.__picklist_dropdown = wx.ScrolledWindow(parent=parent, style = wx.RAISED_BORDER) 453 #self.__picklist_dropdown.SetSizer(szr_dropdown) 454 #list_parent = self.__picklist_dropdown 455 456 self.mac_log('dropdown parent: %s' % self.__picklist_dropdown.GetParent()) 457 458 # FIXME: support optional headers 459 # if kwargs['show_list_headers']: 460 # flags = 0 461 # else: 462 # flags = wx.LC_NO_HEADER 463 self._picklist = cPhraseWheelListCtrl ( 464 list_parent, 465 style = wx.LC_NO_HEADER 466 ) 467 self._picklist.InsertColumn(0, '') 468 469 if szr_dropdown is not None: 470 szr_dropdown.Add(self._picklist, 1, wx.EXPAND) 471 472 self.__picklist_dropdown.Hide()
473 #--------------------------------------------------------
474 - def _show_picklist(self):
475 """Display the pick list.""" 476 477 border_width = 4 478 extra_height = 25 479 480 self.__picklist_dropdown.Hide() 481 482 # this helps if the current input was already selected from the 483 # list but still is the substring of another pick list item 484 if self.data is not None: 485 return 486 487 if not self._has_focus: 488 return 489 490 if len(self.__current_matches) == 0: 491 return 492 493 # if only one match and text == match 494 if len(self.__current_matches) == 1: 495 if self.__current_matches[0]['label'] == self.input2match: 496 self.data = self.__current_matches[0]['data'] 497 return 498 499 # recalculate size 500 rows = len(self.__current_matches) 501 if rows < 2: # 2 rows minimum 502 rows = 2 503 if rows > 20: # 20 rows maximum 504 rows = 20 505 self.mac_log('dropdown needs rows: %s' % rows) 506 dropdown_size = self.__picklist_dropdown.GetSize() 507 pw_size = self.GetSize() 508 dropdown_size.SetWidth(pw_size.width) 509 dropdown_size.SetHeight ( 510 (pw_size.height * rows) 511 + border_width 512 + extra_height 513 ) 514 515 # recalculate position 516 (pw_x_abs, pw_y_abs) = self.ClientToScreenXY(0,0) 517 self.mac_log('phrasewheel position (on screen): x:%s-%s, y:%s-%s' % (pw_x_abs, (pw_x_abs+pw_size.width), pw_y_abs, (pw_y_abs+pw_size.height))) 518 dropdown_new_x = pw_x_abs 519 dropdown_new_y = pw_y_abs + pw_size.height 520 self.mac_log('desired dropdown position (on screen): x:%s-%s, y:%s-%s' % (dropdown_new_x, (dropdown_new_x+dropdown_size.width), dropdown_new_y, (dropdown_new_y+dropdown_size.height))) 521 self.mac_log('desired dropdown size: %s' % dropdown_size) 522 523 # reaches beyond screen ? 524 if (dropdown_new_y + dropdown_size.height) > self._screenheight: 525 self.mac_log('dropdown extends offscreen (screen max y: %s)' % self._screenheight) 526 max_height = self._screenheight - dropdown_new_y - 4 527 self.mac_log('max dropdown height would be: %s' % max_height) 528 if max_height > ((pw_size.height * 2) + 4): 529 dropdown_size.SetHeight(max_height) 530 self.mac_log('possible dropdown position (on screen): x:%s-%s, y:%s-%s' % (dropdown_new_x, (dropdown_new_x+dropdown_size.width), dropdown_new_y, (dropdown_new_y+dropdown_size.height))) 531 self.mac_log('possible dropdown size: %s' % dropdown_size) 532 533 # now set dimensions 534 self.__picklist_dropdown.SetSize(dropdown_size) 535 self._picklist.SetSize(self.__picklist_dropdown.GetClientSize()) 536 self.mac_log('pick list size set to: %s' % self.__picklist_dropdown.GetSize()) 537 if self.__dropdown_needs_relative_position: 538 dropdown_new_x, dropdown_new_y = self.__picklist_dropdown.GetParent().ScreenToClientXY(dropdown_new_x, dropdown_new_y) 539 self.__picklist_dropdown.MoveXY(dropdown_new_x, dropdown_new_y) 540 541 # select first value 542 self._picklist.Select(0) 543 544 # and show it 545 self.__picklist_dropdown.Show(True) 546 547 dd_tl = self.__picklist_dropdown.ClientToScreenXY(0,0) 548 dd_size = self.__picklist_dropdown.GetSize() 549 dd_br = self.__picklist_dropdown.ClientToScreenXY(dd_size.width, dd_size.height) 550 self.mac_log('dropdown placement now (on screen): x:%s-%s, y:%s-%s' % (dd_tl[0], dd_br[0], dd_tl[1], dd_br[1]))
551 #--------------------------------------------------------
552 - def _hide_picklist(self):
553 """Hide the pick list.""" 554 self.__picklist_dropdown.Hide() # dismiss the dropdown list window
555 #--------------------------------------------------------
556 - def __select_picklist_row(self, new_row_idx=None, old_row_idx=None):
557 if old_row_idx is not None: 558 pass # FIXME: do we need unselect here ? Select() should do it for us 559 self._picklist.Select(new_row_idx) 560 self._picklist.EnsureVisible(new_row_idx)
561 #---------------------------------------------------------
562 - def __update_matches_in_picklist(self, val=None):
563 """Get the matches for the currently typed input fragment.""" 564 565 self.input2match = val 566 if self.input2match is None: 567 if self.__phrase_separators is None: 568 self.input2match = self.GetValue().strip() 569 else: 570 # get current(ly relevant part of) input 571 entire_input = self.GetValue() 572 cursor_pos = self.GetInsertionPoint() 573 left_of_cursor = entire_input[:cursor_pos] 574 right_of_cursor = entire_input[cursor_pos:] 575 left_boundary = self.__phrase_separators.search(left_of_cursor) 576 if left_boundary is not None: 577 phrase_start = left_boundary.end() 578 else: 579 phrase_start = 0 580 self.left_part = entire_input[:phrase_start] 581 # find next phrase separator after cursor position 582 right_boundary = self.__phrase_separators.search(right_of_cursor) 583 if right_boundary is not None: 584 phrase_end = cursor_pos + (right_boundary.start() - 1) 585 else: 586 phrase_end = len(entire_input) - 1 587 self.right_part = entire_input[phrase_end+1:] 588 self.input2match = entire_input[phrase_start:phrase_end+1] 589 590 # get all currently matching items 591 if self.matcher is not None: 592 matched, self.__current_matches = self.matcher.getMatches(self.input2match) 593 self._picklist.SetItems(self.__current_matches) 594 595 # no matches found: might simply be due to a typo, so spellcheck 596 if len(self.__current_matches) == 0: 597 if self.speller is not None: 598 # filter out the last word 599 word = regex.split(self.__speller_word_separators, self.input2match)[-1] 600 if word.strip() != u'': 601 success = False 602 try: 603 success = self.speller.check(word) 604 except: 605 _log.exception('had to disable enchant spell checker') 606 self.speller = None 607 if success: 608 spells = self.speller.suggest(word) 609 truncated_input2match = self.input2match[:self.input2match.rindex(word)] 610 for spell in spells: 611 self.__current_matches.append({'label': truncated_input2match + spell, 'data': None}) 612 self._picklist.SetItems(self.__current_matches)
613 #--------------------------------------------------------
615 return self._picklist.GetItemText(self._picklist.GetFirstSelected())
616 #-------------------------------------------------------- 617 # internal helpers: GUI 618 #--------------------------------------------------------
619 - def _on_enter(self):
620 """Called when the user pressed <ENTER>.""" 621 if self.__picklist_dropdown.IsShown(): 622 self._on_list_item_selected() 623 else: 624 # FIXME: check for errors before navigation 625 self.Navigate()
626 #--------------------------------------------------------
627 - def __on_cursor_down(self):
628 629 if self.__picklist_dropdown.IsShown(): 630 selected = self._picklist.GetFirstSelected() 631 if selected < (len(self.__current_matches) - 1): 632 self.__select_picklist_row(selected+1, selected) 633 634 # if we don't yet have a pick list: open new pick list 635 # (this can happen when we TAB into a field pre-filled 636 # with the top-weighted contextual data but want to 637 # select another contextual item) 638 else: 639 self.__timer.Stop() 640 if self.GetValue().strip() == u'': 641 self.__update_matches_in_picklist(val='*') 642 else: 643 self.__update_matches_in_picklist() 644 self._show_picklist()
645 #--------------------------------------------------------
646 - def __on_cursor_up(self):
647 if self.__picklist_dropdown.IsShown(): 648 selected = self._picklist.GetFirstSelected() 649 if selected > 0: 650 self.__select_picklist_row(selected-1, selected) 651 else: 652 # FIXME: input history ? 653 pass
654 #--------------------------------------------------------
655 - def __on_tab(self):
656 """Under certain circumstances takes special action on TAB. 657 658 returns: 659 True: TAB was handled 660 False: TAB was not handled 661 """ 662 if not self.__picklist_dropdown.IsShown(): 663 return False 664 665 if len(self.__current_matches) != 1: 666 return False 667 668 if not self.selection_only: 669 return False 670 671 self.__select_picklist_row(new_row_idx=0) 672 self._on_list_item_selected() 673 674 return True
675 #-------------------------------------------------------- 676 # internal helpers: logic 677 #--------------------------------------------------------
678 - def _create_data(self):
679 raise NotImplementedError('[%s]: cannot create data object' % self.__class__.__name__)
680 #--------------------------------------------------------
681 - def _get_data_tooltip(self):
682 # by default do not support dynamic tooltip parts 683 return None
684 #--------------------------------------------------------
685 - def __reset_tooltip(self):
686 """Calculate dynamic tooltip part based on data item. 687 688 - called via ._set_data() each time property .data (-> .__data) is set 689 - hence also called the first time data is set 690 - the static tooltip can be set any number of ways before that 691 - only when data is first set does the dynamic part become relevant 692 - hence it is sufficient to remember the static part when .data is 693 set for the first time 694 """ 695 if self.__static_tt is None: 696 if self.ToolTip is None: 697 self.__static_tt = u'' 698 else: 699 self.__static_tt = self.ToolTip.Tip 700 701 dynamic_part = self._get_data_tooltip() 702 if dynamic_part is None: 703 return 704 705 static_part = self.__static_tt 706 if (self.__static_tt_extra) is not None and (self.__static_tt_extra.strip() != u''): 707 static_part = u'%s\n\n%s' % ( 708 static_part, 709 self.__static_tt_extra 710 ) 711 712 if static_part == u'': 713 tt = dynamic_part 714 else: 715 if dynamic_part.strip() == u'': 716 tt = static_part 717 else: 718 tt = u'%s\n\n%s\n\n%s' % ( 719 dynamic_part, 720 gmTools.u_box_horiz_single * 32, 721 static_part 722 ) 723 self.SetToolTipString(tt)
724 #--------------------------------------------------------
725 - def __char_is_allowed(self, char=None):
726 # if undefined accept all chars 727 if self.accepted_chars is None: 728 return True 729 return (self.__accepted_chars.match(char) is not None)
730 #-------------------------------------------------------- 731 # properties 732 #--------------------------------------------------------
733 - def _get_data(self):
734 return self.__data
735
736 - def _set_data(self, data):
737 self.__data = data 738 self.__reset_tooltip()
739 740 data = property(_get_data, _set_data) 741 #--------------------------------------------------------
742 - def _set_accepted_chars(self, accepted_chars=None):
743 if accepted_chars is None: 744 self.__accepted_chars = None 745 else: 746 self.__accepted_chars = regex.compile(accepted_chars)
747
748 - def _get_accepted_chars(self):
749 if self.__accepted_chars is None: 750 return None 751 return self.__accepted_chars.pattern
752 753 accepted_chars = property(_get_accepted_chars, _set_accepted_chars) 754 #--------------------------------------------------------
755 - def _set_final_regex(self, final_regex='.*'):
756 self.__final_regex = regex.compile(final_regex, flags = regex.LOCALE | regex.UNICODE)
757
758 - def _get_final_regex(self):
759 return self.__final_regex.pattern
760 761 final_regex = property(_get_final_regex, _set_final_regex) 762 #--------------------------------------------------------
763 - def _set_final_regex_error_msg(self, msg):
764 self.__final_regex_error_msg = msg % self.final_regex
765
766 - def _get_final_regex_error_msg(self):
767 return self.__final_regex_error_msg
768 769 final_regex_error_msg = property(_get_final_regex_error_msg, _set_final_regex_error_msg) 770 #--------------------------------------------------------
771 - def _set_phrase_separators(self, phrase_separators):
772 if phrase_separators is None: 773 self.__phrase_separators = None 774 else: 775 self.__phrase_separators = regex.compile(phrase_separators, flags = regex.LOCALE | regex.UNICODE)
776
777 - def _get_phrase_separators(self):
778 if self.__phrase_separators is None: 779 return None 780 return self.__phrase_separators.pattern
781 782 phrase_separators = property(_get_phrase_separators, _set_phrase_separators) 783 #--------------------------------------------------------
784 - def _set_speller_word_separators(self, word_separators):
785 if word_separators is None: 786 self.__speller_word_separators = regex.compile('[\W\d_]+', flags = regex.LOCALE | regex.UNICODE) 787 else: 788 self.__speller_word_separators = regex.compile(word_separators, flags = regex.LOCALE | regex.UNICODE)
789
791 return self.__speller_word_separators.pattern
792 793 speller_word_separators = property(_get_speller_word_separators, _set_speller_word_separators) 794 #--------------------------------------------------------
795 - def _get_static_tt_extra(self):
796 return self.__static_tt_extra
797
798 - def _set_static_tt_extra(self, tt):
799 self.__static_tt_extra = tt
800 801 static_tooltip_extra = property(_get_static_tt_extra, _set_static_tt_extra) 802 #-------------------------------------------------------- 803 # timer code 804 #--------------------------------------------------------
805 - def __init_timer(self):
806 self.__timer = _cPRWTimer() 807 self.__timer.callback = self._on_timer_fired 808 # initially stopped 809 self.__timer.Stop()
810 #--------------------------------------------------------
811 - def _on_timer_fired(self):
812 """Callback for delayed match retrieval timer. 813 814 if we end up here: 815 - delay has passed without user input 816 - the value in the input field has not changed since the timer started 817 """ 818 # update matches according to current input 819 self.__update_matches_in_picklist() 820 821 # we now have either: 822 # - all possible items (within reasonable limits) if input was '*' 823 # - all matching items 824 # - an empty match list if no matches were found 825 # also, our picklist is refilled and sorted according to weight 826 827 wx.CallAfter(self._show_picklist)
828 #-------------------------------------------------------- 829 # event handling 830 #--------------------------------------------------------
831 - def __register_events(self):
832 wx.EVT_TEXT(self, self.GetId(), self._on_text_update) 833 wx.EVT_KEY_DOWN (self, self._on_key_down) 834 wx.EVT_SET_FOCUS(self, self._on_set_focus) 835 wx.EVT_KILL_FOCUS(self, self._on_lose_focus) 836 self._picklist.Bind(wx.EVT_LEFT_DCLICK, self._on_list_item_selected)
837 #--------------------------------------------------------
838 - def _on_list_item_selected(self, *args, **kwargs):
839 """Gets called when user selected a list item.""" 840 841 self._hide_picklist() 842 self.display_as_valid(valid = True) 843 844 data = self._picklist.GetSelectedItemData() # just so that _picklist_selection2display_string can use it 845 if data is None: 846 return 847 848 self.data = data 849 850 # update our display 851 self.suppress_text_update_smarts = True 852 if self.__phrase_separators is not None: 853 wx.TextCtrl.SetValue(self, u'%s%s%s' % (self.left_part, self._picklist_selection2display_string(), self.right_part)) 854 else: 855 wx.TextCtrl.SetValue(self, self._picklist_selection2display_string()) 856 857 self.data = self._picklist.GetSelectedItemData() 858 self.MarkDirty() 859 860 # and tell the listeners about the user's selection 861 for callback in self._on_selection_callbacks: 862 callback(self.data) 863 864 if self.navigate_after_selection: 865 self.Navigate() 866 else: 867 self.SetInsertionPoint(self.GetLastPosition()) 868 869 return
870 #--------------------------------------------------------
871 - def _on_key_down(self, event):
872 """Is called when a key is pressed.""" 873 874 keycode = event.GetKeyCode() 875 876 if keycode == wx.WXK_DOWN: 877 self.__on_cursor_down() 878 return 879 880 if keycode == wx.WXK_UP: 881 self.__on_cursor_up() 882 return 883 884 if keycode == wx.WXK_RETURN: 885 self._on_enter() 886 return 887 888 if keycode == wx.WXK_TAB: 889 if event.ShiftDown(): 890 self.Navigate(flags = wx.NavigationKeyEvent.IsBackward) 891 return 892 self.__on_tab() 893 self.Navigate(flags = wx.NavigationKeyEvent.IsForward) 894 return 895 896 # FIXME: need PAGE UP/DOWN//POS1/END here to move in picklist 897 if keycode in [wx.WXK_SHIFT, wx.WXK_BACK, wx.WXK_DELETE, wx.WXK_LEFT, wx.WXK_RIGHT]: 898 pass 899 900 # need to handle all non-character key presses *before* this check 901 elif not self.__char_is_allowed(char = unichr(event.GetUnicodeKey())): 902 # FIXME: configure ? 903 wx.Bell() 904 # FIXME: display error message ? Richard doesn't ... 905 return 906 907 event.Skip() 908 return
909 #--------------------------------------------------------
910 - def _on_text_update (self, event):
911 """Internal handler for wx.EVT_TEXT. 912 913 Called when text was changed by user or SetValue(). 914 """ 915 if self.suppress_text_update_smarts: 916 self.suppress_text_update_smarts = False 917 return 918 919 self.data = None 920 self.__current_matches = [] 921 922 # if empty string then hide list dropdown window 923 # we also don't need a timer event then 924 val = self.GetValue().strip() 925 ins_point = self.GetInsertionPoint() 926 if val == u'': 927 self._hide_picklist() 928 self.__timer.Stop() 929 else: 930 new_val = gmTools.capitalize(text = val, mode = self.capitalisation_mode) 931 if new_val != val: 932 self.suppress_text_update_smarts = True 933 wx.TextCtrl.SetValue(self, new_val) 934 if ins_point > len(new_val): 935 self.SetInsertionPointEnd() 936 else: 937 self.SetInsertionPoint(ins_point) 938 # FIXME: SetSelection() ? 939 940 # start timer for delayed match retrieval 941 self.__timer.Start(oneShot = True, milliseconds = self.picklist_delay) 942 943 # notify interested parties 944 for callback in self._on_modified_callbacks: 945 callback() 946 947 return
948 #--------------------------------------------------------
949 - def _on_set_focus(self, event):
950 951 self._has_focus = True 952 event.Skip() 953 954 self.__non_edit_font = self.GetFont() 955 edit_font = self.GetFont() 956 edit_font.SetPointSize(pointSize = self.__non_edit_font.GetPointSize() + 1) 957 self.SetFont(edit_font) 958 self.Refresh() 959 960 # notify interested parties 961 for callback in self._on_set_focus_callbacks: 962 callback() 963 964 self.__timer.Start(oneShot = True, milliseconds = self.picklist_delay) 965 return True
966 #--------------------------------------------------------
967 - def _on_lose_focus(self, event):
968 """Do stuff when leaving the control. 969 970 The user has had her say, so don't second guess 971 intentions but do report error conditions. 972 """ 973 self._has_focus = False 974 975 # don't need timer and pick list anymore 976 self.__timer.Stop() 977 self._hide_picklist() 978 979 # unset selection 980 self.SetSelection(1,1) 981 982 self.SetFont(self.__non_edit_font) 983 self.Refresh() 984 985 is_valid = True 986 987 # the user may have typed a phrase that is an exact match, 988 # however, just typing it won't associate data from the 989 # picklist, so do that now 990 if self.data is None: 991 val = self.GetValue().strip() 992 if val != u'': 993 self.__update_matches_in_picklist() 994 for match in self.__current_matches: 995 if match['label'] == val: 996 self.data = match['data'] 997 self.MarkDirty() 998 break 999 1000 # no exact match found 1001 if self.data is None: 1002 if self.selection_only: 1003 wx.lib.pubsub.Publisher().sendMessage ( 1004 topic = 'statustext', 1005 data = {'msg': self.selection_only_error_msg} 1006 ) 1007 is_valid = False 1008 1009 # check value against final_regex if any given 1010 if self.__final_regex.match(self.GetValue().strip()) is None: 1011 wx.lib.pubsub.Publisher().sendMessage ( 1012 topic = 'statustext', 1013 data = {'msg': self.final_regex_error_msg} 1014 ) 1015 is_valid = False 1016 1017 self.display_as_valid(valid = is_valid) 1018 1019 # notify interested parties 1020 for callback in self._on_lose_focus_callbacks: 1021 callback() 1022 1023 event.Skip() 1024 return True
1025 #----------------------------------------------------
1026 - def mac_log(self, msg):
1027 if self.__use_fake_popup: 1028 _log.debug(msg)
1029 #-------------------------------------------------------- 1030 # MAIN 1031 #-------------------------------------------------------- 1032 if __name__ == '__main__': 1033 1034 if len(sys.argv) < 2: 1035 sys.exit() 1036 1037 if sys.argv[1] != u'test': 1038 sys.exit() 1039 1040 from Gnumed.pycommon import gmI18N 1041 gmI18N.activate_locale() 1042 gmI18N.install_domain(domain='gnumed') 1043 1044 from Gnumed.pycommon import gmPG2, gmMatchProvider 1045 1046 prw = None 1047 #--------------------------------------------------------
1048 - def display_values_set_focus(*args, **kwargs):
1049 print "got focus:" 1050 print "value:", prw.GetValue() 1051 print "data :", prw.GetData() 1052 return True
1053 #--------------------------------------------------------
1054 - def display_values_lose_focus(*args, **kwargs):
1055 print "lost focus:" 1056 print "value:", prw.GetValue() 1057 print "data :", prw.GetData() 1058 return True
1059 #--------------------------------------------------------
1060 - def display_values_modified(*args, **kwargs):
1061 print "modified:" 1062 print "value:", prw.GetValue() 1063 print "data :", prw.GetData() 1064 return True
1065 #--------------------------------------------------------
1066 - def display_values_selected(*args, **kwargs):
1067 print "selected:" 1068 print "value:", prw.GetValue() 1069 print "data :", prw.GetData() 1070 return True
1071 #--------------------------------------------------------
1072 - def test_prw_fixed_list():
1073 app = wx.PyWidgetTester(size = (200, 50)) 1074 1075 items = [ {'data':1, 'label':"Bloggs"}, 1076 {'data':2, 'label':"Baker"}, 1077 {'data':3, 'label':"Jones"}, 1078 {'data':4, 'label':"Judson"}, 1079 {'data':5, 'label':"Jacobs"}, 1080 {'data':6, 'label':"Judson-Jacobs"} 1081 ] 1082 1083 mp = gmMatchProvider.cMatchProvider_FixedList(items) 1084 # do NOT treat "-" as a word separator here as there are names like "asa-sismussen" 1085 mp.word_separators = '[ \t=+&:@]+' 1086 global prw 1087 prw = cPhraseWheel(parent = app.frame, id = -1) 1088 prw.matcher = mp 1089 prw.capitalisation_mode = gmTools.CAPS_NAMES 1090 prw.add_callback_on_set_focus(callback=display_values_set_focus) 1091 prw.add_callback_on_modified(callback=display_values_modified) 1092 prw.add_callback_on_lose_focus(callback=display_values_lose_focus) 1093 prw.add_callback_on_selection(callback=display_values_selected) 1094 1095 app.frame.Show(True) 1096 app.MainLoop() 1097 1098 return True
1099 #--------------------------------------------------------
1100 - def test_prw_sql2():
1101 print "Do you want to test the database connected phrase wheel ?" 1102 yes_no = raw_input('y/n: ') 1103 if yes_no != 'y': 1104 return True 1105 1106 gmPG2.get_connection() 1107 # FIXME: add callbacks 1108 # FIXME: add context 1109 query = u'select code, name from dem.country where _(name) %(fragment_condition)s' 1110 mp = gmMatchProvider.cMatchProvider_SQL2(queries = [query]) 1111 app = wx.PyWidgetTester(size = (200, 50)) 1112 global prw 1113 prw = cPhraseWheel(parent = app.frame, id = -1) 1114 prw.matcher = mp 1115 1116 app.frame.Show(True) 1117 app.MainLoop() 1118 1119 return True
1120 #--------------------------------------------------------
1121 - def test_prw_patients():
1122 gmPG2.get_connection() 1123 query = u"select pk_identity, firstnames || ' ' || lastnames || ' ' || dob::text as pat_name from dem.v_basic_person where firstnames || lastnames %(fragment_condition)s" 1124 1125 mp = gmMatchProvider.cMatchProvider_SQL2(queries = [query]) 1126 app = wx.PyWidgetTester(size = (200, 50)) 1127 global prw 1128 prw = cPhraseWheel(parent = app.frame, id = -1) 1129 prw.matcher = mp 1130 1131 app.frame.Show(True) 1132 app.MainLoop() 1133 1134 return True
1135 #--------------------------------------------------------
1136 - def test_spell_checking_prw():
1137 app = wx.PyWidgetTester(size = (200, 50)) 1138 1139 global prw 1140 prw = cPhraseWheel(parent = app.frame, id = -1) 1141 1142 prw.add_callback_on_set_focus(callback=display_values_set_focus) 1143 prw.add_callback_on_modified(callback=display_values_modified) 1144 prw.add_callback_on_lose_focus(callback=display_values_lose_focus) 1145 prw.add_callback_on_selection(callback=display_values_selected) 1146 1147 prw.enable_default_spellchecker() 1148 1149 app.frame.Show(True) 1150 app.MainLoop() 1151 1152 return True
1153 #-------------------------------------------------------- 1154 # test_prw_fixed_list() 1155 # test_prw_sql2() 1156 test_spell_checking_prw() 1157 # test_prw_patients() 1158 1159 #================================================== 1160