| Home | Trees | Indices | Help |
|
|---|
|
|
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 __author__ = "K.Hilbert <Karsten.Hilbert@gmx.net>, I.Haywood, S.J.Tan <sjtan@bigpond.com>"
11 __license__ = "GPL"
12
13 # stdlib
14 import string, types, time, sys, re as regex, os.path
15
16
17 # 3rd party
18 import wx
19 import wx.lib.mixins.listctrl as listmixins
20
21
22 # GNUmed specific
23 if __name__ == '__main__':
24 sys.path.insert(0, '../../')
25 from Gnumed.pycommon import gmTools
26 from Gnumed.pycommon import gmDispatcher
27
28
29 import logging
30 _log = logging.getLogger('macosx')
31
32
33 color_prw_invalid = 'pink'
34 color_prw_partially_invalid = 'yellow'
35 color_prw_valid = None # this is used by code outside this module
36
37 #default_phrase_separators = r'[;/|]+'
38 default_phrase_separators = r';+'
39 default_spelling_word_separators = r'[\W\d_]+'
40
41 # those can be used by the <accepted_chars> phrasewheel parameter
42 NUMERIC = '0-9'
43 ALPHANUMERIC = 'a-zA-Z0-9'
44 EMAIL_CHARS = "a-zA-Z0-9\-_@\."
45 WEB_CHARS = "a-zA-Z0-9\.\-_/:"
46
47
48 _timers = []
49 #============================================================
51 """It can be useful to call this early from your shutdown code to avoid hangs on Notify()."""
52 global _timers
53 _log.info('shutting down %s pending timers', len(_timers))
54 for timer in _timers:
55 _log.debug('timer [%s]', timer)
56 timer.Stop()
57 _timers = []
58 #------------------------------------------------------------
60
62 wx.Timer.__init__(self, *args, **kwargs)
63 self.callback = lambda x:x
64 global _timers
65 _timers.append(self)
66
69 #============================================================
70 # FIXME: merge with gmListWidgets
72
74 try:
75 kwargs['style'] = kwargs['style'] | wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.SIMPLE_BORDER
76 except: pass
77 wx.ListCtrl.__init__(self, *args, **kwargs)
78 listmixins.ListCtrlAutoWidthMixin.__init__(self)
79 #--------------------------------------------------------
81 self.DeleteAllItems()
82 self.__data = items
83 pos = len(items) + 1
84 for item in items:
85 row_num = self.InsertStringItem(pos, label=item['list_label'])
86 #--------------------------------------------------------
88 sel_idx = self.GetFirstSelected()
89 if sel_idx == -1:
90 return None
91 return self.__data[sel_idx]['data']
92 #--------------------------------------------------------
94 sel_idx = self.GetFirstSelected()
95 if sel_idx == -1:
96 return None
97 return self.__data[sel_idx]
98 #--------------------------------------------------------
104 #============================================================
105 # base class for both single- and multi-phrase phrase wheels
106 #------------------------------------------------------------
108 """Widget for smart guessing of user fields, after Richard Terry's interface.
109
110 - VB implementation by Richard Terry
111 - Python port by Ian Haywood for GNUmed
112 - enhanced by Karsten Hilbert for GNUmed
113 - enhanced by Ian Haywood for aumed
114 - enhanced by Karsten Hilbert for GNUmed
115
116 @param matcher: a class used to find matches for the current input
117 @type matcher: a L{match provider<Gnumed.pycommon.gmMatchProvider.cMatchProvider>}
118 instance or C{None}
119
120 @param selection_only: whether free-text can be entered without associated data
121 @type selection_only: boolean
122
123 @param capitalisation_mode: how to auto-capitalize input, valid values
124 are found in L{capitalize()<Gnumed.pycommon.gmTools.capitalize>}
125 @type capitalisation_mode: integer
126
127 @param accepted_chars: a regex pattern defining the characters
128 acceptable in the input string, if None no checking is performed
129 @type accepted_chars: None or a string holding a valid regex pattern
130
131 @param final_regex: when the control loses focus the input is
132 checked against this regular expression
133 @type final_regex: a string holding a valid regex pattern
134
135 @param navigate_after_selection: whether or not to immediately
136 navigate to the widget next-in-tab-order after selecting an
137 item from the dropdown picklist
138 @type navigate_after_selection: boolean
139
140 @param speller: if not None used to spellcheck the current input
141 and to retrieve suggested replacements/completions
142 @type speller: None or a L{enchant Dict<enchant>} descendant
143
144 @param picklist_delay: this much time of user inactivity must have
145 passed before the input related smarts kick in and the drop
146 down pick list is shown
147 @type picklist_delay: integer (milliseconds)
148 """
150
151 # behaviour
152 self.matcher = None
153 self.selection_only = False
154 self.selection_only_error_msg = _('You must select a value from the picklist or type an exact match.')
155 self.capitalisation_mode = gmTools.CAPS_NONE
156 self.accepted_chars = None
157 self.final_regex = '.*'
158 self.final_regex_error_msg = _('The content is invalid. It must match the regular expression: [%%s]. <%s>') % self.__class__.__name__
159 self.navigate_after_selection = False
160 self.speller = None
161 self.speller_word_separators = default_spelling_word_separators
162 self.picklist_delay = 150 # milliseconds
163
164 # state tracking
165 self._has_focus = False
166 self._current_match_candidates = []
167 self._screenheight = wx.SystemSettings.GetMetric(wx.SYS_SCREEN_Y)
168 self.suppress_text_update_smarts = False
169
170 self.__static_tt = None
171 self.__static_tt_extra = None
172 # don't do this or the tooltip code will fail: self.data = {}
173 # do this instead:
174 self._data = {}
175
176 self._on_selection_callbacks = []
177 self._on_lose_focus_callbacks = []
178 self._on_set_focus_callbacks = []
179 self._on_modified_callbacks = []
180
181 try:
182 kwargs['style'] = kwargs['style'] | wx.TE_PROCESS_TAB | wx.TE_PROCESS_ENTER
183 except KeyError:
184 kwargs['style'] = wx.TE_PROCESS_TAB | wx.TE_PROCESS_ENTER
185 super(cPhraseWheelBase, self).__init__(parent, id, **kwargs)
186
187 self.__my_startup_color = self.GetBackgroundColour()
188 self.__non_edit_font = self.GetFont()
189 global color_prw_valid
190 if color_prw_valid is None:
191 color_prw_valid = wx.SystemSettings_GetColour(wx.SYS_COLOUR_WINDOW)
192
193 self.__init_dropdown(parent = parent)
194 self.__register_events()
195 self.__init_timer()
196 #--------------------------------------------------------
197 # external API
198 #---------------------------------------------------------
200 """Retrieve the data associated with the displayed string(s).
201
202 - self._create_data() must set self.data if possible (/successful)
203 """
204 if len(self._data) == 0:
205 if can_create:
206 self._create_data()
207
208 return self._data
209 #---------------------------------------------------------
211
212 if value is None:
213 value = u''
214
215 self.suppress_text_update_smarts = suppress_smarts
216
217 if data is not None:
218 self.suppress_text_update_smarts = True
219 self.data = self._dictify_data(data = data, value = value)
220 super(cPhraseWheelBase, self).SetValue(value)
221 self.display_as_valid(valid = True)
222
223 # if data already available
224 if len(self._data) > 0:
225 return True
226
227 # empty text value ?
228 if value == u'':
229 # valid value not required ?
230 if not self.selection_only:
231 return True
232
233 if not self._set_data_to_first_match():
234 # not found
235 if self.selection_only:
236 self.display_as_valid(valid = False)
237 return False
238
239 return True
240 #--------------------------------------------------------
243 #--------------------------------------------------------
246 #--------------------------------------------------------
248 if valid is True:
249 self.SetBackgroundColour(self.__my_startup_color)
250 elif valid is False:
251 if partially_invalid:
252 self.SetBackgroundColour(color_prw_partially_invalid)
253 else:
254 self.SetBackgroundColour(color_prw_invalid)
255 else:
256 raise ValueError(u'<valid> must be True or False')
257 self.Refresh()
258 #--------------------------------------------------------
260 if disabled is True:
261 self.SetBackgroundColour(wx.SystemSettings_GetColour(wx.SYS_COLOUR_BACKGROUND))
262 elif disabled is False:
263 self.SetBackgroundColour(color_prw_valid)
264 else:
265 raise ValueError(u'<disabled> must be True or False')
266 self.Refresh()
267 #--------------------------------------------------------
268 # callback API
269 #--------------------------------------------------------
271 """Add a callback for invocation when a picklist item is selected.
272
273 The callback will be invoked whenever an item is selected
274 from the picklist. The associated data is passed in as
275 a single parameter. Callbacks must be able to cope with
276 None as the data parameter as that is sent whenever the
277 user changes a previously selected value.
278 """
279 if not callable(callback):
280 raise ValueError('[add_callback_on_selection]: ignoring callback [%s], it is not callable' % callback)
281
282 self._on_selection_callbacks.append(callback)
283 #---------------------------------------------------------
285 """Add a callback for invocation when getting focus."""
286 if not callable(callback):
287 raise ValueError('[add_callback_on_set_focus]: ignoring callback [%s] - not callable' % callback)
288
289 self._on_set_focus_callbacks.append(callback)
290 #---------------------------------------------------------
292 """Add a callback for invocation when losing focus."""
293 if not callable(callback):
294 raise ValueError('[add_callback_on_lose_focus]: ignoring callback [%s] - not callable' % callback)
295
296 self._on_lose_focus_callbacks.append(callback)
297 #---------------------------------------------------------
299 """Add a callback for invocation when the content is modified.
300
301 This callback will NOT be passed any values.
302 """
303 if not callable(callback):
304 raise ValueError('[add_callback_on_modified]: ignoring callback [%s] - not callable' % callback)
305
306 self._on_modified_callbacks.append(callback)
307 #--------------------------------------------------------
308 # match provider proxies
309 #--------------------------------------------------------
313 #---------------------------------------------------------
317 #--------------------------------------------------------
318 # spell-checking
319 #--------------------------------------------------------
321 # FIXME: use Debian's wgerman-medical as "personal" wordlist if available
322 try:
323 import enchant
324 except ImportError:
325 self.speller = None
326 return False
327
328 try:
329 self.speller = enchant.DictWithPWL(None, os.path.expanduser(os.path.join('~', '.gnumed', 'spellcheck', 'wordlist.pwl')))
330 except enchant.DictNotFoundError:
331 self.speller = None
332 return False
333
334 return True
335 #---------------------------------------------------------
337 if self.speller is None:
338 return None
339
340 # get the last word
341 last_word = self.__speller_word_separators.split(val)[-1]
342 if last_word.strip() == u'':
343 return None
344
345 try:
346 suggestions = self.speller.suggest(last_word)
347 except:
348 _log.exception('had to disable (enchant) spell checker')
349 self.speller = None
350 return None
351
352 if len(suggestions) == 0:
353 return None
354
355 input2match_without_last_word = val[:val.rindex(last_word)]
356 return [ input2match_without_last_word + suggestion for suggestion in suggestions ]
357 #--------------------------------------------------------
359 if word_separators is None:
360 self.__speller_word_separators = regex.compile(default_spelling_word_separators, flags = regex.LOCALE | regex.UNICODE)
361 else:
362 self.__speller_word_separators = regex.compile(word_separators, flags = regex.LOCALE | regex.UNICODE)
363
366
367 speller_word_separators = property(_get_speller_word_separators, _set_speller_word_separators)
368 #--------------------------------------------------------
369 # internal API
370 #--------------------------------------------------------
371 # picklist handling
372 #--------------------------------------------------------
374 szr_dropdown = None
375 try:
376 #raise NotImplementedError # uncomment for testing
377 self.__dropdown_needs_relative_position = False
378 self._picklist_dropdown = wx.PopupWindow(parent)
379 list_parent = self._picklist_dropdown
380 self.__use_fake_popup = False
381 except NotImplementedError:
382 self.__use_fake_popup = True
383
384 # on MacOSX wx.PopupWindow is not implemented, so emulate it
385 add_picklist_to_sizer = True
386 szr_dropdown = wx.BoxSizer(wx.VERTICAL)
387
388 # using wx.MiniFrame
389 self.__dropdown_needs_relative_position = False
390 self._picklist_dropdown = wx.MiniFrame (
391 parent = parent,
392 id = -1,
393 style = wx.SIMPLE_BORDER | wx.FRAME_FLOAT_ON_PARENT | wx.FRAME_NO_TASKBAR | wx.POPUP_WINDOW
394 )
395 scroll_win = wx.ScrolledWindow(parent = self._picklist_dropdown, style = wx.NO_BORDER)
396 scroll_win.SetSizer(szr_dropdown)
397 list_parent = scroll_win
398
399 # using wx.Window
400 #self.__dropdown_needs_relative_position = True
401 #self._picklist_dropdown = wx.ScrolledWindow(parent=parent, style = wx.RAISED_BORDER)
402 #self._picklist_dropdown.SetSizer(szr_dropdown)
403 #list_parent = self._picklist_dropdown
404
405 self.__mac_log('dropdown parent: %s' % self._picklist_dropdown.GetParent())
406
407 self._picklist = cPhraseWheelListCtrl (
408 list_parent,
409 style = wx.LC_NO_HEADER
410 )
411 self._picklist.InsertColumn(0, u'')
412
413 if szr_dropdown is not None:
414 szr_dropdown.Add(self._picklist, 1, wx.EXPAND)
415
416 self._picklist_dropdown.Hide()
417 #--------------------------------------------------------
419 """Display the pick list if useful."""
420
421 self._picklist_dropdown.Hide()
422
423 if not self._has_focus:
424 return
425
426 if len(self._current_match_candidates) == 0:
427 return
428
429 # if only one match and text == match: do not show
430 # picklist but rather pick that match
431 if len(self._current_match_candidates) == 1:
432 candidate = self._current_match_candidates[0]
433 if candidate['field_label'] == input2match:
434 self._update_data_from_picked_item(candidate)
435 return
436
437 # recalculate size
438 dropdown_size = self._picklist_dropdown.GetSize()
439 border_width = 4
440 extra_height = 25
441 # height
442 rows = len(self._current_match_candidates)
443 if rows < 2: # 2 rows minimum
444 rows = 2
445 if rows > 20: # 20 rows maximum
446 rows = 20
447 self.__mac_log('dropdown needs rows: %s' % rows)
448 pw_size = self.GetSize()
449 dropdown_size.SetHeight (
450 (pw_size.height * rows)
451 + border_width
452 + extra_height
453 )
454 # width
455 dropdown_size.SetWidth(min (
456 self.Size.width * 2,
457 self.Parent.Size.width
458 ))
459
460 # recalculate position
461 (pw_x_abs, pw_y_abs) = self.ClientToScreenXY(0,0)
462 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)))
463 dropdown_new_x = pw_x_abs
464 dropdown_new_y = pw_y_abs + pw_size.height
465 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)))
466 self.__mac_log('desired dropdown size: %s' % dropdown_size)
467
468 # reaches beyond screen ?
469 if (dropdown_new_y + dropdown_size.height) > self._screenheight:
470 self.__mac_log('dropdown extends offscreen (screen max y: %s)' % self._screenheight)
471 max_height = self._screenheight - dropdown_new_y - 4
472 self.__mac_log('max dropdown height would be: %s' % max_height)
473 if max_height > ((pw_size.height * 2) + 4):
474 dropdown_size.SetHeight(max_height)
475 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)))
476 self.__mac_log('possible dropdown size: %s' % dropdown_size)
477
478 # now set dimensions
479 self._picklist_dropdown.SetSize(dropdown_size)
480 self._picklist.SetSize(self._picklist_dropdown.GetClientSize())
481 self.__mac_log('pick list size set to: %s' % self._picklist_dropdown.GetSize())
482 if self.__dropdown_needs_relative_position:
483 dropdown_new_x, dropdown_new_y = self._picklist_dropdown.GetParent().ScreenToClientXY(dropdown_new_x, dropdown_new_y)
484 self._picklist_dropdown.MoveXY(dropdown_new_x, dropdown_new_y)
485
486 # select first value
487 self._picklist.Select(0)
488
489 # and show it
490 self._picklist_dropdown.Show(True)
491
492 # dropdown_top_left = self._picklist_dropdown.ClientToScreenXY(0,0)
493 # dropdown_size = self._picklist_dropdown.GetSize()
494 # dropdown_bottom_right = self._picklist_dropdown.ClientToScreenXY(dropdown_size.width, dropdown_size.height)
495 # self.__mac_log('dropdown placement now (on screen): x:%s-%s, y:%s-%s' % (
496 # dropdown_top_left[0],
497 # dropdown_bottom_right[0],
498 # dropdown_top_left[1],
499 # dropdown_bottom_right[1])
500 # )
501 #--------------------------------------------------------
505 #--------------------------------------------------------
507 """Mark the given picklist row as selected."""
508 if old_row_idx is not None:
509 pass # FIXME: do we need unselect here ? Select() should do it for us
510 self._picklist.Select(new_row_idx)
511 self._picklist.EnsureVisible(new_row_idx)
512 #--------------------------------------------------------
514 """Get string to display in the field for the given picklist item."""
515 if item is None:
516 item = self._picklist.get_selected_item()
517 try:
518 return item['field_label']
519 except KeyError:
520 pass
521 try:
522 return item['list_label']
523 except KeyError:
524 pass
525 try:
526 return item['label']
527 except KeyError:
528 return u'<no field_*/list_*/label in item>'
529 #return self._picklist.GetItemText(self._picklist.GetFirstSelected())
530 #--------------------------------------------------------
532 """Update the display to show item strings."""
533 # default to single phrase
534 display_string = self._picklist_item2display_string(item = item)
535 self.suppress_text_update_smarts = True
536 super(cPhraseWheelBase, self).SetValue(display_string)
537 # in single-phrase phrasewheels always set cursor to end of string
538 self.SetInsertionPoint(self.GetLastPosition())
539 return
540 #--------------------------------------------------------
541 # match generation
542 #--------------------------------------------------------
544 raise NotImplementedError('[%s]: fragment extraction not implemented' % self.__class__.__name__)
545 #---------------------------------------------------------
547 """Get candidates matching the currently typed input."""
548
549 # get all currently matching items
550 self._current_match_candidates = []
551 if self.matcher is not None:
552 matched, self._current_match_candidates = self.matcher.getMatches(val)
553 self._picklist.SetItems(self._current_match_candidates)
554
555 # no matches:
556 # - none found (perhaps due to a typo)
557 # - or no matcher available
558 # anyway: spellcheck
559 if len(self._current_match_candidates) == 0:
560 suggestions = self._get_suggestions_from_spell_checker(val)
561 if suggestions is not None:
562 self._current_match_candidates = [
563 {'list_label': suggestion, 'field_label': suggestion, 'data': None}
564 for suggestion in suggestions
565 ]
566 self._picklist.SetItems(self._current_match_candidates)
567 #--------------------------------------------------------
568 # tooltip handling
569 #--------------------------------------------------------
571 # child classes can override this to provide
572 # per data item dynamic tooltips,
573 # by default do not support dynamic tooltip parts:
574 return None
575 #--------------------------------------------------------
577 """Calculate dynamic tooltip part based on data item.
578
579 - called via ._set_data() each time property .data (-> .__data) is set
580 - hence also called the first time data is set
581 - the static tooltip can be set any number of ways before that
582 - only when data is first set does the dynamic part become relevant
583 - hence it is sufficient to remember the static part when .data is
584 set for the first time
585 """
586 if self.__static_tt is None:
587 if self.ToolTip is None:
588 self.__static_tt = u''
589 else:
590 self.__static_tt = self.ToolTip.Tip
591
592 # need to always calculate static part because
593 # the dynamic part can have *become* None, again,
594 # in which case we want to be able to re-set the
595 # tooltip to the static part
596 static_part = self.__static_tt
597 if (self.__static_tt_extra) is not None and (self.__static_tt_extra.strip() != u''):
598 static_part = u'%s\n\n%s' % (
599 static_part,
600 self.__static_tt_extra
601 )
602
603 dynamic_part = self._get_data_tooltip()
604 if dynamic_part is None:
605 self.SetToolTipString(static_part)
606 return
607
608 if static_part == u'':
609 tt = dynamic_part
610 else:
611 if dynamic_part.strip() == u'':
612 tt = static_part
613 else:
614 tt = u'%s\n\n%s\n\n%s' % (
615 dynamic_part,
616 gmTools.u_box_horiz_single * 32,
617 static_part
618 )
619
620 self.SetToolTipString(tt)
621 #--------------------------------------------------------
624
627
628 static_tooltip_extra = property(_get_static_tt_extra, _set_static_tt_extra)
629 #--------------------------------------------------------
630 # event handling
631 #--------------------------------------------------------
633 wx.EVT_KEY_DOWN (self, self._on_key_down)
634 wx.EVT_SET_FOCUS(self, self._on_set_focus)
635 wx.EVT_KILL_FOCUS(self, self._on_lose_focus)
636 wx.EVT_TEXT(self, self.GetId(), self._on_text_update)
637 self._picklist.Bind(wx.EVT_LEFT_DCLICK, self._on_list_item_selected)
638 #--------------------------------------------------------
640 """Is called when a key is pressed."""
641
642 keycode = event.GetKeyCode()
643
644 if keycode == wx.WXK_DOWN:
645 self.__on_cursor_down()
646 return
647
648 if keycode == wx.WXK_UP:
649 self.__on_cursor_up()
650 return
651
652 if keycode == wx.WXK_RETURN:
653 self._on_enter()
654 return
655
656 if keycode == wx.WXK_TAB:
657 if event.ShiftDown():
658 self.Navigate(flags = wx.NavigationKeyEvent.IsBackward)
659 return
660 self.__on_tab()
661 self.Navigate(flags = wx.NavigationKeyEvent.IsForward)
662 return
663
664 # FIXME: need PAGE UP/DOWN//POS1/END here to move in picklist
665 if keycode in [wx.WXK_SHIFT, wx.WXK_BACK, wx.WXK_DELETE, wx.WXK_LEFT, wx.WXK_RIGHT]:
666 pass
667
668 # need to handle all non-character key presses *before* this check
669 elif not self.__char_is_allowed(char = unichr(event.GetUnicodeKey())):
670 wx.Bell()
671 # Richard doesn't show any error message here
672 return
673
674 event.Skip()
675 return
676 #--------------------------------------------------------
678
679 self._has_focus = True
680 event.Skip()
681
682 self.__non_edit_font = self.GetFont()
683 edit_font = self.GetFont()
684 edit_font.SetPointSize(pointSize = self.__non_edit_font.GetPointSize() + 1)
685 self.SetFont(edit_font)
686 self.Refresh()
687
688 # notify interested parties
689 for callback in self._on_set_focus_callbacks:
690 callback()
691
692 self.__timer.Start(oneShot = True, milliseconds = self.picklist_delay)
693 return True
694 #--------------------------------------------------------
696 """Do stuff when leaving the control.
697
698 The user has had her say, so don't second guess
699 intentions but do report error conditions.
700 """
701 event.Skip()
702 self._has_focus = False
703 self.__timer.Stop()
704 self._hide_picklist()
705 wx.CallAfter(self.__on_lost_focus)
706 return True
707 #--------------------------------------------------------
709 self.SetSelection(1,1)
710 self.SetFont(self.__non_edit_font)
711 #self.Refresh() # already done in .display_as_valid() below
712
713 is_valid = True
714
715 # the user may have typed a phrase that is an exact match,
716 # however, just typing it won't associate data from the
717 # picklist, so try do that now
718 self._set_data_to_first_match()
719
720 # check value against final_regex if any given
721 if self.__final_regex.match(self.GetValue().strip()) is None:
722 gmDispatcher.send(signal = 'statustext', msg = self.final_regex_error_msg)
723 is_valid = False
724
725 self.display_as_valid(valid = is_valid)
726
727 # notify interested parties
728 for callback in self._on_lose_focus_callbacks:
729 callback()
730 #--------------------------------------------------------
732 """Gets called when user selected a list item."""
733
734 self._hide_picklist()
735
736 item = self._picklist.get_selected_item()
737 # huh ?
738 if item is None:
739 self.display_as_valid(valid = True)
740 return
741
742 self._update_display_from_picked_item(item)
743 self._update_data_from_picked_item(item)
744 self.MarkDirty()
745
746 # and tell the listeners about the user's selection
747 for callback in self._on_selection_callbacks:
748 callback(self._data)
749
750 if self.navigate_after_selection:
751 self.Navigate()
752
753 return
754 #--------------------------------------------------------
756 """Internal handler for wx.EVT_TEXT.
757
758 Called when text was changed by user or by SetValue().
759 """
760 if self.suppress_text_update_smarts:
761 self.suppress_text_update_smarts = False
762 return
763
764 self._adjust_data_after_text_update()
765 self._current_match_candidates = []
766
767 val = self.GetValue().strip()
768 ins_point = self.GetInsertionPoint()
769
770 # if empty string then hide list dropdown window
771 # we also don't need a timer event then
772 if val == u'':
773 self._hide_picklist()
774 self.__timer.Stop()
775 else:
776 new_val = gmTools.capitalize(text = val, mode = self.capitalisation_mode)
777 if new_val != val:
778 self.suppress_text_update_smarts = True
779 super(cPhraseWheelBase, self).SetValue(new_val)
780 if ins_point > len(new_val):
781 self.SetInsertionPointEnd()
782 else:
783 self.SetInsertionPoint(ins_point)
784 # FIXME: SetSelection() ?
785
786 # start timer for delayed match retrieval
787 self.__timer.Start(oneShot = True, milliseconds = self.picklist_delay)
788
789 # notify interested parties
790 for callback in self._on_modified_callbacks:
791 callback()
792
793 return
794 #--------------------------------------------------------
795 # keypress handling
796 #--------------------------------------------------------
798 """Called when the user pressed <ENTER>."""
799 if self._picklist_dropdown.IsShown():
800 self._on_list_item_selected()
801 else:
802 # FIXME: check for errors before navigation
803 self.Navigate()
804 #--------------------------------------------------------
806
807 if self._picklist_dropdown.IsShown():
808 idx_selected = self._picklist.GetFirstSelected()
809 if idx_selected < (len(self._current_match_candidates) - 1):
810 self._select_picklist_row(idx_selected + 1, idx_selected)
811 return
812
813 # if we don't yet have a pick list: open new pick list
814 # (this can happen when we TAB into a field pre-filled
815 # with the top-weighted contextual item but want to
816 # select another contextual item)
817 self.__timer.Stop()
818 if self.GetValue().strip() == u'':
819 val = u'*'
820 else:
821 val = self._extract_fragment_to_match_on()
822 self._update_candidates_in_picklist(val = val)
823 self._show_picklist(input2match = val)
824 #--------------------------------------------------------
826 if self._picklist_dropdown.IsShown():
827 selected = self._picklist.GetFirstSelected()
828 if selected > 0:
829 self._select_picklist_row(selected-1, selected)
830 else:
831 # FIXME: input history ?
832 pass
833 #--------------------------------------------------------
835 """Under certain circumstances take special action on <TAB>.
836
837 returns:
838 True: <TAB> was handled
839 False: <TAB> was not handled
840
841 -> can be used to decide whether to do further <TAB> handling outside this class
842 """
843 # are we seeing the picklist ?
844 if not self._picklist_dropdown.IsShown():
845 return False
846
847 # with only one candidate ?
848 if len(self._current_match_candidates) != 1:
849 return False
850
851 # and do we require the input to be picked from the candidates ?
852 if not self.selection_only:
853 return False
854
855 # then auto-select that item
856 self._select_picklist_row(new_row_idx = 0)
857 self._on_list_item_selected()
858
859 return True
860 #--------------------------------------------------------
861 # timer handling
862 #--------------------------------------------------------
864 self.__timer = _cPRWTimer()
865 self.__timer.callback = self._on_timer_fired
866 # initially stopped
867 self.__timer.Stop()
868 #--------------------------------------------------------
870 """Callback for delayed match retrieval timer.
871
872 if we end up here:
873 - delay has passed without user input
874 - the value in the input field has not changed since the timer started
875 """
876 # update matches according to current input
877 val = self._extract_fragment_to_match_on()
878 self._update_candidates_in_picklist(val = val)
879
880 # we now have either:
881 # - all possible items (within reasonable limits) if input was '*'
882 # - all matching items
883 # - an empty match list if no matches were found
884 # also, our picklist is refilled and sorted according to weight
885 wx.CallAfter(self._show_picklist, input2match = val)
886 #----------------------------------------------------
887 # random helpers and properties
888 #----------------------------------------------------
892 #--------------------------------------------------------
894 # if undefined accept all chars
895 if self.accepted_chars is None:
896 return True
897 return (self.__accepted_chars.match(char) is not None)
898 #--------------------------------------------------------
900 if accepted_chars is None:
901 self.__accepted_chars = None
902 else:
903 self.__accepted_chars = regex.compile(accepted_chars)
904
909
910 accepted_chars = property(_get_accepted_chars, _set_accepted_chars)
911 #--------------------------------------------------------
913 self.__final_regex = regex.compile(final_regex, flags = regex.LOCALE | regex.UNICODE)
914
917
918 final_regex = property(_get_final_regex, _set_final_regex)
919 #--------------------------------------------------------
921 self.__final_regex_error_msg = msg % self.final_regex
922
925
926 final_regex_error_msg = property(_get_final_regex_error_msg, _set_final_regex_error_msg)
927 #--------------------------------------------------------
928 # data munging
929 #--------------------------------------------------------
932 #--------------------------------------------------------
934 self.data = {item['field_label']: item}
935 #--------------------------------------------------------
938 #---------------------------------------------------------
940 raise NotImplementedError('[%s]: cannot adjust data after text update' % self.__class__.__name__)
941 #--------------------------------------------------------
946 #--------------------------------------------------------
949 #--------------------------------------------------------
952
956
957 data = property(_get_data, _set_data)
958
959 #============================================================
960 # FIXME: cols in pick list
961 # FIXME: snap_to_basename+set selection
962 # FIXME: learn() -> PWL
963 # FIXME: up-arrow: show recent (in-memory) history
964 #----------------------------------------------------------
965 # ideas
966 #----------------------------------------------------------
967 #- display possible completion but highlighted for deletion
968 #(- cycle through possible completions)
969 #- pre-fill selection with SELECT ... LIMIT 25
970 #- async threads for match retrieval instead of timer
971 # - on truncated results return item "..." -> selection forcefully retrieves all matches
972
973 #- generators/yield()
974 #- OnChar() - process a char event
975
976 # split input into words and match components against known phrases
977
978 # make special list window:
979 # - deletion of items
980 # - highlight matched parts
981 # - faster scrolling
982 # - wxEditableListBox ?
983
984 # - if non-learning (i.e. fast select only): autocomplete with match
985 # and move cursor to end of match
986 #-----------------------------------------------------------------------------------------------
987 # darn ! this clever hack won't work since we may have crossed a search location threshold
988 #----
989 # #self.__prevFragment = "***********-very-unlikely--------------***************"
990 # #self.__prevMatches = [] # a list of tuples (ID, listbox name, weight)
991 #
992 # # is the current fragment just a longer version of the previous fragment ?
993 # if string.find(aFragment, self.__prevFragment) == 0:
994 # # we then need to search in the previous matches only
995 # for prevMatch in self.__prevMatches:
996 # if string.find(prevMatch[1], aFragment) == 0:
997 # matches.append(prevMatch)
998 # # remember current matches
999 # self.__prefMatches = matches
1000 # # no matches found
1001 # if len(matches) == 0:
1002 # return [(1,_('*no matching items found*'),1)]
1003 # else:
1004 # return matches
1005 #----
1006 #TODO:
1007 # - see spincontrol for list box handling
1008 # stop list (list of negatives): "an" -> "animal" but not "and"
1009 #-----
1010 #> > remember, you should be searching on either weighted data, or in some
1011 #> > situations a start string search on indexed data
1012 #>
1013 #> Can you be a bit more specific on this ?
1014
1015 #seaching ones own previous text entered would usually be instring but
1016 #weighted (ie the phrases you use the most auto filter to the top)
1017
1018 #Searching a drug database for a drug brand name is usually more
1019 #functional if it does a start string search, not an instring search which is
1020 #much slower and usually unecesary. There are many other examples but trust
1021 #me one needs both
1022
1023 # FIXME: support selection-only-or-empty
1024
1025
1026 #============================================================
1028
1030
1031 super(cPhraseWheel, self).GetData(can_create = can_create)
1032
1033 if len(self._data) > 0:
1034 if as_instance:
1035 return self._data2instance()
1036
1037 if len(self._data) == 0:
1038 return None
1039
1040 return self._data.values()[0]['data']
1041 #---------------------------------------------------------
1043 """Set the data and thereby set the value, too. if possible.
1044
1045 If you call SetData() you better be prepared
1046 doing a scan of the entire potential match space.
1047
1048 The whole thing will only work if data is found
1049 in the match space anyways.
1050 """
1051 # try getting match candidates
1052 self._update_candidates_in_picklist(u'*')
1053
1054 # do we require a match ?
1055 if self.selection_only:
1056 # yes, but we don't have any candidates
1057 if len(self._current_match_candidates) == 0:
1058 return False
1059
1060 # among candidates look for a match with <data>
1061 for candidate in self._current_match_candidates:
1062 if candidate['data'] == data:
1063 super(cPhraseWheel, self).SetText (
1064 value = candidate['field_label'],
1065 data = data,
1066 suppress_smarts = True
1067 )
1068 return True
1069
1070 # no match found in candidates (but needed) ...
1071 if self.selection_only:
1072 self.display_as_valid(valid = False)
1073 return False
1074
1075 self.data = self._dictify_data(data = data)
1076 self.display_as_valid(valid = True)
1077 return True
1078 #--------------------------------------------------------
1079 # internal API
1080 #--------------------------------------------------------
1082
1083 # this helps if the current input was already selected from the
1084 # list but still is the substring of another pick list item or
1085 # else the picklist will re-open just after selection
1086 if len(self._data) > 0:
1087 self._picklist_dropdown.Hide()
1088 return
1089
1090 return super(cPhraseWheel, self)._show_picklist(input2match = input2match)
1091 #--------------------------------------------------------
1093 # data already set ?
1094 if len(self._data) > 0:
1095 return True
1096
1097 # needed ?
1098 val = self.GetValue().strip()
1099 if val == u'':
1100 return True
1101
1102 # so try
1103 self._update_candidates_in_picklist(val = val)
1104 for candidate in self._current_match_candidates:
1105 if candidate['field_label'] == val:
1106 self.data = {candidate['field_label']: candidate}
1107 self.MarkDirty()
1108 # tell listeners about the user's selection
1109 for callback in self._on_selection_callbacks:
1110 callback(self._data)
1111 return True
1112
1113 # no exact match found
1114 if self.selection_only:
1115 gmDispatcher.send(signal = 'statustext', msg = self.selection_only_error_msg)
1116 is_valid = False
1117 return False
1118
1119 return True
1120 #---------------------------------------------------------
1122 self.data = {}
1123 #---------------------------------------------------------
1125 return self.GetValue().strip()
1126 #---------------------------------------------------------
1132 #============================================================
1134
1136
1137 super(cMultiPhraseWheel, self).__init__(*args, **kwargs)
1138
1139 self.phrase_separators = default_phrase_separators
1140 self.left_part = u''
1141 self.right_part = u''
1142 self.speller = None
1143 #---------------------------------------------------------
1145
1146 super(cMultiPhraseWheel, self).GetData(can_create = can_create)
1147
1148 if len(self._data) > 0:
1149 if as_instance:
1150 return self._data2instance()
1151
1152 return self._data.values()
1153 #---------------------------------------------------------
1157 #---------------------------------------------------------
1159
1160 data_dict = {}
1161
1162 for item in data_items:
1163 try:
1164 list_label = item['list_label']
1165 except KeyError:
1166 list_label = item['label']
1167 try:
1168 field_label = item['field_label']
1169 except KeyError:
1170 field_label = list_label
1171 data_dict[field_label] = {'data': item['data'], 'list_label': list_label, 'field_label': field_label}
1172
1173 return data_dict
1174 #---------------------------------------------------------
1175 # internal API
1176 #---------------------------------------------------------
1179 #---------------------------------------------------------
1181 # the textctrl display must already be set properly
1182 new_data = {}
1183 # this way of looping automatically removes stale
1184 # data for labels which are no longer displayed
1185 for displayed_label in self.displayed_strings:
1186 try:
1187 new_data[displayed_label] = self._data[displayed_label]
1188 except KeyError:
1189 # this removes stale data for which there
1190 # is no displayed_label anymore
1191 pass
1192
1193 self.data = new_data
1194 #---------------------------------------------------------
1196
1197 cursor_pos = self.GetInsertionPoint()
1198
1199 entire_input = self.GetValue()
1200 if self.__phrase_separators.search(entire_input) is None:
1201 self.left_part = u''
1202 self.right_part = u''
1203 return self.GetValue().strip()
1204
1205 string_left_of_cursor = entire_input[:cursor_pos]
1206 string_right_of_cursor = entire_input[cursor_pos:]
1207
1208 left_parts = [ lp.strip() for lp in self.__phrase_separators.split(string_left_of_cursor) ]
1209 if len(left_parts) == 0:
1210 self.left_part = u''
1211 else:
1212 self.left_part = u'%s%s ' % (
1213 (u'%s ' % self.__phrase_separators.pattern[0]).join(left_parts[:-1]),
1214 self.__phrase_separators.pattern[0]
1215 )
1216
1217 right_parts = [ rp.strip() for rp in self.__phrase_separators.split(string_right_of_cursor) ]
1218 self.right_part = u'%s %s' % (
1219 self.__phrase_separators.pattern[0],
1220 (u'%s ' % self.__phrase_separators.pattern[0]).join(right_parts[1:])
1221 )
1222
1223 val = (left_parts[-1] + right_parts[0]).strip()
1224 return val
1225 #--------------------------------------------------------
1227 val = (u'%s%s%s' % (
1228 self.left_part,
1229 self._picklist_item2display_string(item = item),
1230 self.right_part
1231 )).lstrip().lstrip(';').strip()
1232 self.suppress_text_update_smarts = True
1233 super(cMultiPhraseWheel, self).SetValue(val)
1234 # find item end and move cursor to that place:
1235 item_end = val.index(item['field_label']) + len(item['field_label'])
1236 self.SetInsertionPoint(item_end)
1237 return
1238 #--------------------------------------------------------
1240
1241 # add item to the data
1242 self._data[item['field_label']] = item
1243
1244 # the textctrl display must already be set properly
1245 field_labels = [ p.strip() for p in self.__phrase_separators.split(self.GetValue().strip()) ]
1246 new_data = {}
1247 # this way of looping automatically removes stale
1248 # data for labels which are no longer displayed
1249 for field_label in field_labels:
1250 try:
1251 new_data[field_label] = self._data[field_label]
1252 except KeyError:
1253 # this removes stale data for which there
1254 # is no displayed_label anymore
1255 pass
1256
1257 self.data = new_data
1258 #---------------------------------------------------------
1260 if type(data) == type([]):
1261 # useful because self.GetData() returns just such a list
1262 return self.list2data_dict(data_items = data)
1263 # else assume new-style already-dictified data
1264 return data
1265 #--------------------------------------------------------
1266 # properties
1267 #--------------------------------------------------------
1269 """Set phrase separators.
1270
1271 - must be a valid regular expression pattern
1272
1273 input is split into phrases at boundaries defined by
1274 this regex and matching is performed on the phrase
1275 the cursor is in only,
1276
1277 after selection from picklist phrase_separators[0] is
1278 added to the end of the match in the PRW
1279 """
1280 self.__phrase_separators = regex.compile(phrase_separators, flags = regex.LOCALE | regex.UNICODE)
1281
1284
1285 phrase_separators = property(_get_phrase_separators, _set_phrase_separators)
1286 #--------------------------------------------------------
1288 return [ p.strip() for p in self.__phrase_separators.split(self.GetValue().strip()) if p.strip() != u'' ]
1289
1290 displayed_strings = property(_get_displayed_strings, lambda x:x)
1291 #============================================================
1292 # main
1293 #------------------------------------------------------------
1294 if __name__ == '__main__':
1295
1296 if len(sys.argv) < 2:
1297 sys.exit()
1298
1299 if sys.argv[1] != u'test':
1300 sys.exit()
1301
1302 from Gnumed.pycommon import gmI18N
1303 gmI18N.activate_locale()
1304 gmI18N.install_domain(domain='gnumed')
1305
1306 from Gnumed.pycommon import gmPG2, gmMatchProvider
1307
1308 prw = None # used for access from display_values_*
1309 #--------------------------------------------------------
1311 print "got focus:"
1312 print "value:", prw.GetValue()
1313 print "data :", prw.GetData()
1314 return True
1315 #--------------------------------------------------------
1317 print "lost focus:"
1318 print "value:", prw.GetValue()
1319 print "data :", prw.GetData()
1320 return True
1321 #--------------------------------------------------------
1323 print "modified:"
1324 print "value:", prw.GetValue()
1325 print "data :", prw.GetData()
1326 return True
1327 #--------------------------------------------------------
1329 print "selected:"
1330 print "value:", prw.GetValue()
1331 print "data :", prw.GetData()
1332 return True
1333 #--------------------------------------------------------
1334 #--------------------------------------------------------
1336 app = wx.PyWidgetTester(size = (200, 50))
1337
1338 items = [ {'data': 1, 'list_label': "Bloggs", 'field_label': "Bloggs", 'weight': 0},
1339 {'data': 2, 'list_label': "Baker", 'field_label': "Baker", 'weight': 0},
1340 {'data': 3, 'list_label': "Jones", 'field_label': "Jones", 'weight': 0},
1341 {'data': 4, 'list_label': "Judson", 'field_label': "Judson", 'weight': 0},
1342 {'data': 5, 'list_label': "Jacobs", 'field_label': "Jacobs", 'weight': 0},
1343 {'data': 6, 'list_label': "Judson-Jacobs", 'field_label': "Judson-Jacobs", 'weight': 0}
1344 ]
1345
1346 mp = gmMatchProvider.cMatchProvider_FixedList(items)
1347 # do NOT treat "-" as a word separator here as there are names like "asa-sismussen"
1348 mp.word_separators = '[ \t=+&:@]+'
1349 global prw
1350 prw = cPhraseWheel(parent = app.frame, id = -1)
1351 prw.matcher = mp
1352 prw.capitalisation_mode = gmTools.CAPS_NAMES
1353 prw.add_callback_on_set_focus(callback=display_values_set_focus)
1354 prw.add_callback_on_modified(callback=display_values_modified)
1355 prw.add_callback_on_lose_focus(callback=display_values_lose_focus)
1356 prw.add_callback_on_selection(callback=display_values_selected)
1357
1358 app.frame.Show(True)
1359 app.MainLoop()
1360
1361 return True
1362 #--------------------------------------------------------
1364 print "Do you want to test the database connected phrase wheel ?"
1365 yes_no = raw_input('y/n: ')
1366 if yes_no != 'y':
1367 return True
1368
1369 gmPG2.get_connection()
1370 query = u"""SELECT code, code || ': ' || _(name), _(name) FROM dem.country WHERE _(name) %(fragment_condition)s"""
1371 mp = gmMatchProvider.cMatchProvider_SQL2(queries = [query])
1372 app = wx.PyWidgetTester(size = (400, 50))
1373 global prw
1374 #prw = cPhraseWheel(parent = app.frame, id = -1)
1375 prw = cMultiPhraseWheel(parent = app.frame, id = -1)
1376 prw.matcher = mp
1377
1378 app.frame.Show(True)
1379 app.MainLoop()
1380
1381 return True
1382 #--------------------------------------------------------
1384 gmPG2.get_connection()
1385 query = u"""
1386 select
1387 pk_identity,
1388 firstnames || ' ' || lastnames || ', ' || to_char(dob, 'YYYY-MM-DD'),
1389 firstnames || ' ' || lastnames
1390 from
1391 dem.v_basic_person
1392 where
1393 firstnames || lastnames %(fragment_condition)s
1394 """
1395 mp = gmMatchProvider.cMatchProvider_SQL2(queries = [query])
1396 app = wx.PyWidgetTester(size = (500, 50))
1397 global prw
1398 prw = cPhraseWheel(parent = app.frame, id = -1)
1399 prw.matcher = mp
1400 prw.selection_only = True
1401
1402 app.frame.Show(True)
1403 app.MainLoop()
1404
1405 return True
1406 #--------------------------------------------------------
1408 app = wx.PyWidgetTester(size = (200, 50))
1409
1410 global prw
1411 prw = cPhraseWheel(parent = app.frame, id = -1)
1412
1413 prw.add_callback_on_set_focus(callback=display_values_set_focus)
1414 prw.add_callback_on_modified(callback=display_values_modified)
1415 prw.add_callback_on_lose_focus(callback=display_values_lose_focus)
1416 prw.add_callback_on_selection(callback=display_values_selected)
1417
1418 prw.enable_default_spellchecker()
1419
1420 app.frame.Show(True)
1421 app.MainLoop()
1422
1423 return True
1424 #--------------------------------------------------------
1425 #test_prw_fixed_list()
1426 #test_prw_sql2()
1427 #test_spell_checking_prw()
1428 test_prw_patients()
1429
1430 #==================================================
1431
| Home | Trees | Indices | Help |
|
|---|
| Generated by Epydoc 3.0.1 on Mon Jun 10 03:56:51 2013 | http://epydoc.sourceforge.net |