Source code for fsleyes_widgets.texttag

#!/usr/bin/env python
#
# tagtext.py - The StaticTextTag and TextTagPanel classes.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides two classes:

.. autosummary::
   :nosignatures:

   StaticTextTag
   TextTagPanel


.. image:: images/texttagpanel.png
   :scale: 50%
   :align: center
"""


import colorsys
import logging
import random

import wx
import wx.lib.newevent   as wxevent
import numpy             as np
import matplotlib.colors as mplcolors

import fsleyes_widgets.autotextctrl as atc


log = logging.getLogger(__name__)


[docs] def complementary_colour(rgb): """Given a RGB colour, estimates a colour which complements it. Used by the :class:`StaticTextTag` class to generate a foreground (text) colour for a specific background colour. Taken from the FSLeyes source code (fsleyes.colourmaps.complementaryColour) """ # if matplotlib doesn't recognise the colour, # assume it is a sequence of numbers in the # range [0, 255], and convert it to a sequence # in the range [0, 1] if not mplcolors.is_color_like(rgb): rgb = np.array(rgb) / 255 else: rgb = mplcolors.to_rgb(rgb) h, l, s = colorsys.rgb_to_hls(*(rgb[:3])) nh = 1.0 - h nl = 1.0 - l ns = s if abs(nl - l) < 0.3: if l > 0.5: nl = 0.0 else: nl = 1.0 nr, ng, nb = np.clip(colorsys.hls_to_rgb(nh, nl, ns), 0, 1) return mplcolors.to_hex((nr, ng, nb))
[docs] class StaticTextTag(wx.Panel): """The ``StaticTextTag`` class is a ``wx.Panel`` which contains a ``StaticText`` control, and a *close* button. The displayed text and background colour are configurable. When the close button is pushed, an :data:`EVT_STT_CLOSE` is generated. """ def __init__(self, parent, text=None, bgColour='#aaaaaa', borderColour='#ffcdcd'): """Create a ``StaticTextTag``. :arg parent: The :mod:`wx` parent object. :arg text: Initial text to display. :arg bgColour: Initial background colour. :arg borderColour: Initial border colour. """ self.__bgColour = None self.__borderColour = None wx.Panel.__init__(self, parent) self.__sizer = wx.BoxSizer(wx.HORIZONTAL) self.__closeBtn = wx.StaticText(self, label='X', style=(wx.SUNKEN_BORDER | wx.ALIGN_CENTRE_VERTICAL | wx.ALIGN_CENTRE_HORIZONTAL)) self.__text = wx.StaticText(self, style=(wx.ALIGN_CENTRE_VERTICAL | wx.ALIGN_CENTRE_HORIZONTAL)) self.__closeBtn.SetFont(self.__closeBtn.GetFont().Smaller()) self.__sizer.Add(self.__closeBtn, border=2, flag=(wx.EXPAND | wx.LEFT | wx.TOP | wx.BOTTOM)) self.__sizer.Add(self.__text, border=2, flag=(wx.EXPAND | wx.RIGHT | wx.TOP | wx.BOTTOM)) self.SetSizer(self.__sizer) self .SetBackgroundColour(bgColour) self .SetBorderColour( borderColour) self.SetText(text) self.__closeBtn.Bind(wx.EVT_LEFT_UP, self.__onCloseButton) self.__closeBtn.Bind(wx.EVT_SET_FOCUS, self.__onSetFocus) self.__text .Bind(wx.EVT_SET_FOCUS, self.__onSetFocus) self .Bind(wx.EVT_SET_FOCUS, self.__onSetFocus) self.__closeBtn.Bind(wx.EVT_KILL_FOCUS, self.__onKillFocus) self.__text .Bind(wx.EVT_KILL_FOCUS, self.__onKillFocus) self .Bind(wx.EVT_KILL_FOCUS, self.__onKillFocus) def __str__(self): """Returns a string representation of this ``StaticTextTag``. """ return 'StaticTextTag(\'{}\')'.format(self.GetText()) @property def closeButton(self): """Returns a reference to the ``StaticText`` control used as the close button. """ return self.__closeBtn @property def text(self): """Returns a reference to the ``StaticText`` control used for displaying the tag text. """ return self.__text
[docs] def SetBackgroundColour(self, colour): """Sets the background colour of this ``StaticTextTag``. Also automatically sets the foreground (text) colour to a complementary colour. """ fgColour = complementary_colour(colour) wx.Panel.SetBackgroundColour(self, colour) self.__text .SetForegroundColour(fgColour) self.__text .SetBackgroundColour(colour) self.__closeBtn.SetForegroundColour(fgColour) self.__closeBtn.SetBackgroundColour(colour) self.__bgColour = colour self.Refresh()
[docs] def SetBorderColour(self, colour): """Sets the border colour of this ``StaticTextTag``, for when it has focus. """ self.__borderColour = colour
[docs] def SetText(self, text): """Sets the text shown on this ``StaticTextTag``. """ if text is None: text = '' self.__text.SetLabel(text) self.Layout() self.Fit()
[docs] def GetText(self): """Returns the text shown on this ``StaticTextTag``. """ return self.__text.GetLabel()
def __onSetFocus(self, ev): """Called when this ``StaticTextTag`` gains focus. Changes the border colour. """ ev.Skip() wx.Panel.SetBackgroundColour(self, self.__borderColour) self.Refresh() def __onKillFocus(self, ev): """Called when this ``StaticTextTag`` loses focus. Clears the border colour. """ ev.Skip() if ev.GetWindow() not in (self, self.__text, self.__closeBtn): wx.Panel.SetBackgroundColour(self, self.__bgColour) self.Refresh() def __onCloseButton(self, ev): """Called when the close button is pushed. Generates an :data:`EVT_STT_CLOSE`. """ log.debug('{} close button pressed'.format(str(self))) ev = StaticTextTagCloseEvent() ev.SetEventObject(self) wx.PostEvent(self, ev)
_StaticTextTagCloseEvent, _EVT_STT_CLOSE = wxevent.NewEvent() EVT_STT_CLOSE = _EVT_STT_CLOSE """Identifier for the event generated by a :class:`StaticTextTag` when its close button is pushed. """ StaticTextTagCloseEvent = _StaticTextTagCloseEvent """Event object created for an :data:`EVT_STT_CLOSE`. """
[docs] class TextTagPanel(wx.Panel): """The ``TextTagPanel`` is a panel which contains a control allowing the user to add new tags, and a collection of :class:`StaticTextTag` controls. The ``TextTagPanel`` supports the following styles: .. autosummary:: TTP_ALLOW_NEW_TAGS TTP_ADD_NEW_TAGS TTP_NO_DUPLICATES TTP_CASE_SENSITIVE TTP_KEYBOARD_NAV The ``TextTagPanel`` generates the following events: .. autosummary:: EVT_TTP_TAG_REMOVED EVT_TTP_TAG_ADDED EVT_TTP_TAG_SELECT """ def __init__(self, parent, style=None): """Create a ``TextTagPanel``. :arg parent: The :mod:`wx` parent object. :arg style: Style flags. Defaults to ``TTP_ALLOW_NEW_TAGS | TTP_ADD_NEW_TAGS``. """ wx.Panel.__init__(self, parent) if style is None: style = TTP_ALLOW_NEW_TAGS | TTP_ADD_NEW_TAGS self.__allowNewTags = style & TTP_ALLOW_NEW_TAGS self.__addNewTags = style & TTP_ADD_NEW_TAGS and self.__allowNewTags self.__noDuplicates = style & TTP_NO_DUPLICATES self.__keyboardNav = style & TTP_KEYBOARD_NAV self.__caseSensitive = style & TTP_CASE_SENSITIVE self.__allTags = [] self.__tagDisplays = {} self.__activeTags = {} self.__tagColours = {} self.__tagWidgets = [] if self.__caseSensitive: atcStyle = atc.ATC_CASE_SENSITIVE else: atcStyle = 0 self.__newTagCtrl = atc.AutoTextCtrl(self, style=atcStyle) self.__mainSizer = wx.BoxSizer( wx.HORIZONTAL) self.__tagSizer = wx.WrapSizer(wx.HORIZONTAL, 2) # ^^ the WrapSizer style flags don't # seem to have made it into wxPython: # # EXTEND_LAST_ON_EACH_LINE = 1 # REMOVE_LEADING_SPACES = 2 self.__mainSizer.Add(self.__newTagCtrl) self.__mainSizer.Add(self.__tagSizer, flag=wx.EXPAND, proportion=1) self.__newTagCtrl.Bind(atc.EVT_ATC_TEXT_ENTER, self.__onTextCtrl) if self.__keyboardNav: self.__newTagCtrl.Bind(wx.EVT_KEY_DOWN, self.__onNewTagKeyDown) self.SetSizer(self.__mainSizer) self.Layout() @property def newTagCtrl(self): """Returns a reference to the :class:`.AutoTextCtrl`. """ return self.__newTagCtrl @property def tags(self): """Returns a list containing all :class:`StaticTextTag` widgets. """ return list(self.__tagWidgets)
[docs] def FocusNewTagCtrl(self): """Gives focus to the new tag control (an :class:`.AutoTextCtrl`). """ self.__newTagCtrl.SetFocus()
[docs] def SelectTag(self, tag): """Gives focus to the :class:`StaticTextTag` control with the specified tag, if it exists. """ tagIdx = self.GetTagIndex(tag) self.__tagWidgets[tagIdx].SetFocus()
[docs] def SetOptions(self, options, colours=None): """Sets the tag options made available to the user via the :class:`.AutoTextCtrl`. :arg options: A sequence of tags that the user can choose from. :arg colours: A sequence of corresponding colours for each tag. """ origOptions = options if not self.__caseSensitive: lowered = [o.lower() for o in options] uniq = [] orig = [] for i, o in enumerate(lowered): if o not in uniq: uniq.append(o) orig.append(origOptions[i]) options = uniq origOptions = orig self.__allTags = list(options) self.__tagDisplays = {o : oo for (o, oo) in zip(options, origOptions)} self.__updateNewTagOptions() if colours is not None: for option, colour in zip(options, colours): self.SetTagColour(option, colour)
# TODO delete any active tags # that are no longer valid?
[docs] def GetOptions(self): """Returns a list of all the tags that are currently available to the user. """ return [self.__tagDisplays[o] for o in self.__allTags]
[docs] def AddTag(self, tag, colour=None): """Add a new :class:`StaticTextTag` to this ``TextTagPanel``. :arg tag: The tag text. :arg colour: The tag background colour. """ origTag = tag if not self.__caseSensitive: tag = tag.lower() if colour is None: colour = self.__tagColours.get(tag, None) if colour is None: colour = [random.randint(100, 255), random.randint(100, 255), random.randint(100, 255)] stt = StaticTextTag(self, origTag, colour) stt.Bind(EVT_STT_CLOSE, self.__onTagClose) if self.__keyboardNav: stt.Bind(wx.EVT_LEFT_DOWN, self.__onTagLeftDown) stt.Bind(wx.EVT_KEY_DOWN, self.__onTagKeyDown) self.__tagSizer.Add(stt, flag=wx.ALL, border=3) self.Layout() self.GetParent().Layout() if self.__addNewTags and tag not in self.__allTags: log.debug('Adding new tag to options: {}'.format(tag)) self.__allTags.append(tag) self.__tagDisplays[tag] = origTag self.__tagWidgets.append(stt) self.__tagColours[tag] = colour self.__activeTags[tag] = self.__activeTags.get(tag, 0) + 1 self.__updateNewTagOptions()
[docs] def RemoveTag(self, tag): """Removes the specified tag. """ if not self.__caseSensitive: tag = tag.lower() tagIdx = self.GetTagIndex(tag) stt = self.__tagWidgets[tagIdx] self.__tagSizer .Detach(stt) self.__tagWidgets.remove(stt) count = self.__activeTags[tag] if count == 1: self.__activeTags.pop(tag) else: self.__activeTags[tag] = count - 1 stt.Destroy() self.Layout() self.GetParent().Layout() self.__updateNewTagOptions()
[docs] def GetTags(self): """Returns a list containing all active tags in this ``TextTagPanel``. """ return [stt.GetText() for stt in self.__tagWidgets]
[docs] def ClearTags(self): """Removes all tags from this ``TextTagPanel``. """ for tag in list(self.__tagWidgets): self.RemoveTag(tag.GetText())
[docs] def GetTagIndex(self, tag): """Returns the index of the specified tag. """ tags = self.GetTags() if not self.__caseSensitive: tag = tag.lower() tags = [t.lower() for t in tags] for i, t in enumerate(tags): if t == tag: return i raise IndexError('Unknown tag: "{}"'.format(tag))
[docs] def TagCount(self): """Returns the number of tags currently visible. """ return len(self.__tagWidgets)
[docs] def HasTag(self, tag): """Returns ``True`` if the given tag is currently shown, ``False`` otherwise. """ if not self.__caseSensitive: tag = tag.lower() return tag in self.__activeTags
[docs] def GetTagColour(self, tag): """Returns the background colour of the specified ``tag``, or ``None`` if there is no default colour for the tag. """ if not self.__caseSensitive: tag = tag.lower() return self.__tagColours.get(tag, None)
[docs] def SetTagColour(self, tag, colour): """Sets the background colour on all :class:`StaticTextTag` items which have the given tag text. """ if not self.__caseSensitive: tag = tag.lower() if tag not in self.__allTags: return self.__tagColours[tag] = colour for stt in self.__tagWidgets: if self.__caseSensitive: sttText = stt.GetText() else: sttText = stt.GetText().lower() if sttText == tag: stt.SetBackgroundColour(colour)
def __selectTag(self, stt): """Called by event handlers which listen for mouse/keyboard activity on :class:`StaticTextTag` widgets. Focuses the given ``StaticTextTag``, and generates an :data:`EVT_TTP_TAG_SELECT` event. """ tag = stt.GetText() stt.SetFocus() log.debug('Posting tag select event ("{}")'.format(tag)) ev = TextTagPanelTagSelectEvent(tag=tag) ev.SetEventObject(self) wx.PostEvent(self, ev) def __onTagLeftDown(self, ev): """Called on left mouse down events on :class:`StaticTextTag` objects (only if the :data:`TTP_KEYBOARD_NAV` style is set). Gives the tag focus. """ stt = ev.GetEventObject() tag = stt.GetText() log.debug('Mouse down on tag: "{}"'.format(tag)) self.__selectTag(stt) def __onTagClose(self, ev): """Called when the user pushes the close button on a :class:`StaticTextTag`. Removes the tag, and generates a :data:`EVT_TTP_TAG_REMOVED` event. """ stt = ev.GetEventObject() tag = stt.GetText() idx = self.GetTagIndex(tag) self.RemoveTag(tag) if len(self.__tagWidgets) == 0: self.FocusNewTagCtrl() else: if idx == len(self.__tagWidgets): idx -= 1 self.__tagWidgets[idx].SetFocus() log.debug('Tag removed: "{}"'.format(tag)) ev = TextTagPanelTagRemovedEvent(tag=tag) ev.SetEventObject(self) wx.PostEvent(self, ev) def __onNewTagKeyDown(self, ev): """Called on key down events from the new tag control (if the :data:`TTP_KEYBOARD_NAV` style is set). If the right arrow key is pushed, the first :class:`StaticTextTag` is given input focus. """ key = ev.GetKeyCode() log.debug('TextTagPanel key down [new tag control] ({})'.format(key)) # Only process right arrows if the text # control cursor is on the far right value = self.__newTagCtrl.GetValue() cursor = self.__newTagCtrl.GetInsertionPoint() if key != wx.WXK_RIGHT or \ len(self.__tagWidgets) == 0 or \ cursor != len(value): ev.Skip() return log.debug('Right arrow key on new tag control - focusing tags') self.__selectTag(self.__tagWidgets[0]) def __onTagKeyDown(self, ev): """Called on key down events from a :class:`StaticTextTag` object. If the left/right arrow keys are pushed, the focus is shifted accordingly. """ left = wx.WXK_LEFT right = wx.WXK_RIGHT delete = wx.WXK_DELETE backspace = wx.WXK_BACK key = ev.GetKeyCode() stt = ev.GetEventObject() log.debug('TextTagPanel key event ({})'.format(key)) if key not in (left, right, delete, backspace): ev.ResumePropagation(wx.EVENT_PROPAGATE_MAX) ev.Skip() return if key in (delete, backspace): self.__onTagClose(ev) return sttIdx = self.__tagWidgets.index(stt) if key == left: sttIdx -= 1 elif key == right: sttIdx += 1 if sttIdx == -1: log.debug('Arrow key on tag ({}) - focusing new ' 'tag control'.format(stt.GetText())) self.FocusNewTagCtrl() return elif sttIdx == len(self.__tagWidgets): ev.Skip() return log.debug('Arrow key on tag ({}) - selecting ' 'adjacent tag ({})'.format( stt.GetText(), self.__tagWidgets[sttIdx].GetText())) self.__selectTag(self.__tagWidgets[sttIdx]) def __onTextCtrl(self, ev): """Called when the user enters a new value via the ``TextCtrl`` (if this ``TextTagPanel`` allows new tags). Adds the new tag, and generates an :data:`EVT_TTP_TAG_ADDED` event. """ tag = self.__newTagCtrl.GetValue().strip() self.__newTagCtrl.ChangeValue('') if tag == '': return # If we don't care about case, and # this is a known option, use the # 'display' version of the tag. origTag = tag if not self.__caseSensitive: tag = tag.lower() origTag = self.__tagDisplays.get(tag, origTag) if self.__noDuplicates and self.HasTag(tag): log.debug('New tag {} ignored (noDuplicates is True)'.format(tag)) return if not self.__allowNewTags and tag not in self.__allTags: log.debug('New tag {} ignored (allowNewTags is False)'.format(tag)) return log.debug('New tag from text control: {}'.format(origTag)) self.__newTagCtrl.Refresh() self.AddTag(origTag) ev = TextTagPanelTagAddedEvent(tag=origTag) ev.SetEventObject(self) wx.PostEvent(self, ev) def __updateNewTagOptions(self): """Updates the options shown on the new tag control.""" tags = list(self.__allTags) if self.__noDuplicates: tags = [t for t in tags if t not in self.__activeTags] tags = [self.__tagDisplays[t] for t in tags] self.__newTagCtrl.AutoComplete(tags)
TTP_ALLOW_NEW_TAGS = 1 """Style flag for use with a :class:`TextTagPanel` - if set, the user is able to type in tag names that are not known by the :class:`.AutoTextCtrl`. """ TTP_ADD_NEW_TAGS = 2 """Style flag for use with a :class:`TextTagPanel` - if set, when the user types in a tag name that is not known by the ``AutoTextCtrl``, that name is added to its list of options. This flag only has an effect if the :data:`TTP_ALLOW_NEW_TAGS` flag is also set. """ TTP_NO_DUPLICATES = 4 """Style flag for use with a :class:`TextTagPanel` - if set, the user will be prevented from adding the same tag more than once. """ TTP_CASE_SENSITIVE = 8 """Style flag for use with a :class:`TextTagPanel` - if set, the auto-completion options will be case sensitive. This flag only has an effect if the :data:`TTP_ALLOW_NEW_TAGS` flag is also set. """ TTP_KEYBOARD_NAV = 16 """Style flag for use with a :class:`TextTagPanel` - if set, the user can use the left and right arrow keys to move between the new tag control and the tags and, when a tag is focused can use the delete/backspace keys to remove it. """ _TextTagPanelTagAddedEvent, _EVT_TTP_TAG_ADDED = wxevent.NewEvent() _TextTagPanelTagRemovedEvent, _EVT_TTP_TAG_REMOVED = wxevent.NewEvent() _TextTagPanelTagSelectEvent, _EVT_TTP_TAG_SELECT = wxevent.NewEvent() EVT_TTP_TAG_ADDED = _EVT_TTP_TAG_ADDED """Identifier for the event generated when a tag is added to a :class:`TextTagPanel`. """ TextTagPanelTagAddedEvent = _TextTagPanelTagAddedEvent """Event generated when a tag is added to a :class:`TextTagPanel`. A ``TextTagPanelTagAddedEvent`` has a single attribute called ``tag``, which contains the tag text. """ EVT_TTP_TAG_REMOVED = _EVT_TTP_TAG_REMOVED """Identifier for the event generated when a tag is removed from a :class:`TextTagPanel`. """ TextTagPanelTagRemovedEvent = _TextTagPanelTagRemovedEvent """Event generated when a tag is removed from a :class:`TextTagPanel`. A ``TextTagPanelTagRemovedEvent`` has a single attribute called ``tag``, which contains the tag text. """ EVT_TTP_TAG_SELECT = _EVT_TTP_TAG_SELECT """Identifier for the event generated when a tag is selected in a :class:`TextTagPanel`. """ TextTagPanelTagSelectEvent = _TextTagPanelTagSelectEvent """Event generated when a tag is selected in a :class:`TextTagPanel`. A ``TextTagPanelTagSelectEvent`` has a single attribute called ``tag``, which contains the tag text. """