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