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