| 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 return
838
839 # FIXME: check for errors before navigation
840 self.Navigate()
841
842 #--------------------------------------------------------
844
845 if self._picklist_dropdown.IsShown():
846 idx_selected = self._picklist.GetFirstSelected()
847 if idx_selected < (len(self._current_match_candidates) - 1):
848 self._select_picklist_row(idx_selected + 1, idx_selected)
849 return
850
851 # if we don't yet have a pick list: open new pick list
852 # (this can happen when we TAB into a field pre-filled
853 # with the top-weighted contextual item but want to
854 # select another contextual item)
855 self.__timer.Stop()
856 if self.GetValue().strip() == '':
857 val = '*'
858 else:
859 val = self._extract_fragment_to_match_on()
860 self._update_candidates_in_picklist(val = val)
861 self._show_picklist(input2match = val)
862
863 #--------------------------------------------------------
865 if self._picklist_dropdown.IsShown():
866 selected = self._picklist.GetFirstSelected()
867 if selected > 0:
868 self._select_picklist_row(selected-1, selected)
869 #else:
870 # # FIXME: input history ?
871
872 #--------------------------------------------------------
874 """Under certain circumstances take special action on <TAB>.
875
876 returns:
877 True: <TAB> was handled
878 False: <TAB> was not handled
879
880 -> can be used to decide whether to do further <TAB> handling outside this class
881 """
882 # are we seeing the picklist ?
883 if not self._picklist_dropdown.IsShown():
884 return False
885
886 # with only one candidate ?
887 if len(self._current_match_candidates) != 1:
888 return False
889
890 # and do we require the input to be picked from the candidates ?
891 if not self.selection_only:
892 return False
893
894 # then auto-select that item
895 self._select_picklist_row(new_row_idx = 0)
896 self._on_list_item_selected()
897
898 return True
899 #--------------------------------------------------------
900 # timer handling
901 #--------------------------------------------------------
903 self.__timer = _cPRWTimer()
904 self.__timer.callback = self._on_timer_fired
905 # initially stopped
906 self.__timer.Stop()
907 #--------------------------------------------------------
909 """Callback for delayed match retrieval timer.
910
911 if we end up here:
912 - delay has passed without user input
913 - the value in the input field has not changed since the timer started
914 """
915 # update matches according to current input
916 val = self._extract_fragment_to_match_on()
917 self._update_candidates_in_picklist(val = val)
918
919 # we now have either:
920 # - all possible items (within reasonable limits) if input was '*'
921 # - all matching items
922 # - an empty match list if no matches were found
923 # also, our picklist is refilled and sorted according to weight
924 wx.CallAfter(self._show_picklist, input2match = val)
925 #----------------------------------------------------
926 # random helpers and properties
927 #----------------------------------------------------
931
932 #--------------------------------------------------------
934 # if undefined accept all chars
935 if self.accepted_chars is None:
936 return True
937 return (self.__accepted_chars.match(char) is not None)
938
939 #--------------------------------------------------------
941 if accepted_chars is None:
942 self.__accepted_chars = None
943 else:
944 self.__accepted_chars = regex.compile(accepted_chars)
945
950
951 accepted_chars = property(_get_accepted_chars, _set_accepted_chars)
952
953 #--------------------------------------------------------
955 self.__final_regex = regex.compile(final_regex, flags = regex.UNICODE)
956
959
960 final_regex = property(_get_final_regex, _set_final_regex)
961
962 #--------------------------------------------------------
965
968
969 final_regex_error_msg = property(_get_final_regex_error_msg, _set_final_regex_error_msg)
970
971 #--------------------------------------------------------
972 # data munging
973 #--------------------------------------------------------
976 #--------------------------------------------------------
979 #--------------------------------------------------------
982 #---------------------------------------------------------
984 raise NotImplementedError('[%s]: cannot adjust data after text update' % self.__class__.__name__)
985 #--------------------------------------------------------
990 #--------------------------------------------------------
993 #--------------------------------------------------------
996
1000
1001 data = property(_get_data, _set_data)
1002
1003 #============================================================
1004 # FIXME: cols in pick list
1005 # FIXME: snap_to_basename+set selection
1006 # FIXME: learn() -> PWL
1007 # FIXME: up-arrow: show recent (in-memory) history
1008 #----------------------------------------------------------
1009 # ideas
1010 #----------------------------------------------------------
1011 #- display possible completion but highlighted for deletion
1012 #(- cycle through possible completions)
1013 #- pre-fill selection with SELECT ... LIMIT 25
1014 #- async threads for match retrieval instead of timer
1015 # - on truncated results return item "..." -> selection forcefully retrieves all matches
1016
1017 #- generators/yield()
1018 #- OnChar() - process a char event
1019
1020 # split input into words and match components against known phrases
1021
1022 # make special list window:
1023 # - deletion of items
1024 # - highlight matched parts
1025 # - faster scrolling
1026 # - wxEditableListBox ?
1027
1028 # - if non-learning (i.e. fast select only): autocomplete with match
1029 # and move cursor to end of match
1030 #-----------------------------------------------------------------------------------------------
1031 # darn ! this clever hack won't work since we may have crossed a search location threshold
1032 #----
1033 # #self.__prevFragment = "***********-very-unlikely--------------***************"
1034 # #self.__prevMatches = [] # a list of tuples (ID, listbox name, weight)
1035 #
1036 # # is the current fragment just a longer version of the previous fragment ?
1037 # if string.find(aFragment, self.__prevFragment) == 0:
1038 # # we then need to search in the previous matches only
1039 # for prevMatch in self.__prevMatches:
1040 # if string.find(prevMatch[1], aFragment) == 0:
1041 # matches.append(prevMatch)
1042 # # remember current matches
1043 # self.__prefMatches = matches
1044 # # no matches found
1045 # if len(matches) == 0:
1046 # return [(1,_('*no matching items found*'),1)]
1047 # else:
1048 # return matches
1049 #----
1050 #TODO:
1051 # - see spincontrol for list box handling
1052 # stop list (list of negatives): "an" -> "animal" but not "and"
1053 #-----
1054 #> > remember, you should be searching on either weighted data, or in some
1055 #> > situations a start string search on indexed data
1056 #>
1057 #> Can you be a bit more specific on this ?
1058
1059 #seaching ones own previous text entered would usually be instring but
1060 #weighted (ie the phrases you use the most auto filter to the top)
1061
1062 #Searching a drug database for a drug product name is usually more
1063 #functional if it does a start string search, not an instring search which is
1064 #much slower and usually unecesary. There are many other examples but trust
1065 #me one needs both
1066
1067 # FIXME: support selection-only-or-empty
1068
1069
1070 #============================================================
1072
1074
1075 super(cPhraseWheel, self).GetData(can_create = can_create)
1076
1077 if len(self._data) > 0:
1078 if as_instance:
1079 return self._data2instance()
1080
1081 if len(self._data) == 0:
1082 return None
1083
1084 return list(self._data.values())[0]['data']
1085
1086 #---------------------------------------------------------
1088 """Set the data and thereby set the value, too. if possible.
1089
1090 If you call SetData() you better be prepared
1091 doing a scan of the entire potential match space.
1092
1093 The whole thing will only work if data is found
1094 in the match space anyways.
1095 """
1096 if data is None:
1097 self._data = {}
1098 return True
1099
1100 # try getting match candidates
1101 self._update_candidates_in_picklist('*')
1102
1103 # do we require a match ?
1104 if self.selection_only:
1105 # yes, but we don't have any candidates
1106 if len(self._current_match_candidates) == 0:
1107 return False
1108
1109 # among candidates look for a match with <data>
1110 for candidate in self._current_match_candidates:
1111 if candidate['data'] == data:
1112 super(cPhraseWheel, self).SetText (
1113 value = candidate['field_label'],
1114 data = data,
1115 suppress_smarts = True
1116 )
1117 return True
1118
1119 # no match found in candidates (but needed) ...
1120 if self.selection_only:
1121 self.display_as_valid(valid = False)
1122 return False
1123
1124 self.data = self._dictify_data(data = data)
1125 self.display_as_valid(valid = True)
1126 return True
1127
1128 #--------------------------------------------------------
1129 # internal API
1130 #--------------------------------------------------------
1132
1133 # this helps if the current input was already selected from the
1134 # list but still is the substring of another pick list item or
1135 # else the picklist will re-open just after selection
1136 if len(self._data) > 0:
1137 self._picklist_dropdown.Hide()
1138 return
1139
1140 return super(cPhraseWheel, self)._show_picklist(input2match = input2match)
1141
1142 #--------------------------------------------------------
1144 # data already set ?
1145 if len(self._data) > 0:
1146 return True
1147
1148 # needed ?
1149 val = self.GetValue().strip()
1150 if val == '':
1151 return True
1152
1153 # so try
1154 self._update_candidates_in_picklist(val = val)
1155 for candidate in self._current_match_candidates:
1156 if candidate['field_label'] == val:
1157 self._update_data_from_picked_item(candidate)
1158 self.MarkDirty()
1159 # tell listeners about the user's selection
1160 for callback in self._on_selection_callbacks:
1161 callback(self._data)
1162 return True
1163
1164 # no exact match found
1165 if self.selection_only:
1166 gmDispatcher.send(signal = 'statustext', msg = self.selection_only_error_msg)
1167 is_valid = False
1168 return False
1169
1170 return True
1171
1172 #---------------------------------------------------------
1174 self.data = {}
1175
1176 #---------------------------------------------------------
1178 return self.GetValue().strip()
1179
1180 #---------------------------------------------------------
1186
1187 #============================================================
1189
1191
1192 super(cMultiPhraseWheel, self).__init__(*args, **kwargs)
1193
1194 self.phrase_separators = default_phrase_separators
1195 self.left_part = ''
1196 self.right_part = ''
1197 self.speller = None
1198 #---------------------------------------------------------
1200
1201 super(cMultiPhraseWheel, self).GetData(can_create = can_create)
1202
1203 if len(self._data) > 0:
1204 if as_instance:
1205 return self._data2instance()
1206
1207 return list(self._data.values())
1208 #---------------------------------------------------------
1212 #---------------------------------------------------------
1214
1215 data_dict = {}
1216
1217 for item in data_items:
1218 try:
1219 list_label = item['list_label']
1220 except KeyError:
1221 list_label = item['label']
1222 try:
1223 field_label = item['field_label']
1224 except KeyError:
1225 field_label = list_label
1226 data_dict[field_label] = {'data': item['data'], 'list_label': list_label, 'field_label': field_label}
1227
1228 return data_dict
1229 #---------------------------------------------------------
1230 # internal API
1231 #---------------------------------------------------------
1234 #---------------------------------------------------------
1236 # the textctrl display must already be set properly
1237 new_data = {}
1238 # this way of looping automatically removes stale
1239 # data for labels which are no longer displayed
1240 for displayed_label in self.displayed_strings:
1241 try:
1242 new_data[displayed_label] = self._data[displayed_label]
1243 except KeyError:
1244 # this removes stale data for which there
1245 # is no displayed_label anymore
1246 pass
1247
1248 self.data = new_data
1249 #---------------------------------------------------------
1251
1252 cursor_pos = self.GetInsertionPoint()
1253
1254 entire_input = self.GetValue()
1255 if self.__phrase_separators.search(entire_input) is None:
1256 self.left_part = ''
1257 self.right_part = ''
1258 return self.GetValue().strip()
1259
1260 string_left_of_cursor = entire_input[:cursor_pos]
1261 string_right_of_cursor = entire_input[cursor_pos:]
1262
1263 left_parts = [ lp.strip() for lp in self.__phrase_separators.split(string_left_of_cursor) ]
1264 if len(left_parts) == 0:
1265 self.left_part = ''
1266 else:
1267 self.left_part = '%s%s ' % (
1268 ('%s ' % self.__phrase_separators.pattern[0]).join(left_parts[:-1]),
1269 self.__phrase_separators.pattern[0]
1270 )
1271
1272 right_parts = [ rp.strip() for rp in self.__phrase_separators.split(string_right_of_cursor) ]
1273 self.right_part = '%s %s' % (
1274 self.__phrase_separators.pattern[0],
1275 ('%s ' % self.__phrase_separators.pattern[0]).join(right_parts[1:])
1276 )
1277
1278 val = (left_parts[-1] + right_parts[0]).strip()
1279 return val
1280 #--------------------------------------------------------
1282 val = ('%s%s%s' % (
1283 self.left_part,
1284 self._picklist_item2display_string(item = item),
1285 self.right_part
1286 )).lstrip().lstrip(';').strip()
1287 self.suppress_text_update_smarts = True
1288 super(cMultiPhraseWheel, self).SetValue(val)
1289 # find item end and move cursor to that place:
1290 item_end = val.index(item['field_label']) + len(item['field_label'])
1291 self.SetInsertionPoint(item_end)
1292 return
1293 #--------------------------------------------------------
1295
1296 # add item to the data
1297 self._data[item['field_label']] = item
1298
1299 # the textctrl display must already be set properly
1300 field_labels = [ p.strip() for p in self.__phrase_separators.split(self.GetValue().strip()) ]
1301 new_data = {}
1302 # this way of looping automatically removes stale
1303 # data for labels which are no longer displayed
1304 for field_label in field_labels:
1305 try:
1306 new_data[field_label] = self._data[field_label]
1307 except KeyError:
1308 # this removes stale data for which there
1309 # is no displayed_label anymore
1310 pass
1311
1312 self.data = new_data
1313 #---------------------------------------------------------
1315 if type(data) == type([]):
1316 # useful because self.GetData() returns just such a list
1317 return self.list2data_dict(data_items = data)
1318 # else assume new-style already-dictified data
1319 return data
1320 #--------------------------------------------------------
1321 # properties
1322 #--------------------------------------------------------
1324 """Set phrase separators.
1325
1326 - must be a valid regular expression pattern
1327
1328 input is split into phrases at boundaries defined by
1329 this regex and matching is performed on the phrase
1330 the cursor is in only,
1331
1332 after selection from picklist phrase_separators[0] is
1333 added to the end of the match in the PRW
1334 """
1335 self.__phrase_separators = regex.compile(phrase_separators, flags = regex.UNICODE)
1336
1339
1340 phrase_separators = property(_get_phrase_separators, _set_phrase_separators)
1341 #--------------------------------------------------------
1343 return [ p.strip() for p in self.__phrase_separators.split(self.GetValue().strip()) if p.strip() != '' ]
1344
1345 displayed_strings = property(_get_displayed_strings, lambda x:x)
1346 #============================================================
1347 # main
1348 #------------------------------------------------------------
1349 if __name__ == '__main__':
1350
1351 if len(sys.argv) < 2:
1352 sys.exit()
1353
1354 if sys.argv[1] != 'test':
1355 sys.exit()
1356
1357 from Gnumed.pycommon import gmI18N
1358 gmI18N.activate_locale()
1359 gmI18N.install_domain(domain='gnumed')
1360
1361 from Gnumed.pycommon import gmPG2, gmMatchProvider
1362
1363 prw = None # used for access from display_values_*
1364 #--------------------------------------------------------
1366 print("got focus:")
1367 print("value:", prw.GetValue())
1368 print("data :", prw.GetData())
1369 return True
1370 #--------------------------------------------------------
1372 print("lost focus:")
1373 print("value:", prw.GetValue())
1374 print("data :", prw.GetData())
1375 return True
1376 #--------------------------------------------------------
1378 print("modified:")
1379 print("value:", prw.GetValue())
1380 print("data :", prw.GetData())
1381 return True
1382 #--------------------------------------------------------
1384 print("selected:")
1385 print("value:", prw.GetValue())
1386 print("data :", prw.GetData())
1387 return True
1388 #--------------------------------------------------------
1389 #--------------------------------------------------------
1391 app = wx.PyWidgetTester(size = (200, 50))
1392
1393 items = [ {'data': 1, 'list_label': "Bloggs", 'field_label': "Bloggs", 'weight': 0},
1394 {'data': 2, 'list_label': "Baker", 'field_label': "Baker", 'weight': 0},
1395 {'data': 3, 'list_label': "Jones", 'field_label': "Jones", 'weight': 0},
1396 {'data': 4, 'list_label': "Judson", 'field_label': "Judson", 'weight': 0},
1397 {'data': 5, 'list_label': "Jacobs", 'field_label': "Jacobs", 'weight': 0},
1398 {'data': 6, 'list_label': "Judson-Jacobs", 'field_label': "Judson-Jacobs", 'weight': 0}
1399 ]
1400
1401 mp = gmMatchProvider.cMatchProvider_FixedList(items)
1402 # do NOT treat "-" as a word separator here as there are names like "asa-sismussen"
1403 mp.word_separators = '[ \t=+&:@]+'
1404 global prw
1405 prw = cPhraseWheel(app.frame, -1)
1406 prw.matcher = mp
1407 prw.capitalisation_mode = gmTools.CAPS_NAMES
1408 prw.add_callback_on_set_focus(callback=display_values_set_focus)
1409 prw.add_callback_on_modified(callback=display_values_modified)
1410 prw.add_callback_on_lose_focus(callback=display_values_lose_focus)
1411 prw.add_callback_on_selection(callback=display_values_selected)
1412
1413 app.frame.Show(True)
1414 app.MainLoop()
1415
1416 return True
1417 #--------------------------------------------------------
1419 print("Do you want to test the database connected phrase wheel ?")
1420 yes_no = input('y/n: ')
1421 if yes_no != 'y':
1422 return True
1423
1424 gmPG2.get_connection()
1425 query = """SELECT code, code || ': ' || _(name), _(name) FROM dem.country WHERE _(name) %(fragment_condition)s"""
1426 mp = gmMatchProvider.cMatchProvider_SQL2(queries = [query])
1427 app = wx.PyWidgetTester(size = (400, 50))
1428 global prw
1429 #prw = cPhraseWheel(app.frame, -1)
1430 prw = cMultiPhraseWheel(app.frame, -1)
1431 prw.matcher = mp
1432
1433 app.frame.Show(True)
1434 app.MainLoop()
1435
1436 return True
1437 #--------------------------------------------------------
1439 gmPG2.get_connection()
1440 query = """
1441 select
1442 pk_identity,
1443 firstnames || ' ' || lastnames || ', ' || to_char(dob, 'YYYY-MM-DD'),
1444 firstnames || ' ' || lastnames
1445 from
1446 dem.v_active_persons
1447 where
1448 firstnames || lastnames %(fragment_condition)s
1449 """
1450 mp = gmMatchProvider.cMatchProvider_SQL2(queries = [query])
1451 app = wx.PyWidgetTester(size = (500, 50))
1452 global prw
1453 prw = cPhraseWheel(app.frame, -1)
1454 prw.matcher = mp
1455 prw.selection_only = True
1456
1457 app.frame.Show(True)
1458 app.MainLoop()
1459
1460 return True
1461 #--------------------------------------------------------
1463 app = wx.PyWidgetTester(size = (200, 50))
1464
1465 global prw
1466 prw = cPhraseWheel(app.frame, -1)
1467
1468 prw.add_callback_on_set_focus(callback=display_values_set_focus)
1469 prw.add_callback_on_modified(callback=display_values_modified)
1470 prw.add_callback_on_lose_focus(callback=display_values_lose_focus)
1471 prw.add_callback_on_selection(callback=display_values_selected)
1472
1473 prw.enable_default_spellchecker()
1474
1475 app.frame.Show(True)
1476 app.MainLoop()
1477
1478 return True
1479 #--------------------------------------------------------
1480 #test_prw_fixed_list()
1481 #test_prw_sql2()
1482 #test_spell_checking_prw()
1483 test_prw_patients()
1484
1485 #==================================================
1486
| Home | Trees | Indices | Help |
|
|---|
| Generated by Epydoc 3.0.1 on Fri Sep 13 01:55:28 2019 | http://epydoc.sourceforge.net |