| 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 if (value == u'') and (data is None):
216 self._data = {}
217 super(cPhraseWheelBase, self).SetValue(value)
218 return
219
220 self.suppress_text_update_smarts = suppress_smarts
221
222 if data is not None:
223 self.suppress_text_update_smarts = True
224 self.data = self._dictify_data(data = data, value = value)
225 super(cPhraseWheelBase, self).SetValue(value)
226 self.display_as_valid(valid = True)
227
228 # if data already available
229 if len(self._data) > 0:
230 return True
231
232 # empty text value ?
233 if value == u'':
234 # valid value not required ?
235 if not self.selection_only:
236 return True
237
238 if not self._set_data_to_first_match():
239 # not found
240 if self.selection_only:
241 self.display_as_valid(valid = False)
242 return False
243
244 return True
245 #--------------------------------------------------------
248 #--------------------------------------------------------
251 #--------------------------------------------------------
253 if valid is True:
254 self.SetBackgroundColour(self.__my_startup_color)
255 elif valid is False:
256 if partially_invalid:
257 self.SetBackgroundColour(color_prw_partially_invalid)
258 else:
259 self.SetBackgroundColour(color_prw_invalid)
260 else:
261 raise ValueError(u'<valid> must be True or False')
262 self.Refresh()
263 #--------------------------------------------------------
265 if disabled is True:
266 self.SetBackgroundColour(wx.SystemSettings_GetColour(wx.SYS_COLOUR_BACKGROUND))
267 elif disabled is False:
268 self.SetBackgroundColour(color_prw_valid)
269 else:
270 raise ValueError(u'<disabled> must be True or False')
271 self.Refresh()
272 #--------------------------------------------------------
273 # callback API
274 #--------------------------------------------------------
276 """Add a callback for invocation when a picklist item is selected.
277
278 The callback will be invoked whenever an item is selected
279 from the picklist. The associated data is passed in as
280 a single parameter. Callbacks must be able to cope with
281 None as the data parameter as that is sent whenever the
282 user changes a previously selected value.
283 """
284 if not callable(callback):
285 raise ValueError('[add_callback_on_selection]: ignoring callback [%s], it is not callable' % callback)
286
287 self._on_selection_callbacks.append(callback)
288 #---------------------------------------------------------
290 """Add a callback for invocation when getting focus."""
291 if not callable(callback):
292 raise ValueError('[add_callback_on_set_focus]: ignoring callback [%s] - not callable' % callback)
293
294 self._on_set_focus_callbacks.append(callback)
295 #---------------------------------------------------------
297 """Add a callback for invocation when losing focus."""
298 if not callable(callback):
299 raise ValueError('[add_callback_on_lose_focus]: ignoring callback [%s] - not callable' % callback)
300
301 self._on_lose_focus_callbacks.append(callback)
302 #---------------------------------------------------------
304 """Add a callback for invocation when the content is modified.
305
306 This callback will NOT be passed any values.
307 """
308 if not callable(callback):
309 raise ValueError('[add_callback_on_modified]: ignoring callback [%s] - not callable' % callback)
310
311 self._on_modified_callbacks.append(callback)
312 #--------------------------------------------------------
313 # match provider proxies
314 #--------------------------------------------------------
318 #---------------------------------------------------------
322 #--------------------------------------------------------
323 # spell-checking
324 #--------------------------------------------------------
326 # FIXME: use Debian's wgerman-medical as "personal" wordlist if available
327 try:
328 import enchant
329 except ImportError:
330 self.speller = None
331 return False
332
333 try:
334 self.speller = enchant.DictWithPWL(None, os.path.expanduser(os.path.join('~', '.gnumed', 'spellcheck', 'wordlist.pwl')))
335 except enchant.DictNotFoundError:
336 self.speller = None
337 return False
338
339 return True
340 #---------------------------------------------------------
342 if self.speller is None:
343 return None
344
345 # get the last word
346 last_word = self.__speller_word_separators.split(val)[-1]
347 if last_word.strip() == u'':
348 return None
349
350 try:
351 suggestions = self.speller.suggest(last_word)
352 except:
353 _log.exception('had to disable (enchant) spell checker')
354 self.speller = None
355 return None
356
357 if len(suggestions) == 0:
358 return None
359
360 input2match_without_last_word = val[:val.rindex(last_word)]
361 return [ input2match_without_last_word + suggestion for suggestion in suggestions ]
362 #--------------------------------------------------------
364 if word_separators is None:
365 self.__speller_word_separators = regex.compile(default_spelling_word_separators, flags = regex.LOCALE | regex.UNICODE)
366 else:
367 self.__speller_word_separators = regex.compile(word_separators, flags = regex.LOCALE | regex.UNICODE)
368
371
372 speller_word_separators = property(_get_speller_word_separators, _set_speller_word_separators)
373 #--------------------------------------------------------
374 # internal API
375 #--------------------------------------------------------
376 # picklist handling
377 #--------------------------------------------------------
379 szr_dropdown = None
380 try:
381 #raise NotImplementedError # uncomment for testing
382 self.__dropdown_needs_relative_position = False
383 self._picklist_dropdown = wx.PopupWindow(parent)
384 list_parent = self._picklist_dropdown
385 self.__use_fake_popup = False
386 except NotImplementedError:
387 self.__use_fake_popup = True
388
389 # on MacOSX wx.PopupWindow is not implemented, so emulate it
390 add_picklist_to_sizer = True
391 szr_dropdown = wx.BoxSizer(wx.VERTICAL)
392
393 # using wx.MiniFrame
394 self.__dropdown_needs_relative_position = False
395 self._picklist_dropdown = wx.MiniFrame (
396 parent = parent,
397 id = -1,
398 style = wx.SIMPLE_BORDER | wx.FRAME_FLOAT_ON_PARENT | wx.FRAME_NO_TASKBAR | wx.POPUP_WINDOW
399 )
400 scroll_win = wx.ScrolledWindow(parent = self._picklist_dropdown, style = wx.NO_BORDER)
401 scroll_win.SetSizer(szr_dropdown)
402 list_parent = scroll_win
403
404 # using wx.Window
405 #self.__dropdown_needs_relative_position = True
406 #self._picklist_dropdown = wx.ScrolledWindow(parent=parent, style = wx.RAISED_BORDER)
407 #self._picklist_dropdown.SetSizer(szr_dropdown)
408 #list_parent = self._picklist_dropdown
409
410 self.__mac_log('dropdown parent: %s' % self._picklist_dropdown.GetParent())
411
412 self._picklist = cPhraseWheelListCtrl (
413 list_parent,
414 style = wx.LC_NO_HEADER
415 )
416 self._picklist.InsertColumn(0, u'')
417
418 if szr_dropdown is not None:
419 szr_dropdown.Add(self._picklist, 1, wx.EXPAND)
420
421 self._picklist_dropdown.Hide()
422 #--------------------------------------------------------
424 """Display the pick list if useful."""
425
426 self._picklist_dropdown.Hide()
427
428 if not self._has_focus:
429 return
430
431 if len(self._current_match_candidates) == 0:
432 return
433
434 # if only one match and text == match: do not show
435 # picklist but rather pick that match
436 if len(self._current_match_candidates) == 1:
437 candidate = self._current_match_candidates[0]
438 if candidate['field_label'] == input2match:
439 self._update_data_from_picked_item(candidate)
440 return
441
442 # recalculate size
443 dropdown_size = self._picklist_dropdown.GetSize()
444 border_width = 4
445 extra_height = 25
446 # height
447 rows = len(self._current_match_candidates)
448 if rows < 2: # 2 rows minimum
449 rows = 2
450 if rows > 20: # 20 rows maximum
451 rows = 20
452 self.__mac_log('dropdown needs rows: %s' % rows)
453 pw_size = self.GetSize()
454 dropdown_size.SetHeight (
455 (pw_size.height * rows)
456 + border_width
457 + extra_height
458 )
459 # width
460 dropdown_size.SetWidth(min (
461 self.Size.width * 2,
462 self.Parent.Size.width
463 ))
464
465 # recalculate position
466 (pw_x_abs, pw_y_abs) = self.ClientToScreenXY(0,0)
467 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)))
468 dropdown_new_x = pw_x_abs
469 dropdown_new_y = pw_y_abs + pw_size.height
470 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)))
471 self.__mac_log('desired dropdown size: %s' % dropdown_size)
472
473 # reaches beyond screen ?
474 if (dropdown_new_y + dropdown_size.height) > self._screenheight:
475 self.__mac_log('dropdown extends offscreen (screen max y: %s)' % self._screenheight)
476 max_height = self._screenheight - dropdown_new_y - 4
477 self.__mac_log('max dropdown height would be: %s' % max_height)
478 if max_height > ((pw_size.height * 2) + 4):
479 dropdown_size.SetHeight(max_height)
480 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)))
481 self.__mac_log('possible dropdown size: %s' % dropdown_size)
482
483 # now set dimensions
484 self._picklist_dropdown.SetSize(dropdown_size)
485 self._picklist.SetSize(self._picklist_dropdown.GetClientSize())
486 self.__mac_log('pick list size set to: %s' % self._picklist_dropdown.GetSize())
487 if self.__dropdown_needs_relative_position:
488 dropdown_new_x, dropdown_new_y = self._picklist_dropdown.GetParent().ScreenToClientXY(dropdown_new_x, dropdown_new_y)
489 self._picklist_dropdown.MoveXY(dropdown_new_x, dropdown_new_y)
490
491 # select first value
492 self._picklist.Select(0)
493
494 # and show it
495 self._picklist_dropdown.Show(True)
496
497 # dropdown_top_left = self._picklist_dropdown.ClientToScreenXY(0,0)
498 # dropdown_size = self._picklist_dropdown.GetSize()
499 # dropdown_bottom_right = self._picklist_dropdown.ClientToScreenXY(dropdown_size.width, dropdown_size.height)
500 # self.__mac_log('dropdown placement now (on screen): x:%s-%s, y:%s-%s' % (
501 # dropdown_top_left[0],
502 # dropdown_bottom_right[0],
503 # dropdown_top_left[1],
504 # dropdown_bottom_right[1])
505 # )
506 #--------------------------------------------------------
510 #--------------------------------------------------------
512 """Mark the given picklist row as selected."""
513 if old_row_idx is not None:
514 pass # FIXME: do we need unselect here ? Select() should do it for us
515 self._picklist.Select(new_row_idx)
516 self._picklist.EnsureVisible(new_row_idx)
517 #--------------------------------------------------------
519 """Get string to display in the field for the given picklist item."""
520 if item is None:
521 item = self._picklist.get_selected_item()
522 try:
523 return item['field_label']
524 except KeyError:
525 pass
526 try:
527 return item['list_label']
528 except KeyError:
529 pass
530 try:
531 return item['label']
532 except KeyError:
533 return u'<no field_*/list_*/label in item>'
534 #return self._picklist.GetItemText(self._picklist.GetFirstSelected())
535 #--------------------------------------------------------
537 """Update the display to show item strings."""
538 # default to single phrase
539 display_string = self._picklist_item2display_string(item = item)
540 self.suppress_text_update_smarts = True
541 super(cPhraseWheelBase, self).SetValue(display_string)
542 # in single-phrase phrasewheels always set cursor to end of string
543 self.SetInsertionPoint(self.GetLastPosition())
544 return
545 #--------------------------------------------------------
546 # match generation
547 #--------------------------------------------------------
549 raise NotImplementedError('[%s]: fragment extraction not implemented' % self.__class__.__name__)
550 #---------------------------------------------------------
552 """Get candidates matching the currently typed input."""
553
554 # get all currently matching items
555 self._current_match_candidates = []
556 if self.matcher is not None:
557 matched, self._current_match_candidates = self.matcher.getMatches(val)
558 self._picklist.SetItems(self._current_match_candidates)
559
560 # no matches:
561 # - none found (perhaps due to a typo)
562 # - or no matcher available
563 # anyway: spellcheck
564 if len(self._current_match_candidates) == 0:
565 suggestions = self._get_suggestions_from_spell_checker(val)
566 if suggestions is not None:
567 self._current_match_candidates = [
568 {'list_label': suggestion, 'field_label': suggestion, 'data': None}
569 for suggestion in suggestions
570 ]
571 self._picklist.SetItems(self._current_match_candidates)
572 #--------------------------------------------------------
573 # tooltip handling
574 #--------------------------------------------------------
576 # child classes can override this to provide
577 # per data item dynamic tooltips,
578 # by default do not support dynamic tooltip parts:
579 return None
580 #--------------------------------------------------------
582 """Calculate dynamic tooltip part based on data item.
583
584 - called via ._set_data() each time property .data (-> .__data) is set
585 - hence also called the first time data is set
586 - the static tooltip can be set any number of ways before that
587 - only when data is first set does the dynamic part become relevant
588 - hence it is sufficient to remember the static part when .data is
589 set for the first time
590 """
591 if self.__static_tt is None:
592 if self.ToolTip is None:
593 self.__static_tt = u''
594 else:
595 self.__static_tt = self.ToolTip.Tip
596
597 # need to always calculate static part because
598 # the dynamic part can have *become* None, again,
599 # in which case we want to be able to re-set the
600 # tooltip to the static part
601 static_part = self.__static_tt
602 if (self.__static_tt_extra) is not None and (self.__static_tt_extra.strip() != u''):
603 static_part = u'%s\n\n%s' % (
604 static_part,
605 self.__static_tt_extra
606 )
607
608 dynamic_part = self._get_data_tooltip()
609 if dynamic_part is None:
610 self.SetToolTipString(static_part)
611 return
612
613 if static_part == u'':
614 tt = dynamic_part
615 else:
616 if dynamic_part.strip() == u'':
617 tt = static_part
618 else:
619 tt = u'%s\n\n%s\n\n%s' % (
620 dynamic_part,
621 gmTools.u_box_horiz_single * 32,
622 static_part
623 )
624
625 self.SetToolTipString(tt)
626 #--------------------------------------------------------
629
632
633 static_tooltip_extra = property(_get_static_tt_extra, _set_static_tt_extra)
634 #--------------------------------------------------------
635 # event handling
636 #--------------------------------------------------------
638 wx.EVT_KEY_DOWN (self, self._on_key_down)
639 wx.EVT_SET_FOCUS(self, self._on_set_focus)
640 wx.EVT_KILL_FOCUS(self, self._on_lose_focus)
641 wx.EVT_TEXT(self, self.GetId(), self._on_text_update)
642 self._picklist.Bind(wx.EVT_LEFT_DCLICK, self._on_list_item_selected)
643 #--------------------------------------------------------
645 """Is called when a key is pressed."""
646
647 keycode = event.GetKeyCode()
648
649 if keycode == wx.WXK_DOWN:
650 self.__on_cursor_down()
651 return
652
653 if keycode == wx.WXK_UP:
654 self.__on_cursor_up()
655 return
656
657 if keycode == wx.WXK_RETURN:
658 self._on_enter()
659 return
660
661 if keycode == wx.WXK_TAB:
662 if event.ShiftDown():
663 self.Navigate(flags = wx.NavigationKeyEvent.IsBackward)
664 return
665 self.__on_tab()
666 self.Navigate(flags = wx.NavigationKeyEvent.IsForward)
667 return
668
669 # FIXME: need PAGE UP/DOWN//POS1/END here to move in picklist
670 if keycode in [wx.WXK_SHIFT, wx.WXK_BACK, wx.WXK_DELETE, wx.WXK_LEFT, wx.WXK_RIGHT]:
671 pass
672
673 # need to handle all non-character key presses *before* this check
674 elif not self.__char_is_allowed(char = unichr(event.GetUnicodeKey())):
675 wx.Bell()
676 # Richard doesn't show any error message here
677 return
678
679 event.Skip()
680 return
681 #--------------------------------------------------------
683
684 self._has_focus = True
685 event.Skip()
686
687 #self.__non_edit_font = self.GetFont()
688 #edit_font = self.GetFont()
689 edit_font = wx.FontFromNativeInfo(self.__non_edit_font.NativeFontInfo)
690 edit_font.SetPointSize(pointSize = edit_font.GetPointSize() + 1)
691 self.SetFont(edit_font)
692 self.Refresh()
693
694 # notify interested parties
695 for callback in self._on_set_focus_callbacks:
696 callback()
697
698 self.__timer.Start(oneShot = True, milliseconds = self.picklist_delay)
699 return True
700 #--------------------------------------------------------
702 """Do stuff when leaving the control.
703
704 The user has had her say, so don't second guess
705 intentions but do report error conditions.
706 """
707 event.Skip()
708 self._has_focus = False
709 self.__timer.Stop()
710 self._hide_picklist()
711 wx.CallAfter(self.__on_lost_focus)
712 return True
713 #--------------------------------------------------------
715 self.SetSelection(1,1)
716 self.SetFont(self.__non_edit_font)
717 #self.Refresh() # already done in .display_as_valid() below
718
719 is_valid = True
720
721 # the user may have typed a phrase that is an exact match,
722 # however, just typing it won't associate data from the
723 # picklist, so try do that now
724 self._set_data_to_first_match()
725
726 # check value against final_regex if any given
727 if self.__final_regex.match(self.GetValue().strip()) is None:
728 gmDispatcher.send(signal = 'statustext', msg = self.final_regex_error_msg)
729 is_valid = False
730
731 self.display_as_valid(valid = is_valid)
732
733 # notify interested parties
734 for callback in self._on_lose_focus_callbacks:
735 callback()
736 #--------------------------------------------------------
738 """Gets called when user selected a list item."""
739
740 self._hide_picklist()
741
742 item = self._picklist.get_selected_item()
743 # huh ?
744 if item is None:
745 self.display_as_valid(valid = True)
746 return
747
748 self._update_display_from_picked_item(item)
749 self._update_data_from_picked_item(item)
750 self.MarkDirty()
751
752 # and tell the listeners about the user's selection
753 for callback in self._on_selection_callbacks:
754 callback(self._data)
755
756 if self.navigate_after_selection:
757 self.Navigate()
758
759 return
760 #--------------------------------------------------------
762 """Internal handler for wx.EVT_TEXT.
763
764 Called when text was changed by user or by SetValue().
765 """
766 if self.suppress_text_update_smarts:
767 self.suppress_text_update_smarts = False
768 return
769
770 self._adjust_data_after_text_update()
771 self._current_match_candidates = []
772
773 val = self.GetValue().strip()
774 ins_point = self.GetInsertionPoint()
775
776 # if empty string then hide list dropdown window
777 # we also don't need a timer event then
778 if val == u'':
779 self._hide_picklist()
780 self.__timer.Stop()
781 else:
782 new_val = gmTools.capitalize(text = val, mode = self.capitalisation_mode)
783 if new_val != val:
784 self.suppress_text_update_smarts = True
785 super(cPhraseWheelBase, self).SetValue(new_val)
786 if ins_point > len(new_val):
787 self.SetInsertionPointEnd()
788 else:
789 self.SetInsertionPoint(ins_point)
790 # FIXME: SetSelection() ?
791
792 # start timer for delayed match retrieval
793 self.__timer.Start(oneShot = True, milliseconds = self.picklist_delay)
794
795 # notify interested parties
796 for callback in self._on_modified_callbacks:
797 callback()
798
799 return
800 #--------------------------------------------------------
801 # keypress handling
802 #--------------------------------------------------------
804 """Called when the user pressed <ENTER>."""
805 if self._picklist_dropdown.IsShown():
806 self._on_list_item_selected()
807 else:
808 # FIXME: check for errors before navigation
809 self.Navigate()
810 #--------------------------------------------------------
812
813 if self._picklist_dropdown.IsShown():
814 idx_selected = self._picklist.GetFirstSelected()
815 if idx_selected < (len(self._current_match_candidates) - 1):
816 self._select_picklist_row(idx_selected + 1, idx_selected)
817 return
818
819 # if we don't yet have a pick list: open new pick list
820 # (this can happen when we TAB into a field pre-filled
821 # with the top-weighted contextual item but want to
822 # select another contextual item)
823 self.__timer.Stop()
824 if self.GetValue().strip() == u'':
825 val = u'*'
826 else:
827 val = self._extract_fragment_to_match_on()
828 self._update_candidates_in_picklist(val = val)
829 self._show_picklist(input2match = val)
830 #--------------------------------------------------------
832 if self._picklist_dropdown.IsShown():
833 selected = self._picklist.GetFirstSelected()
834 if selected > 0:
835 self._select_picklist_row(selected-1, selected)
836 else:
837 # FIXME: input history ?
838 pass
839 #--------------------------------------------------------
841 """Under certain circumstances take special action on <TAB>.
842
843 returns:
844 True: <TAB> was handled
845 False: <TAB> was not handled
846
847 -> can be used to decide whether to do further <TAB> handling outside this class
848 """
849 # are we seeing the picklist ?
850 if not self._picklist_dropdown.IsShown():
851 return False
852
853 # with only one candidate ?
854 if len(self._current_match_candidates) != 1:
855 return False
856
857 # and do we require the input to be picked from the candidates ?
858 if not self.selection_only:
859 return False
860
861 # then auto-select that item
862 self._select_picklist_row(new_row_idx = 0)
863 self._on_list_item_selected()
864
865 return True
866 #--------------------------------------------------------
867 # timer handling
868 #--------------------------------------------------------
870 self.__timer = _cPRWTimer()
871 self.__timer.callback = self._on_timer_fired
872 # initially stopped
873 self.__timer.Stop()
874 #--------------------------------------------------------
876 """Callback for delayed match retrieval timer.
877
878 if we end up here:
879 - delay has passed without user input
880 - the value in the input field has not changed since the timer started
881 """
882 # update matches according to current input
883 val = self._extract_fragment_to_match_on()
884 self._update_candidates_in_picklist(val = val)
885
886 # we now have either:
887 # - all possible items (within reasonable limits) if input was '*'
888 # - all matching items
889 # - an empty match list if no matches were found
890 # also, our picklist is refilled and sorted according to weight
891 wx.CallAfter(self._show_picklist, input2match = val)
892 #----------------------------------------------------
893 # random helpers and properties
894 #----------------------------------------------------
898 #--------------------------------------------------------
900 # if undefined accept all chars
901 if self.accepted_chars is None:
902 return True
903 return (self.__accepted_chars.match(char) is not None)
904 #--------------------------------------------------------
906 if accepted_chars is None:
907 self.__accepted_chars = None
908 else:
909 self.__accepted_chars = regex.compile(accepted_chars)
910
915
916 accepted_chars = property(_get_accepted_chars, _set_accepted_chars)
917 #--------------------------------------------------------
919 self.__final_regex = regex.compile(final_regex, flags = regex.LOCALE | regex.UNICODE)
920
923
924 final_regex = property(_get_final_regex, _set_final_regex)
925 #--------------------------------------------------------
927 self.__final_regex_error_msg = msg % self.final_regex
928
931
932 final_regex_error_msg = property(_get_final_regex_error_msg, _set_final_regex_error_msg)
933 #--------------------------------------------------------
934 # data munging
935 #--------------------------------------------------------
938 #--------------------------------------------------------
940 self.data = {item['field_label']: item}
941 #--------------------------------------------------------
944 #---------------------------------------------------------
946 raise NotImplementedError('[%s]: cannot adjust data after text update' % self.__class__.__name__)
947 #--------------------------------------------------------
952 #--------------------------------------------------------
955 #--------------------------------------------------------
958
962
963 data = property(_get_data, _set_data)
964
965 #============================================================
966 # FIXME: cols in pick list
967 # FIXME: snap_to_basename+set selection
968 # FIXME: learn() -> PWL
969 # FIXME: up-arrow: show recent (in-memory) history
970 #----------------------------------------------------------
971 # ideas
972 #----------------------------------------------------------
973 #- display possible completion but highlighted for deletion
974 #(- cycle through possible completions)
975 #- pre-fill selection with SELECT ... LIMIT 25
976 #- async threads for match retrieval instead of timer
977 # - on truncated results return item "..." -> selection forcefully retrieves all matches
978
979 #- generators/yield()
980 #- OnChar() - process a char event
981
982 # split input into words and match components against known phrases
983
984 # make special list window:
985 # - deletion of items
986 # - highlight matched parts
987 # - faster scrolling
988 # - wxEditableListBox ?
989
990 # - if non-learning (i.e. fast select only): autocomplete with match
991 # and move cursor to end of match
992 #-----------------------------------------------------------------------------------------------
993 # darn ! this clever hack won't work since we may have crossed a search location threshold
994 #----
995 # #self.__prevFragment = "***********-very-unlikely--------------***************"
996 # #self.__prevMatches = [] # a list of tuples (ID, listbox name, weight)
997 #
998 # # is the current fragment just a longer version of the previous fragment ?
999 # if string.find(aFragment, self.__prevFragment) == 0:
1000 # # we then need to search in the previous matches only
1001 # for prevMatch in self.__prevMatches:
1002 # if string.find(prevMatch[1], aFragment) == 0:
1003 # matches.append(prevMatch)
1004 # # remember current matches
1005 # self.__prefMatches = matches
1006 # # no matches found
1007 # if len(matches) == 0:
1008 # return [(1,_('*no matching items found*'),1)]
1009 # else:
1010 # return matches
1011 #----
1012 #TODO:
1013 # - see spincontrol for list box handling
1014 # stop list (list of negatives): "an" -> "animal" but not "and"
1015 #-----
1016 #> > remember, you should be searching on either weighted data, or in some
1017 #> > situations a start string search on indexed data
1018 #>
1019 #> Can you be a bit more specific on this ?
1020
1021 #seaching ones own previous text entered would usually be instring but
1022 #weighted (ie the phrases you use the most auto filter to the top)
1023
1024 #Searching a drug database for a drug brand name is usually more
1025 #functional if it does a start string search, not an instring search which is
1026 #much slower and usually unecesary. There are many other examples but trust
1027 #me one needs both
1028
1029 # FIXME: support selection-only-or-empty
1030
1031
1032 #============================================================
1034
1036
1037 super(cPhraseWheel, self).GetData(can_create = can_create)
1038
1039 if len(self._data) > 0:
1040 if as_instance:
1041 return self._data2instance()
1042
1043 if len(self._data) == 0:
1044 return None
1045
1046 return self._data.values()[0]['data']
1047 #---------------------------------------------------------
1049 """Set the data and thereby set the value, too. if possible.
1050
1051 If you call SetData() you better be prepared
1052 doing a scan of the entire potential match space.
1053
1054 The whole thing will only work if data is found
1055 in the match space anyways.
1056 """
1057 if data is None:
1058 self._data = {}
1059 return True
1060
1061 # try getting match candidates
1062 self._update_candidates_in_picklist(u'*')
1063
1064 # do we require a match ?
1065 if self.selection_only:
1066 # yes, but we don't have any candidates
1067 if len(self._current_match_candidates) == 0:
1068 return False
1069
1070 # among candidates look for a match with <data>
1071 for candidate in self._current_match_candidates:
1072 if candidate['data'] == data:
1073 super(cPhraseWheel, self).SetText (
1074 value = candidate['field_label'],
1075 data = data,
1076 suppress_smarts = True
1077 )
1078 return True
1079
1080 # no match found in candidates (but needed) ...
1081 if self.selection_only:
1082 self.display_as_valid(valid = False)
1083 return False
1084
1085 self.data = self._dictify_data(data = data)
1086 self.display_as_valid(valid = True)
1087 return True
1088 #--------------------------------------------------------
1089 # internal API
1090 #--------------------------------------------------------
1092
1093 # this helps if the current input was already selected from the
1094 # list but still is the substring of another pick list item or
1095 # else the picklist will re-open just after selection
1096 if len(self._data) > 0:
1097 self._picklist_dropdown.Hide()
1098 return
1099
1100 return super(cPhraseWheel, self)._show_picklist(input2match = input2match)
1101 #--------------------------------------------------------
1103 # data already set ?
1104 if len(self._data) > 0:
1105 return True
1106
1107 # needed ?
1108 val = self.GetValue().strip()
1109 if val == u'':
1110 return True
1111
1112 # so try
1113 self._update_candidates_in_picklist(val = val)
1114 for candidate in self._current_match_candidates:
1115 if candidate['field_label'] == val:
1116 self.data = {candidate['field_label']: candidate}
1117 self.MarkDirty()
1118 # tell listeners about the user's selection
1119 for callback in self._on_selection_callbacks:
1120 callback(self._data)
1121 return True
1122
1123 # no exact match found
1124 if self.selection_only:
1125 gmDispatcher.send(signal = 'statustext', msg = self.selection_only_error_msg)
1126 is_valid = False
1127 return False
1128
1129 return True
1130 #---------------------------------------------------------
1132 self.data = {}
1133 #---------------------------------------------------------
1135 return self.GetValue().strip()
1136 #---------------------------------------------------------
1142 #============================================================
1144
1146
1147 super(cMultiPhraseWheel, self).__init__(*args, **kwargs)
1148
1149 self.phrase_separators = default_phrase_separators
1150 self.left_part = u''
1151 self.right_part = u''
1152 self.speller = None
1153 #---------------------------------------------------------
1155
1156 super(cMultiPhraseWheel, self).GetData(can_create = can_create)
1157
1158 if len(self._data) > 0:
1159 if as_instance:
1160 return self._data2instance()
1161
1162 return self._data.values()
1163 #---------------------------------------------------------
1167 #---------------------------------------------------------
1169
1170 data_dict = {}
1171
1172 for item in data_items:
1173 try:
1174 list_label = item['list_label']
1175 except KeyError:
1176 list_label = item['label']
1177 try:
1178 field_label = item['field_label']
1179 except KeyError:
1180 field_label = list_label
1181 data_dict[field_label] = {'data': item['data'], 'list_label': list_label, 'field_label': field_label}
1182
1183 return data_dict
1184 #---------------------------------------------------------
1185 # internal API
1186 #---------------------------------------------------------
1189 #---------------------------------------------------------
1191 # the textctrl display must already be set properly
1192 new_data = {}
1193 # this way of looping automatically removes stale
1194 # data for labels which are no longer displayed
1195 for displayed_label in self.displayed_strings:
1196 try:
1197 new_data[displayed_label] = self._data[displayed_label]
1198 except KeyError:
1199 # this removes stale data for which there
1200 # is no displayed_label anymore
1201 pass
1202
1203 self.data = new_data
1204 #---------------------------------------------------------
1206
1207 cursor_pos = self.GetInsertionPoint()
1208
1209 entire_input = self.GetValue()
1210 if self.__phrase_separators.search(entire_input) is None:
1211 self.left_part = u''
1212 self.right_part = u''
1213 return self.GetValue().strip()
1214
1215 string_left_of_cursor = entire_input[:cursor_pos]
1216 string_right_of_cursor = entire_input[cursor_pos:]
1217
1218 left_parts = [ lp.strip() for lp in self.__phrase_separators.split(string_left_of_cursor) ]
1219 if len(left_parts) == 0:
1220 self.left_part = u''
1221 else:
1222 self.left_part = u'%s%s ' % (
1223 (u'%s ' % self.__phrase_separators.pattern[0]).join(left_parts[:-1]),
1224 self.__phrase_separators.pattern[0]
1225 )
1226
1227 right_parts = [ rp.strip() for rp in self.__phrase_separators.split(string_right_of_cursor) ]
1228 self.right_part = u'%s %s' % (
1229 self.__phrase_separators.pattern[0],
1230 (u'%s ' % self.__phrase_separators.pattern[0]).join(right_parts[1:])
1231 )
1232
1233 val = (left_parts[-1] + right_parts[0]).strip()
1234 return val
1235 #--------------------------------------------------------
1237 val = (u'%s%s%s' % (
1238 self.left_part,
1239 self._picklist_item2display_string(item = item),
1240 self.right_part
1241 )).lstrip().lstrip(';').strip()
1242 self.suppress_text_update_smarts = True
1243 super(cMultiPhraseWheel, self).SetValue(val)
1244 # find item end and move cursor to that place:
1245 item_end = val.index(item['field_label']) + len(item['field_label'])
1246 self.SetInsertionPoint(item_end)
1247 return
1248 #--------------------------------------------------------
1250
1251 # add item to the data
1252 self._data[item['field_label']] = item
1253
1254 # the textctrl display must already be set properly
1255 field_labels = [ p.strip() for p in self.__phrase_separators.split(self.GetValue().strip()) ]
1256 new_data = {}
1257 # this way of looping automatically removes stale
1258 # data for labels which are no longer displayed
1259 for field_label in field_labels:
1260 try:
1261 new_data[field_label] = self._data[field_label]
1262 except KeyError:
1263 # this removes stale data for which there
1264 # is no displayed_label anymore
1265 pass
1266
1267 self.data = new_data
1268 #---------------------------------------------------------
1270 if type(data) == type([]):
1271 # useful because self.GetData() returns just such a list
1272 return self.list2data_dict(data_items = data)
1273 # else assume new-style already-dictified data
1274 return data
1275 #--------------------------------------------------------
1276 # properties
1277 #--------------------------------------------------------
1279 """Set phrase separators.
1280
1281 - must be a valid regular expression pattern
1282
1283 input is split into phrases at boundaries defined by
1284 this regex and matching is performed on the phrase
1285 the cursor is in only,
1286
1287 after selection from picklist phrase_separators[0] is
1288 added to the end of the match in the PRW
1289 """
1290 self.__phrase_separators = regex.compile(phrase_separators, flags = regex.LOCALE | regex.UNICODE)
1291
1294
1295 phrase_separators = property(_get_phrase_separators, _set_phrase_separators)
1296 #--------------------------------------------------------
1298 return [ p.strip() for p in self.__phrase_separators.split(self.GetValue().strip()) if p.strip() != u'' ]
1299
1300 displayed_strings = property(_get_displayed_strings, lambda x:x)
1301 #============================================================
1302 # main
1303 #------------------------------------------------------------
1304 if __name__ == '__main__':
1305
1306 if len(sys.argv) < 2:
1307 sys.exit()
1308
1309 if sys.argv[1] != u'test':
1310 sys.exit()
1311
1312 from Gnumed.pycommon import gmI18N
1313 gmI18N.activate_locale()
1314 gmI18N.install_domain(domain='gnumed')
1315
1316 from Gnumed.pycommon import gmPG2, gmMatchProvider
1317
1318 prw = None # used for access from display_values_*
1319 #--------------------------------------------------------
1321 print "got focus:"
1322 print "value:", prw.GetValue()
1323 print "data :", prw.GetData()
1324 return True
1325 #--------------------------------------------------------
1327 print "lost focus:"
1328 print "value:", prw.GetValue()
1329 print "data :", prw.GetData()
1330 return True
1331 #--------------------------------------------------------
1333 print "modified:"
1334 print "value:", prw.GetValue()
1335 print "data :", prw.GetData()
1336 return True
1337 #--------------------------------------------------------
1339 print "selected:"
1340 print "value:", prw.GetValue()
1341 print "data :", prw.GetData()
1342 return True
1343 #--------------------------------------------------------
1344 #--------------------------------------------------------
1346 app = wx.PyWidgetTester(size = (200, 50))
1347
1348 items = [ {'data': 1, 'list_label': "Bloggs", 'field_label': "Bloggs", 'weight': 0},
1349 {'data': 2, 'list_label': "Baker", 'field_label': "Baker", 'weight': 0},
1350 {'data': 3, 'list_label': "Jones", 'field_label': "Jones", 'weight': 0},
1351 {'data': 4, 'list_label': "Judson", 'field_label': "Judson", 'weight': 0},
1352 {'data': 5, 'list_label': "Jacobs", 'field_label': "Jacobs", 'weight': 0},
1353 {'data': 6, 'list_label': "Judson-Jacobs", 'field_label': "Judson-Jacobs", 'weight': 0}
1354 ]
1355
1356 mp = gmMatchProvider.cMatchProvider_FixedList(items)
1357 # do NOT treat "-" as a word separator here as there are names like "asa-sismussen"
1358 mp.word_separators = '[ \t=+&:@]+'
1359 global prw
1360 prw = cPhraseWheel(parent = app.frame, id = -1)
1361 prw.matcher = mp
1362 prw.capitalisation_mode = gmTools.CAPS_NAMES
1363 prw.add_callback_on_set_focus(callback=display_values_set_focus)
1364 prw.add_callback_on_modified(callback=display_values_modified)
1365 prw.add_callback_on_lose_focus(callback=display_values_lose_focus)
1366 prw.add_callback_on_selection(callback=display_values_selected)
1367
1368 app.frame.Show(True)
1369 app.MainLoop()
1370
1371 return True
1372 #--------------------------------------------------------
1374 print "Do you want to test the database connected phrase wheel ?"
1375 yes_no = raw_input('y/n: ')
1376 if yes_no != 'y':
1377 return True
1378
1379 gmPG2.get_connection()
1380 query = u"""SELECT code, code || ': ' || _(name), _(name) FROM dem.country WHERE _(name) %(fragment_condition)s"""
1381 mp = gmMatchProvider.cMatchProvider_SQL2(queries = [query])
1382 app = wx.PyWidgetTester(size = (400, 50))
1383 global prw
1384 #prw = cPhraseWheel(parent = app.frame, id = -1)
1385 prw = cMultiPhraseWheel(parent = app.frame, id = -1)
1386 prw.matcher = mp
1387
1388 app.frame.Show(True)
1389 app.MainLoop()
1390
1391 return True
1392 #--------------------------------------------------------
1394 gmPG2.get_connection()
1395 query = u"""
1396 select
1397 pk_identity,
1398 firstnames || ' ' || lastnames || ', ' || to_char(dob, 'YYYY-MM-DD'),
1399 firstnames || ' ' || lastnames
1400 from
1401 dem.v_basic_person
1402 where
1403 firstnames || lastnames %(fragment_condition)s
1404 """
1405 mp = gmMatchProvider.cMatchProvider_SQL2(queries = [query])
1406 app = wx.PyWidgetTester(size = (500, 50))
1407 global prw
1408 prw = cPhraseWheel(parent = app.frame, id = -1)
1409 prw.matcher = mp
1410 prw.selection_only = True
1411
1412 app.frame.Show(True)
1413 app.MainLoop()
1414
1415 return True
1416 #--------------------------------------------------------
1418 app = wx.PyWidgetTester(size = (200, 50))
1419
1420 global prw
1421 prw = cPhraseWheel(parent = app.frame, id = -1)
1422
1423 prw.add_callback_on_set_focus(callback=display_values_set_focus)
1424 prw.add_callback_on_modified(callback=display_values_modified)
1425 prw.add_callback_on_lose_focus(callback=display_values_lose_focus)
1426 prw.add_callback_on_selection(callback=display_values_selected)
1427
1428 prw.enable_default_spellchecker()
1429
1430 app.frame.Show(True)
1431 app.MainLoop()
1432
1433 return True
1434 #--------------------------------------------------------
1435 #test_prw_fixed_list()
1436 #test_prw_sql2()
1437 #test_spell_checking_prw()
1438 test_prw_patients()
1439
1440 #==================================================
1441
| Home | Trees | Indices | Help |
|
|---|
| Generated by Epydoc 3.0.1 on Sat Oct 5 03:56:35 2013 | http://epydoc.sourceforge.net |