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