Source code for fsleyes_widgets.autotextctrl

#!/usr/bin/env python
#
# autotextctrl.py - The AutoTextCtrl class.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`AutoTextCtrl` class, an alternative to the
``wx.TextCtrl``, which has auto-completion capability.

I wrote this class because ``wx.TextCtrl`` auto-completion does not work under
OSX, and the ``wx.ComboBox`` does not give me enough fine-grained control with
respect to managing focus.
"""


import logging

import wx
import wx.lib.newevent as wxevent

import fsleyes_widgets.utils as wutils


log = logging.getLogger(__name__)


[docs] class AutoTextCtrl(wx.Panel): """The ``AutoTextCtrl`` class is essentially a ``wx.TextCtrl`` which is able to dynamically show a list of options to the user, with a :class:`AutoCompletePopup`. """ def __init__(self, parent, style=0, modal=True): """Create an ``AutoTextCtrl``. Supported style flags are: - :data:`ATC_CASE_SENSITIVE`: restrict the auto-completion options to case sensitive matches. - :data:`ATC_NO_PROPAGATE_ENTER`: Cause enter events on the :class:`AutoCompletePopup` to *not* be propagated upwards as ``EVT_ATC_TEXT_ENTER`` events. :arg parent: The ``wx`` parent object. :arg style: Style flags. :arg modal: If ``True`` (the default), the :class:`AutoCompletePopup` is shoown modally. This option is primarily for testing purposes. """ wx.Panel.__init__(self, parent) self.__style = style self.__modal = modal self.__popup = None self.__textCtrl = wx.TextCtrl(self, style=wx.TE_PROCESS_ENTER) self.__sizer = wx.BoxSizer(wx.HORIZONTAL) self.__sizer.Add(self.__textCtrl, flag=wx.EXPAND, proportion=1) self.SetSizer(self.__sizer) # The takeFocus flag is set by SetTakeFocus, # and used in __showPopup. The options array # contains the auto complete options. self.__takeFocus = False self.__options = [] self.__textCtrl.Bind(wx.EVT_TEXT, self.__onText) self.__textCtrl.Bind(wx.EVT_LEFT_DCLICK, self.__onDoubleClick) self.__textCtrl.Bind(wx.EVT_TEXT_ENTER, self.__onEnter) self.__textCtrl.Bind(wx.EVT_KEY_DOWN, self.__onKeyDown) self.__textCtrl.Bind(wx.EVT_SET_FOCUS, self.__onSetFocus) self .Bind(wx.EVT_SET_FOCUS, self.__onSetFocus) def __onSetFocus(self, ev): """Called when this ``AutoTextCtrl`` or any of its children gains focus. Makes sure that the text control insertion point is at the end of its current contents. """ ev.Skip() log.debug('Text control gained focus: {}'.format( wx.Window.FindFocus())) # Under wx/GTK, when a text control gains focus, # it seems to select its entire contents, meaning # that when the user types something, the current # contents are replaced with the new contents. To # prevent this, here we make sure that no text is # selected, and the insertion point is at the end # of the current contents. text = self.__textCtrl.GetValue() self.__textCtrl.SetSelection(len(text) - 1, len(text) - 1) self.__textCtrl.SetInsertionPointEnd() @property def textCtrl(self): """Returns a reference to the internal ``wx.TextCtrl``. """ return self.__textCtrl @property def popup(self): """Returns a reference to the ``AutoCompletePopup`` or ``None`` if it is not currently shown. """ return self.__popup
[docs] def AutoComplete(self, options): """Set the list of options to be shown to the user. """ self.__options = list(options)
[docs] def GetValue(self): """Returns the current value shown on this ``AutoTextCtrl``. """ return self.__textCtrl.GetValue()
[docs] def SetValue(self, value): """Sets the current value shown on this ``AutoTextCtrl``. .. note:: Calling this method will result in an ``wx.EVT_TEXT`` event being generated - use :meth:`ChangeValue` if you do not want this to occur. """ self.__textCtrl.SetValue(value)
[docs] def ChangeValue(self, value): """Sets the current value shown on this ``AutoTextCtrl``. """ self.__textCtrl.ChangeValue(value)
[docs] def GetInsertionPoint(self): """Returns the cursor location in this ``AutoTextCtrl``. """ return self.__textCtrl.GetInsertionPoint()
[docs] def SetInsertionPoint(self, idx): """Sets the cursor location in this ``AutoTextCtrl``. """ self.__textCtrl.SetInsertionPoint(idx)
[docs] def GenEnterEvent(self): """Programmatically generates an :data:`EVT_ATC_TEXT_ENTER` event. """ self.__onEnter(None)
[docs] def SetTakeFocus(self, takeFocus): """If ``takeFocus`` is ``True``, this ``AutoTextCtrl`` will give itself focus when its ``AutoCompletePopup`` is closed. """ self.__takeFocus = takeFocus
def __onKeyDown(self, ev): """Called on ``EVT_KEY_DOWN`` events in the text control. """ enter = wx.WXK_RETURN key = ev.GetKeyCode() log.debug('Key event on text control: {}'.format(key)) # Make sure the event is propagated # up the window hierarchy, if we skip it ev.ResumePropagation(wx.EVENT_PROPAGATE_MAX) if key != enter: ev.Skip() return if self.GetValue() == '': log.debug('Enter/right arrow - displaying all options') self.__showPopup('') # Let the text control handle the event normally else: ev.Skip() def __onDoubleClick(self, ev): """Called when the user double clicks in this ``AutoTextCtrl``. Creates an :class:`AutoCompletePopup`. """ log.debug('Double click on text control - simulating text entry') self.__onText(None) def __onText(self, ev): """Called when the user changes the text shown on this ``AutoTextCtrl``. Creates an :class:`AutoCompletePopup`. """ text = self.__textCtrl.GetValue() log.debug('Text - displaying options matching "{}"'.format(text)) self.__showPopup(text) def __onEnter(self, ev): """Called when the user presses enter in this ``AutoTextCtrl``. Generates an :data:`EVT_ATC_TEXT_ENTER` event. """ value = self.__textCtrl.GetValue() ev = AutoTextCtrlEnterEvent(text=value) log.debug('Enter - generating ATC enter ' 'event (text: "{}")'.format(value)) wx.PostEvent(self, ev) def __showPopup(self, text): """Creates an :class:`AutoCompletePopup` which displays a list of auto-completion options, matching the given prefix text, to the user. The popup is not displayed if there are no options with the given prefix. """ text = text.strip() popup = AutoCompletePopup( self, self, text, self.__options, self.__style) if popup.GetCount() == 0: popup.Destroy() return # Don't take focus unless the AutoCompletePopup # tells us to (it will call the SetTakeFocus method) self.__takeFocus = False # Make sure we get the focus back # when the popup is destroyed def refocus(ev): self.__popup = None # A call to Raise is required under # GTK, as otherwise the main window # won't be given focus. if wx.Platform == '__WXGTK__': self.GetTopLevelParent().Raise() if self.__takeFocus: self.__textCtrl.SetFocus() popup.Bind(EVT_ATC_POPUP_DESTROY, refocus) # The popup has its own textctrl - we # position the popup so that its textctrl # is displayed on top of our textctrl, # with the option list underneath. posx, posy = self.__textCtrl.GetScreenPosition().Get() self.__popup = popup popup.SetSize((-1, -1)) popup.SetPosition((posx, posy)) if self.__modal: popup.ShowModal() else: popup.Show()
ATC_CASE_SENSITIVE = 1 """Syle flag for use with the :class:`AutoTextCtrl` class. If set, the auto-completion pattern matching will be case sensitive. """ ATC_NO_PROPAGATE_ENTER = 2 """Syle flag for use with the :class:`AutoTextCtrl` class. If set, enter events which occur on the :class:`AutoCompletePopup` list will *not* be propagated as :attr:`EVT_ATC_TEXT_ENTER` events. """ _AutoTextCtrlEnterEvent, _EVT_ATC_TEXT_ENTER = wxevent.NewEvent() EVT_ATC_TEXT_ENTER = _EVT_ATC_TEXT_ENTER """Identifier for the :data:`AutoTextCtrlEnterEvent`, which is generated when the user presses enter in an :class:`AutoTextCtrl`. """ AutoTextCtrlEnterEvent = _AutoTextCtrlEnterEvent """Event generated when the user presses enter in an :class:`AutoTextCtrl`. Contains a single attribute, ``text``, which contains the text in the ``AutoTextCtrl``. """
[docs] class AutoCompletePopup(wx.Dialog): """The ``AutoCompletePopup`` class is used by the :class:`AutoTextCtrl` to display a list of completion options to the user. """ def __init__(self, parent, atc, text, options, style=0): """Create an ``AutoCompletePopup``. Accepts the same style flags as the :class:`AutoTextCtrl`. :arg parent: The ``wx`` parent object. :arg atc: The :class:`AutoTextCtrl` that is using this popup. :arg text: Initial text value. :arg options: A list of all possible auto-completion options. :arg style: Style flags. """ wx.Dialog.__init__(self, parent, style=(wx.NO_BORDER | wx.STAY_ON_TOP)) self.__alive = True self.__caseSensitive = style & ATC_CASE_SENSITIVE self.__propagateEnter = not (style & ATC_NO_PROPAGATE_ENTER) self.__atc = atc self.__options = options self.__textCtrl = wx.TextCtrl(self, value=text, style=wx.TE_PROCESS_ENTER) self.__listBox = wx.ListBox( self, style=(wx.LB_SINGLE)) self.__listBox.Set(self.__getMatches(text)) self.__sizer = wx.BoxSizer(wx.VERTICAL) self.__sizer.Add(self.__textCtrl, flag=wx.EXPAND) self.__sizer.Add(self.__listBox, flag=wx.EXPAND, proportion=1) self.SetSizer(self.__sizer) self.__textCtrl.SetMinSize(parent.GetSize()) self.__textCtrl.SetFont(parent.GetFont()) self.__listBox .SetFont(parent.GetFont()) self.Layout() self.Fit() self.__textCtrl.Bind(wx.EVT_TEXT, self.__onText) self.__textCtrl.Bind(wx.EVT_TEXT_ENTER, self.__onEnter) self.__textCtrl.Bind(wx.EVT_KEY_DOWN, self.__onKeyDown) self.__textCtrl.Bind(wx.EVT_CHAR_HOOK, self.__onKeyDown) self.__listBox .Bind(wx.EVT_KEY_DOWN, self.__onListKeyDown) self.__listBox .Bind(wx.EVT_CHAR_HOOK, self.__onListKeyDown) self.__listBox .Bind(wx.EVT_LISTBOX_DCLICK, self.__onListMouseDblClick) # Under GTK, the SetFocus/KillFocus event # objects often don't have a reference to # the window that received/is about to # receive focus. In particular, if the # list box is clicked, a killFocus event # is triggered, but the list box is not # passed in. So on mouse down events, we # force the list box to have focus. if wx.Platform == '__WXGTK__': self.__listBox .Bind(wx.EVT_LEFT_DOWN, self.__onListMouseDown) self.__listBox .Bind(wx.EVT_RIGHT_DOWN, self.__onListMouseDown) self .Bind(wx.EVT_KILL_FOCUS, self.__onKillFocus) self.__textCtrl.Bind(wx.EVT_KILL_FOCUS, self.__onKillFocus) self.__listBox .Bind(wx.EVT_KILL_FOCUS, self.__onKillFocus) self .Bind(wx.EVT_SET_FOCUS, self.__onSetFocus) self.__textCtrl.Bind(wx.EVT_SET_FOCUS, self.__onSetFocus) self.__listBox .Bind(wx.EVT_SET_FOCUS, self.__onSetFocus)
[docs] def GetCount(self): """Returns the number of auto-completion options currently available. """ return self.__listBox.GetCount()
@property def textCtrl(self): """Returns a reference to the ``wx.TextCtrl``.""" return self.__textCtrl @property def listBox(self): """Returns a reference to the ``wx.ListBox``.""" return self.__listBox def __onSetFocus(self, ev): """Called when this ``AutoCompletePopup`` or any of its children gains focus. Makes sure that the text control insertion point is at the end of its current contents. """ ev.Skip() log.debug('Popup gained focus: {}'.format(ev.GetWindow())) # See note in AutoTextCtrl.__onSetFocus text = self.__textCtrl.GetValue() self.__textCtrl.SetSelection(len(text) - 1, len(text) - 1) self.__textCtrl.SetInsertionPointEnd() def __onKillFocus(self, ev): """Called when this ``AutoCompletePopup`` loses focus. Calls :meth:`__destroy`. """ ev.Skip() focused = ev.GetWindow() log.debug('Kill focus event on popup: {}'.format(focused)) objs = (self, self.__textCtrl, self.__listBox) if focused not in objs: log.debug('Focus lost - destroying popup') self.__destroy(False, False) def __destroy(self, genEnter=True, returnFocus=True): """Called by various event handlers. Copies the current value in this ``AutoCompletePopup`` to the owning :class:`AutoTextCtrl`, and then (asynchronously) destroys this ``AutoCompletePopup``. """ # destroy has already been # called - don't run again if not self.__alive: return self.__alive = False genEnter = genEnter and self.__propagateEnter value = self.__textCtrl.GetValue() idx = self.__textCtrl.GetInsertionPoint() atc = self.__atc # Under wx/GTK, we might still receive focus # events, which will trigger another call to # __destroy. So we remove all callbacks to # prevent this from happening. self.__textCtrl.Bind(wx.EVT_TEXT, None) self.__textCtrl.Bind(wx.EVT_TEXT_ENTER, None) self.__textCtrl.Bind(wx.EVT_CHAR_HOOK, None) self.__textCtrl.Bind(wx.EVT_KEY_DOWN, None) self.__listBox .Bind(wx.EVT_CHAR_HOOK, None) self.__listBox .Bind(wx.EVT_KEY_DOWN, None) self.__listBox .Bind(wx.EVT_LISTBOX_DCLICK, None) self.__listBox .Bind(wx.EVT_LEFT_DOWN, None) self.__listBox .Bind(wx.EVT_RIGHT_DOWN, None) self .Bind(wx.EVT_SET_FOCUS, None) self.__textCtrl.Bind(wx.EVT_SET_FOCUS, None) self.__listBox .Bind(wx.EVT_SET_FOCUS, None) self .Bind(wx.EVT_KILL_FOCUS, None) self.__textCtrl.Bind(wx.EVT_KILL_FOCUS, None) self.__listBox .Bind(wx.EVT_KILL_FOCUS, None) atc.ChangeValue( value) atc.SetInsertionPoint(idx) # Tell the atc whether or not it # should take the focus when this # popup is destroyed. atc.SetTakeFocus(returnFocus) if genEnter: atc.GenEnterEvent() def destroy(): if not wutils.isalive(self): return if self.IsModal(): self.EndModal(wx.ID_OK) else: self.Close() self.Destroy() ev = ATCPopupDestroyEvent() ev.SetEventObject(self) wx.PostEvent(self, ev) wx.CallAfter(destroy) def __getMatches(self, prefix): """Returns a list of auto-completion options which match the given prefix. """ prefix = prefix.strip() options = self.__options if not self.__caseSensitive: prefix = prefix.lower() options = [o.lower() for o in options] matches = [o.startswith(prefix) for o in options] return [o for o, m in zip(self.__options, matches) if m] def __onKeyDown(self, ev): """Called on an ``EVT_KEY_DOWN`` event from the text control. """ up = wx.WXK_UP down = wx.WXK_DOWN esc = wx.WXK_ESCAPE enter = wx.WXK_RETURN key = ev.GetKeyCode() log.debug('Key down event on popup text control: {}'.format(key)) if key not in (up, down, enter, esc): ev.ResumePropagation(wx.EVENT_PROPAGATE_MAX) ev.Skip() return # Absorb the up arrow if key == up: return # The user hitting enter/escape will result # in this popup being destroyed if key in (esc, enter): log.debug('Enter/escape on popup text ' 'control - destroying popup') self.__destroy(key == enter) return # If the user hits the down # arrow, focus the listbox self.__listBox.SetFocus() self.__listBox.SetSelection(0) def __onText(self, ev): """Called on an ``EVT_TEXT`` event from the text control.""" text = self.__textCtrl.GetValue().strip() matches = self.__getMatches(text) if text == '' or len(matches) == 0: log.debug('Text on popup text control ("{}") - ' 'no matches, destroying popup'.format(text)) self.__destroy(False) else: log.debug('Text on popup text control ("{}") - ' 'displaying {} matches'.format(text, len(matches))) self.__listBox.Set(matches) def __onEnter(self, ev): """Called on an ``EVT_TEXT_ENTER`` event from the text control.""" log.debug('Enter on popup text control - destroying popup') self.__destroy() def __onListKeyDown(self, ev): """Called on an ``EVT_KEY_DOWN`` event from the list box. """ key = ev.GetKeyCode() enter = wx.WXK_RETURN esc = wx.WXK_ESCAPE backspace = wx.WXK_BACK delete = wx.WXK_DELETE up = wx.WXK_UP log.debug('Key event on popup list box: {}'.format(key)) if key not in (enter, esc, up, backspace, delete): ev.Skip() return sel = self.__listBox.GetSelection() val = self.__listBox.GetString(sel) # If the user pushed the up arrow, # and we're at the top of the list, # give the focus to the text control if key == up: if sel == 0: log.debug('Up arrow on popup list box - ' 'shifting focus to text control') self.__textCtrl.SetFocus() self.__textCtrl.SetInsertionPointEnd() else: ev.Skip() return # If the user pushed enter, copy # the current list selection to # the text control. if key == enter: log.debug('Enter on popup list box ("{}") - destroying ' 'popup (and submitting value)'.format(val)) self.__textCtrl.ChangeValue(val) self.__textCtrl.SetInsertionPointEnd() genEnter = True elif key in (esc, backspace, delete): log.debug('Escape on popup list box ("{}") ' '- destroying popup'.format(val)) genEnter = False # The user hitting enter or escape # will result in this popup being # destroyed self.__destroy(genEnter) def __onListMouseDown(self, ev): """Called on GTK when the user clicks in the list box. Forces the list box to have focus. """ ev.Skip() self.__listBox.SetFocus() def __onListMouseDblClick(self, ev): """Called when the user double clicks an item in the list box. """ ev.Skip() sel = self.__listBox.GetSelection() val = self.__listBox.GetString(sel) log.debug('Double click on popup list box ("{}") - ' 'destroying popup (and submitting value)'.format(val)) self.__textCtrl.ChangeValue(val) self.__textCtrl.SetInsertionPointEnd() self.__destroy()
_ATCPopupDestroyEvent, _EVT_ATC_POPUP_DESTROY = wxevent.NewEvent() EVT_ATC_POPUP_DESTROY = _EVT_ATC_POPUP_DESTROY """Identifier for the :class:`ATCPopupDestroyEvent`. """ ATCPopupDestroyEvent = _ATCPopupDestroyEvent """Event emitted when the :class:`AutoCompletePopup` is destroyed. This event is emitted because the ``wx.EVT_WINDOW_DESTROY`` is too unreliable. """