| 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 import wx.lib.pubsub
22
23
24 # GNUmed specific
25 if __name__ == '__main__':
26 sys.path.insert(0, '../../')
27 from Gnumed.pycommon import gmTools
28
29
30 import logging
31 _log = logging.getLogger('macosx')
32
33
34 color_prw_invalid = 'pink'
35 color_prw_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
184 except KeyError:
185 kwargs['style'] = wx.TE_PROCESS_TAB
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 #--------------------------------------------------------
243 if valid is True:
244 self.SetBackgroundColour(self.__my_startup_color)
245 elif valid is False:
246 if partially_invalid:
247 self.SetBackgroundColour(color_prw_partially_invalid)
248 else:
249 self.SetBackgroundColour(color_prw_invalid)
250 else:
251 raise ValueError(u'<valid> must be True or False')
252 self.Refresh()
253 #--------------------------------------------------------
255 if disabled is True:
256 self.SetBackgroundColour(wx.SystemSettings_GetColour(wx.SYS_COLOUR_BACKGROUND))
257 elif disabled is False:
258 self.SetBackgroundColour(color_prw_valid)
259 else:
260 raise ValueError(u'<disabled> must be True or False')
261 self.Refresh()
262 #--------------------------------------------------------
263 # callback API
264 #--------------------------------------------------------
266 """Add a callback for invocation when a picklist item is selected.
267
268 The callback will be invoked whenever an item is selected
269 from the picklist. The associated data is passed in as
270 a single parameter. Callbacks must be able to cope with
271 None as the data parameter as that is sent whenever the
272 user changes a previously selected value.
273 """
274 if not callable(callback):
275 raise ValueError('[add_callback_on_selection]: ignoring callback [%s], it is not callable' % callback)
276
277 self._on_selection_callbacks.append(callback)
278 #---------------------------------------------------------
280 """Add a callback for invocation when getting focus."""
281 if not callable(callback):
282 raise ValueError('[add_callback_on_set_focus]: ignoring callback [%s] - not callable' % callback)
283
284 self._on_set_focus_callbacks.append(callback)
285 #---------------------------------------------------------
287 """Add a callback for invocation when losing focus."""
288 if not callable(callback):
289 raise ValueError('[add_callback_on_lose_focus]: ignoring callback [%s] - not callable' % callback)
290
291 self._on_lose_focus_callbacks.append(callback)
292 #---------------------------------------------------------
294 """Add a callback for invocation when the content is modified."""
295 if not callable(callback):
296 raise ValueError('[add_callback_on_modified]: ignoring callback [%s] - not callable' % callback)
297
298 self._on_modified_callbacks.append(callback)
299 #--------------------------------------------------------
300 # match provider proxies
301 #--------------------------------------------------------
305 #---------------------------------------------------------
309 #--------------------------------------------------------
310 # spell-checking
311 #--------------------------------------------------------
313 # FIXME: use Debian's wgerman-medical as "personal" wordlist if available
314 try:
315 import enchant
316 except ImportError:
317 self.speller = None
318 return False
319
320 try:
321 self.speller = enchant.DictWithPWL(None, os.path.expanduser(os.path.join('~', '.gnumed', 'spellcheck', 'wordlist.pwl')))
322 except enchant.DictNotFoundError:
323 self.speller = None
324 return False
325
326 return True
327 #---------------------------------------------------------
329 if self.speller is None:
330 return None
331
332 # get the last word
333 last_word = self.__speller_word_separators.split(val)[-1]
334 if last_word.strip() == u'':
335 return None
336
337 try:
338 suggestions = self.speller.suggest(last_word)
339 except:
340 _log.exception('had to disable (enchant) spell checker')
341 self.speller = None
342 return None
343
344 if len(suggestions) == 0:
345 return None
346
347 input2match_without_last_word = val[:val.rindex(last_word)]
348 return [ input2match_without_last_word + suggestion for suggestion in suggestions ]
349 #--------------------------------------------------------
351 if word_separators is None:
352 self.__speller_word_separators = regex.compile(default_spelling_word_separators, flags = regex.LOCALE | regex.UNICODE)
353 else:
354 self.__speller_word_separators = regex.compile(word_separators, flags = regex.LOCALE | regex.UNICODE)
355
358
359 speller_word_separators = property(_get_speller_word_separators, _set_speller_word_separators)
360 #--------------------------------------------------------
361 # internal API
362 #--------------------------------------------------------
363 # picklist handling
364 #--------------------------------------------------------
366 szr_dropdown = None
367 try:
368 #raise NotImplementedError # uncomment for testing
369 self.__dropdown_needs_relative_position = False
370 self._picklist_dropdown = wx.PopupWindow(parent)
371 list_parent = self._picklist_dropdown
372 self.__use_fake_popup = False
373 except NotImplementedError:
374 self.__use_fake_popup = True
375
376 # on MacOSX wx.PopupWindow is not implemented, so emulate it
377 add_picklist_to_sizer = True
378 szr_dropdown = wx.BoxSizer(wx.VERTICAL)
379
380 # using wx.MiniFrame
381 self.__dropdown_needs_relative_position = False
382 self._picklist_dropdown = wx.MiniFrame (
383 parent = parent,
384 id = -1,
385 style = wx.SIMPLE_BORDER | wx.FRAME_FLOAT_ON_PARENT | wx.FRAME_NO_TASKBAR | wx.POPUP_WINDOW
386 )
387 scroll_win = wx.ScrolledWindow(parent = self._picklist_dropdown, style = wx.NO_BORDER)
388 scroll_win.SetSizer(szr_dropdown)
389 list_parent = scroll_win
390
391 # using wx.Window
392 #self.__dropdown_needs_relative_position = True
393 #self._picklist_dropdown = wx.ScrolledWindow(parent=parent, style = wx.RAISED_BORDER)
394 #self._picklist_dropdown.SetSizer(szr_dropdown)
395 #list_parent = self._picklist_dropdown
396
397 self.__mac_log('dropdown parent: %s' % self._picklist_dropdown.GetParent())
398
399 self._picklist = cPhraseWheelListCtrl (
400 list_parent,
401 style = wx.LC_NO_HEADER
402 )
403 self._picklist.InsertColumn(0, u'')
404
405 if szr_dropdown is not None:
406 szr_dropdown.Add(self._picklist, 1, wx.EXPAND)
407
408 self._picklist_dropdown.Hide()
409 #--------------------------------------------------------
411 """Display the pick list if useful."""
412
413 self._picklist_dropdown.Hide()
414
415 if not self._has_focus:
416 return
417
418 if len(self._current_match_candidates) == 0:
419 return
420
421 # if only one match and text == match: do not show
422 # picklist but rather pick that match
423 if len(self._current_match_candidates) == 1:
424 candidate = self._current_match_candidates[0]
425 if candidate['field_label'] == input2match:
426 self._update_data_from_picked_item(candidate)
427 return
428
429 # recalculate size
430 dropdown_size = self._picklist_dropdown.GetSize()
431 border_width = 4
432 extra_height = 25
433 # height
434 rows = len(self._current_match_candidates)
435 if rows < 2: # 2 rows minimum
436 rows = 2
437 if rows > 20: # 20 rows maximum
438 rows = 20
439 self.__mac_log('dropdown needs rows: %s' % rows)
440 pw_size = self.GetSize()
441 dropdown_size.SetHeight (
442 (pw_size.height * rows)
443 + border_width
444 + extra_height
445 )
446 # width
447 dropdown_size.SetWidth(min (
448 self.Size.width * 2,
449 self.Parent.Size.width
450 ))
451
452 # recalculate position
453 (pw_x_abs, pw_y_abs) = self.ClientToScreenXY(0,0)
454 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)))
455 dropdown_new_x = pw_x_abs
456 dropdown_new_y = pw_y_abs + pw_size.height
457 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)))
458 self.__mac_log('desired dropdown size: %s' % dropdown_size)
459
460 # reaches beyond screen ?
461 if (dropdown_new_y + dropdown_size.height) > self._screenheight:
462 self.__mac_log('dropdown extends offscreen (screen max y: %s)' % self._screenheight)
463 max_height = self._screenheight - dropdown_new_y - 4
464 self.__mac_log('max dropdown height would be: %s' % max_height)
465 if max_height > ((pw_size.height * 2) + 4):
466 dropdown_size.SetHeight(max_height)
467 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)))
468 self.__mac_log('possible dropdown size: %s' % dropdown_size)
469
470 # now set dimensions
471 self._picklist_dropdown.SetSize(dropdown_size)
472 self._picklist.SetSize(self._picklist_dropdown.GetClientSize())
473 self.__mac_log('pick list size set to: %s' % self._picklist_dropdown.GetSize())
474 if self.__dropdown_needs_relative_position:
475 dropdown_new_x, dropdown_new_y = self._picklist_dropdown.GetParent().ScreenToClientXY(dropdown_new_x, dropdown_new_y)
476 self._picklist_dropdown.MoveXY(dropdown_new_x, dropdown_new_y)
477
478 # select first value
479 self._picklist.Select(0)
480
481 # and show it
482 self._picklist_dropdown.Show(True)
483
484 # dropdown_top_left = self._picklist_dropdown.ClientToScreenXY(0,0)
485 # dropdown_size = self._picklist_dropdown.GetSize()
486 # dropdown_bottom_right = self._picklist_dropdown.ClientToScreenXY(dropdown_size.width, dropdown_size.height)
487 # self.__mac_log('dropdown placement now (on screen): x:%s-%s, y:%s-%s' % (
488 # dropdown_top_left[0],
489 # dropdown_bottom_right[0],
490 # dropdown_top_left[1],
491 # dropdown_bottom_right[1])
492 # )
493 #--------------------------------------------------------
497 #--------------------------------------------------------
499 """Mark the given picklist row as selected."""
500 if old_row_idx is not None:
501 pass # FIXME: do we need unselect here ? Select() should do it for us
502 self._picklist.Select(new_row_idx)
503 self._picklist.EnsureVisible(new_row_idx)
504 #--------------------------------------------------------
506 """Get string to display in the field for the given picklist item."""
507 if item is None:
508 item = self._picklist.get_selected_item()
509 try:
510 return item['field_label']
511 except KeyError:
512 pass
513 try:
514 return item['list_label']
515 except KeyError:
516 pass
517 try:
518 return item['label']
519 except KeyError:
520 return u'<no field_*/list_*/label in item>'
521 #return self._picklist.GetItemText(self._picklist.GetFirstSelected())
522 #--------------------------------------------------------
524 """Update the display to show item strings."""
525 # default to single phrase
526 display_string = self._picklist_item2display_string(item = item)
527 self.suppress_text_update_smarts = True
528 super(cPhraseWheelBase, self).SetValue(display_string)
529 # in single-phrase phrasewheels always set cursor to end of string
530 self.SetInsertionPoint(self.GetLastPosition())
531 return
532 #--------------------------------------------------------
533 # match generation
534 #--------------------------------------------------------
536 raise NotImplementedError('[%s]: fragment extraction not implemented' % self.__class__.__name__)
537 #---------------------------------------------------------
539 """Get candidates matching the currently typed input."""
540
541 # get all currently matching items
542 self._current_match_candidates = []
543 if self.matcher is not None:
544 matched, self._current_match_candidates = self.matcher.getMatches(val)
545 self._picklist.SetItems(self._current_match_candidates)
546
547 # no matches:
548 # - none found (perhaps due to a typo)
549 # - or no matcher available
550 # anyway: spellcheck
551 if len(self._current_match_candidates) == 0:
552 suggestions = self._get_suggestions_from_spell_checker(val)
553 if suggestions is not None:
554 self._current_match_candidates = [
555 {'list_label': suggestion, 'field_label': suggestion, 'data': None}
556 for suggestion in suggestions
557 ]
558 self._picklist.SetItems(self._current_match_candidates)
559 #--------------------------------------------------------
560 # tooltip handling
561 #--------------------------------------------------------
565 #--------------------------------------------------------
567 """Calculate dynamic tooltip part based on data item.
568
569 - called via ._set_data() each time property .data (-> .__data) is set
570 - hence also called the first time data is set
571 - the static tooltip can be set any number of ways before that
572 - only when data is first set does the dynamic part become relevant
573 - hence it is sufficient to remember the static part when .data is
574 set for the first time
575 """
576 if self.__static_tt is None:
577 if self.ToolTip is None:
578 self.__static_tt = u''
579 else:
580 self.__static_tt = self.ToolTip.Tip
581
582 # need to always calculate static part because
583 # the dynamic part can have *become* None, again,
584 # in which case we want to be able to re-set the
585 # tooltip to the static part
586 static_part = self.__static_tt
587 if (self.__static_tt_extra) is not None and (self.__static_tt_extra.strip() != u''):
588 static_part = u'%s\n\n%s' % (
589 static_part,
590 self.__static_tt_extra
591 )
592
593 dynamic_part = self._get_data_tooltip()
594 if dynamic_part is None:
595 self.SetToolTipString(static_part)
596 return
597
598 if static_part == u'':
599 tt = dynamic_part
600 else:
601 if dynamic_part.strip() == u'':
602 tt = static_part
603 else:
604 tt = u'%s\n\n%s\n\n%s' % (
605 dynamic_part,
606 gmTools.u_box_horiz_single * 32,
607 static_part
608 )
609
610 self.SetToolTipString(tt)
611 #--------------------------------------------------------
614
617
618 static_tooltip_extra = property(_get_static_tt_extra, _set_static_tt_extra)
619 #--------------------------------------------------------
620 # event handling
621 #--------------------------------------------------------
623 wx.EVT_KEY_DOWN (self, self._on_key_down)
624 wx.EVT_SET_FOCUS(self, self._on_set_focus)
625 wx.EVT_KILL_FOCUS(self, self._on_lose_focus)
626 wx.EVT_TEXT(self, self.GetId(), self._on_text_update)
627 self._picklist.Bind(wx.EVT_LEFT_DCLICK, self._on_list_item_selected)
628 #--------------------------------------------------------
630 """Is called when a key is pressed."""
631
632 keycode = event.GetKeyCode()
633
634 if keycode == wx.WXK_DOWN:
635 self.__on_cursor_down()
636 return
637
638 if keycode == wx.WXK_UP:
639 self.__on_cursor_up()
640 return
641
642 if keycode == wx.WXK_RETURN:
643 self._on_enter()
644 return
645
646 if keycode == wx.WXK_TAB:
647 if event.ShiftDown():
648 self.Navigate(flags = wx.NavigationKeyEvent.IsBackward)
649 return
650 self.__on_tab()
651 self.Navigate(flags = wx.NavigationKeyEvent.IsForward)
652 return
653
654 # FIXME: need PAGE UP/DOWN//POS1/END here to move in picklist
655 if keycode in [wx.WXK_SHIFT, wx.WXK_BACK, wx.WXK_DELETE, wx.WXK_LEFT, wx.WXK_RIGHT]:
656 pass
657
658 # need to handle all non-character key presses *before* this check
659 elif not self.__char_is_allowed(char = unichr(event.GetUnicodeKey())):
660 wx.Bell()
661 # Richard doesn't show any error message here
662 return
663
664 event.Skip()
665 return
666 #--------------------------------------------------------
668
669 self._has_focus = True
670 event.Skip()
671
672 self.__non_edit_font = self.GetFont()
673 edit_font = self.GetFont()
674 edit_font.SetPointSize(pointSize = self.__non_edit_font.GetPointSize() + 1)
675 self.SetFont(edit_font)
676 self.Refresh()
677
678 # notify interested parties
679 for callback in self._on_set_focus_callbacks:
680 callback()
681
682 self.__timer.Start(oneShot = True, milliseconds = self.picklist_delay)
683 return True
684 #--------------------------------------------------------
686 """Do stuff when leaving the control.
687
688 The user has had her say, so don't second guess
689 intentions but do report error conditions.
690 """
691 self._has_focus = False
692
693 self.__timer.Stop()
694 self._hide_picklist()
695 self.SetSelection(1,1)
696 self.SetFont(self.__non_edit_font)
697 self.Refresh()
698
699 is_valid = True
700
701 # the user may have typed a phrase that is an exact match,
702 # however, just typing it won't associate data from the
703 # picklist, so try do that now
704 self._set_data_to_first_match()
705
706 # check value against final_regex if any given
707 if self.__final_regex.match(self.GetValue().strip()) is None:
708 wx.lib.pubsub.Publisher().sendMessage (
709 topic = 'statustext',
710 data = {'msg': self.final_regex_error_msg}
711 )
712 is_valid = False
713
714 self.display_as_valid(valid = is_valid)
715
716 # notify interested parties
717 for callback in self._on_lose_focus_callbacks:
718 callback()
719
720 event.Skip()
721 return True
722 #--------------------------------------------------------
724 """Gets called when user selected a list item."""
725
726 self._hide_picklist()
727
728 item = self._picklist.get_selected_item()
729 # huh ?
730 if item is None:
731 self.display_as_valid(valid = True)
732 return
733
734 self._update_display_from_picked_item(item)
735 self._update_data_from_picked_item(item)
736 self.MarkDirty()
737
738 # and tell the listeners about the user's selection
739 for callback in self._on_selection_callbacks:
740 callback(self._data)
741
742 if self.navigate_after_selection:
743 self.Navigate()
744
745 return
746 #--------------------------------------------------------
748 """Internal handler for wx.EVT_TEXT.
749
750 Called when text was changed by user or by SetValue().
751 """
752 if self.suppress_text_update_smarts:
753 self.suppress_text_update_smarts = False
754 return
755
756 self._adjust_data_after_text_update()
757 self._current_match_candidates = []
758
759 val = self.GetValue().strip()
760 ins_point = self.GetInsertionPoint()
761
762 # if empty string then hide list dropdown window
763 # we also don't need a timer event then
764 if val == u'':
765 self._hide_picklist()
766 self.__timer.Stop()
767 else:
768 new_val = gmTools.capitalize(text = val, mode = self.capitalisation_mode)
769 if new_val != val:
770 self.suppress_text_update_smarts = True
771 super(cPhraseWheelBase, self).SetValue(new_val)
772 if ins_point > len(new_val):
773 self.SetInsertionPointEnd()
774 else:
775 self.SetInsertionPoint(ins_point)
776 # FIXME: SetSelection() ?
777
778 # start timer for delayed match retrieval
779 self.__timer.Start(oneShot = True, milliseconds = self.picklist_delay)
780
781 # notify interested parties
782 for callback in self._on_modified_callbacks:
783 callback()
784
785 return
786 #--------------------------------------------------------
787 # keypress handling
788 #--------------------------------------------------------
790 """Called when the user pressed <ENTER>."""
791 if self._picklist_dropdown.IsShown():
792 self._on_list_item_selected()
793 else:
794 # FIXME: check for errors before navigation
795 self.Navigate()
796 #--------------------------------------------------------
798
799 if self._picklist_dropdown.IsShown():
800 idx_selected = self._picklist.GetFirstSelected()
801 if idx_selected < (len(self._current_match_candidates) - 1):
802 self._select_picklist_row(idx_selected + 1, idx_selected)
803 return
804
805 # if we don't yet have a pick list: open new pick list
806 # (this can happen when we TAB into a field pre-filled
807 # with the top-weighted contextual item but want to
808 # select another contextual item)
809 self.__timer.Stop()
810 if self.GetValue().strip() == u'':
811 val = u'*'
812 else:
813 val = self._extract_fragment_to_match_on()
814 self._update_candidates_in_picklist(val = val)
815 self._show_picklist(input2match = val)
816 #--------------------------------------------------------
818 if self._picklist_dropdown.IsShown():
819 selected = self._picklist.GetFirstSelected()
820 if selected > 0:
821 self._select_picklist_row(selected-1, selected)
822 else:
823 # FIXME: input history ?
824 pass
825 #--------------------------------------------------------
827 """Under certain circumstances take special action on <TAB>.
828
829 returns:
830 True: <TAB> was handled
831 False: <TAB> was not handled
832
833 -> can be used to decide whether to do further <TAB> handling outside this class
834 """
835 # are we seeing the picklist ?
836 if not self._picklist_dropdown.IsShown():
837 return False
838
839 # with only one candidate ?
840 if len(self._current_match_candidates) != 1:
841 return False
842
843 # and do we require the input to be picked from the candidates ?
844 if not self.selection_only:
845 return False
846
847 # then auto-select that item
848 self._select_picklist_row(new_row_idx = 0)
849 self._on_list_item_selected()
850
851 return True
852 #--------------------------------------------------------
853 # timer handling
854 #--------------------------------------------------------
856 self.__timer = _cPRWTimer()
857 self.__timer.callback = self._on_timer_fired
858 # initially stopped
859 self.__timer.Stop()
860 #--------------------------------------------------------
862 """Callback for delayed match retrieval timer.
863
864 if we end up here:
865 - delay has passed without user input
866 - the value in the input field has not changed since the timer started
867 """
868 # update matches according to current input
869 val = self._extract_fragment_to_match_on()
870 self._update_candidates_in_picklist(val = val)
871
872 # we now have either:
873 # - all possible items (within reasonable limits) if input was '*'
874 # - all matching items
875 # - an empty match list if no matches were found
876 # also, our picklist is refilled and sorted according to weight
877 wx.CallAfter(self._show_picklist, input2match = val)
878 #----------------------------------------------------
879 # random helpers and properties
880 #----------------------------------------------------
884 #--------------------------------------------------------
886 # if undefined accept all chars
887 if self.accepted_chars is None:
888 return True
889 return (self.__accepted_chars.match(char) is not None)
890 #--------------------------------------------------------
892 if accepted_chars is None:
893 self.__accepted_chars = None
894 else:
895 self.__accepted_chars = regex.compile(accepted_chars)
896
901
902 accepted_chars = property(_get_accepted_chars, _set_accepted_chars)
903 #--------------------------------------------------------
905 self.__final_regex = regex.compile(final_regex, flags = regex.LOCALE | regex.UNICODE)
906
909
910 final_regex = property(_get_final_regex, _set_final_regex)
911 #--------------------------------------------------------
913 self.__final_regex_error_msg = msg % self.final_regex
914
917
918 final_regex_error_msg = property(_get_final_regex_error_msg, _set_final_regex_error_msg)
919 #--------------------------------------------------------
920 # data munging
921 #--------------------------------------------------------
924 #--------------------------------------------------------
926 self.data = {item['field_label']: item}
927 #--------------------------------------------------------
930 #---------------------------------------------------------
932 raise NotImplementedError('[%s]: cannot adjust data after text update' % self.__class__.__name__)
933 #--------------------------------------------------------
938 #--------------------------------------------------------
941 #--------------------------------------------------------
944
948
949 data = property(_get_data, _set_data)
950
951 #============================================================
952 # FIXME: cols in pick list
953 # FIXME: snap_to_basename+set selection
954 # FIXME: learn() -> PWL
955 # FIXME: up-arrow: show recent (in-memory) history
956 #----------------------------------------------------------
957 # ideas
958 #----------------------------------------------------------
959 #- display possible completion but highlighted for deletion
960 #(- cycle through possible completions)
961 #- pre-fill selection with SELECT ... LIMIT 25
962 #- async threads for match retrieval instead of timer
963 # - on truncated results return item "..." -> selection forcefully retrieves all matches
964
965 #- generators/yield()
966 #- OnChar() - process a char event
967
968 # split input into words and match components against known phrases
969
970 # make special list window:
971 # - deletion of items
972 # - highlight matched parts
973 # - faster scrolling
974 # - wxEditableListBox ?
975
976 # - if non-learning (i.e. fast select only): autocomplete with match
977 # and move cursor to end of match
978 #-----------------------------------------------------------------------------------------------
979 # darn ! this clever hack won't work since we may have crossed a search location threshold
980 #----
981 # #self.__prevFragment = "***********-very-unlikely--------------***************"
982 # #self.__prevMatches = [] # a list of tuples (ID, listbox name, weight)
983 #
984 # # is the current fragment just a longer version of the previous fragment ?
985 # if string.find(aFragment, self.__prevFragment) == 0:
986 # # we then need to search in the previous matches only
987 # for prevMatch in self.__prevMatches:
988 # if string.find(prevMatch[1], aFragment) == 0:
989 # matches.append(prevMatch)
990 # # remember current matches
991 # self.__prefMatches = matches
992 # # no matches found
993 # if len(matches) == 0:
994 # return [(1,_('*no matching items found*'),1)]
995 # else:
996 # return matches
997 #----
998 #TODO:
999 # - see spincontrol for list box handling
1000 # stop list (list of negatives): "an" -> "animal" but not "and"
1001 #-----
1002 #> > remember, you should be searching on either weighted data, or in some
1003 #> > situations a start string search on indexed data
1004 #>
1005 #> Can you be a bit more specific on this ?
1006
1007 #seaching ones own previous text entered would usually be instring but
1008 #weighted (ie the phrases you use the most auto filter to the top)
1009
1010 #Searching a drug database for a drug brand name is usually more
1011 #functional if it does a start string search, not an instring search which is
1012 #much slower and usually unecesary. There are many other examples but trust
1013 #me one needs both
1014
1015 # FIXME: support selection-only-or-empty
1016
1017
1018 #============================================================
1020
1022
1023 super(cPhraseWheel, self).GetData(can_create = can_create)
1024
1025 if len(self._data) > 0:
1026 if as_instance:
1027 return self._data2instance()
1028
1029 if len(self._data) == 0:
1030 return None
1031
1032 return self._data.values()[0]['data']
1033 #---------------------------------------------------------
1035 """Set the data and thereby set the value, too. if possible.
1036
1037 If you call SetData() you better be prepared
1038 doing a scan of the entire potential match space.
1039
1040 The whole thing will only work if data is found
1041 in the match space anyways.
1042 """
1043 # try getting match candidates
1044 self._update_candidates_in_picklist(u'*')
1045
1046 # do we require a match ?
1047 if self.selection_only:
1048 # yes, but we don't have any candidates
1049 if len(self._current_match_candidates) == 0:
1050 return False
1051
1052 # among candidates look for a match with <data>
1053 for candidate in self._current_match_candidates:
1054 if candidate['data'] == data:
1055 super(cPhraseWheel, self).SetText (
1056 value = candidate['field_label'],
1057 data = data,
1058 suppress_smarts = True
1059 )
1060 return True
1061
1062 # no match found in candidates (but needed) ...
1063 if self.selection_only:
1064 self.display_as_valid(valid = False)
1065 return False
1066
1067 self.data = self._dictify_data(data = data)
1068 self.display_as_valid(valid = True)
1069 return True
1070 #--------------------------------------------------------
1071 # internal API
1072 #--------------------------------------------------------
1074
1075 # this helps if the current input was already selected from the
1076 # list but still is the substring of another pick list item or
1077 # else the picklist will re-open just after selection
1078 if len(self._data) > 0:
1079 self._picklist_dropdown.Hide()
1080 return
1081
1082 return super(cPhraseWheel, self)._show_picklist(input2match = input2match)
1083 #--------------------------------------------------------
1085 # data already set ?
1086 if len(self._data) > 0:
1087 return True
1088
1089 # needed ?
1090 val = self.GetValue().strip()
1091 if val == u'':
1092 return True
1093
1094 # so try
1095 self._update_candidates_in_picklist(val = val)
1096 for candidate in self._current_match_candidates:
1097 if candidate['field_label'] == val:
1098 self.data = {candidate['field_label']: candidate}
1099 self.MarkDirty()
1100 return True
1101
1102 # no exact match found
1103 if self.selection_only:
1104 wx.lib.pubsub.Publisher().sendMessage (
1105 topic = 'statustext',
1106 data = {'msg': self.selection_only_error_msg}
1107 )
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 Dec 5 04:00:20 2011 | http://epydoc.sourceforge.net |