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