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