Source code for fsleyes.controls.overlaylistpanel
#
# overlaylistpanel.py - The OverlayListPanel.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the ``OverlayListPanel``, a *FSLeyes control* which
displays a list of all overlays currently in the :class:`.OverlayList`.
"""
import logging
import colorsys
import wx
import numpy as np
import fsl.data.image as fslimage
import fsl.utils.idle as idle
import fsleyes_props as props
import fsleyes_widgets.bitmaptoggle as bmptoggle
import fsleyes_widgets.elistbox as elistbox
import fsleyes.controls.controlpanel as ctrlpanel
import fsleyes.icons as icons
import fsleyes.autodisplay as autodisplay
import fsleyes.strings as strings
import fsleyes.tooltips as fsltooltips
import fsleyes.actions.loadoverlay as loadoverlay
import fsleyes.actions.saveoverlay as saveoverlay
import fsleyes.actions.removeoverlay as removeoverlay
log = logging.getLogger(__name__)
[docs]class OverlayListPanel(ctrlpanel.ControlPanel):
"""The ``OverlayListPanel`` displays all overlays in the
:class:`.OverlayList`, and allows the user to add, remove, and re-order
overlays. An ``OverlayListPanel`` looks something like this:
.. image:: images/overlaylistpanel.png
:scale: 50%
:align: center
A :class:`ListItemWidget` is displayed alongside every overlay in the
list - this allows the user to enable/disable, group, and save each
overlay.
The ``OverlayListPanel`` is closely coupled to a few
:class:`.DisplayContext` properties: the
:attr:`.DisplayContext.selectedOverlay` property is linked to the currently
selected item in the overlay list, and the order in which the overlays are
shown is defined by the :attr:`.DisplayContext.overlayOrder` property. This
property is updated when the user changes the order of items in the list.
"""
[docs] @staticmethod
def defaultLayout():
"""Returns a dictionary containing layout settings to be passed to
:class:`.ViewPanel.togglePanel`.
"""
return {'location' : wx.BOTTOM}
[docs] def __init__(self,
parent,
overlayList,
displayCtx,
viewPanel,
showVis=True,
showGroup=True,
showSave=True,
propagateSelect=True,
elistboxStyle=None,
filterFunc=None):
"""Create an ``OverlayListPanel``.
:arg parent: The :mod:`wx` parent object.
:arg overlayList: An :class:`.OverlayList` instance.
:arg displayCtx: A :class:`.DisplayContext` instance.
:arg viewPanel: The :class:`.ViewPanel` instance.
:arg showVis: If ``True`` (the default), a button will be shown
alongside each overlay, allowing the user to
toggle the overlay visibility.
:arg showGroup: If ``True`` (the default), a button will be shown
alongside each overlay, allowing the user to
toggle overlay grouping.
:arg showSave: If ``True`` (the default), a button will be shown
alongside each overlay, allowing the user to save
the overlay (if it is not saved).
:arg propagateSelect: If ``True`` (the default), when the user
interacts with the :class:`.ListItemWidget` for
an overlay which is *not* the currently selected
overlay, that overlay is updated to be the
selected overlay.
:arg elistboxStyle: Style flags passed through to the
:class:`.EditableListBox`.
:arg filterFunc: Function which must accept an overlay as its
sole argument, and return ``True`` or ``False``.
If this function returns ``False`` for an
overlay, the :class:`ListItemWidget` for that
overlay will be disabled.
"""
def defaultFilter(o):
return True
if filterFunc is None:
filterFunc = defaultFilter
ctrlpanel.ControlPanel.__init__(
self, parent, overlayList, displayCtx, viewPanel)
self.__showVis = showVis
self.__showGroup = showGroup
self.__showSave = showSave
self.__propagateSelect = propagateSelect
self.__filterFunc = filterFunc
if elistboxStyle is None:
elistboxStyle = (elistbox.ELB_REVERSE |
elistbox.ELB_TOOLTIP_DOWN |
elistbox.ELB_SCROLL_BUTTONS)
# list box containing the list of overlays - it
# is populated in the _overlayListChanged method
self.__listBox = elistbox.EditableListBox(self, style=elistboxStyle)
# listeners for when the user does
# something with the list box
self.__listBox.Bind(elistbox.EVT_ELB_SELECT_EVENT, self.__lbSelect)
self.__listBox.Bind(elistbox.EVT_ELB_MOVE_EVENT, self.__lbMove)
self.__listBox.Bind(elistbox.EVT_ELB_REMOVE_EVENT, self.__lbRemove)
self.__listBox.Bind(elistbox.EVT_ELB_ADD_EVENT, self.__lbAdd)
self.__listBox.Bind(elistbox.EVT_ELB_DBLCLICK_EVENT, self.__lbDblClick)
self.__sizer = wx.BoxSizer(wx.HORIZONTAL)
self.SetSizer(self.__sizer)
self.__sizer.Add(self.__listBox, flag=wx.EXPAND, proportion=1)
self.overlayList.addListener(
'overlays',
self.name,
self.__overlayListChanged)
self.displayCtx.addListener(
'overlayOrder',
self.name,
self.__overlayListChanged)
self.displayCtx.addListener(
'selectedOverlay',
self.name,
self.__selectedOverlayChanged)
self.__overlayListChanged()
self.__selectedOverlayChanged()
self.Layout()
self.__minSize = self.__sizer.GetMinSize()
self.SetMinSize(self.__minSize)
[docs] def GetMinSize(self):
"""Returns the minimum size for this ``OverlayListPanel``.
Under Linux/GTK, the ``wx.agw.lib.aui`` layout manager seems to
arbitrarily adjust the minimum sizes of some panels. Therefore, The
minimum size of the ``OverlayListPanel`` is calculated in
:meth:`__init__`, and is fixed.
"""
return self.__minSize
[docs] def destroy(self):
"""Must be called when this ``OverlayListPanel`` is no longer needed.
Removes some property listeners, and calls
:meth:`.ControlPanel.destroy`.
"""
self.overlayList.removeListener('overlays', self.name)
self.displayCtx .removeListener('selectedOverlay', self.name)
self.displayCtx .removeListener('overlayOrder', self.name)
# A listener on name was added
# in the _overlayListChanged method
for overlay in self.overlayList:
display = self.displayCtx.getDisplay(overlay)
display.removeListener('name', self.name)
self.__filterFunc = None
self.__listBox.Clear()
ctrlpanel.ControlPanel.destroy(self)
def __selectedOverlayChanged(self, *a):
"""Called when the :attr:`.DisplayContext.selectedOverlay` property
changes. Updates the selected item in the list box.
"""
if len(self.overlayList) > 0:
self.__listBox.SetSelection(
self.displayCtx.getOverlayOrder(
self.displayCtx.selectedOverlay))
def __overlayNameChanged(self, value, valid, display, propName):
"""Called when the :attr:`.Display.name` of an overlay changes. Updates
the corresponding label in the overlay list.
"""
overlay = display.overlay
idx = self.displayCtx.getOverlayOrder(overlay)
name = display.name
if name is None:
name = ''
self.__listBox.SetItemLabel(idx, name)
def __overlayListChanged(self, *a):
"""Called when the :class:`.OverlayList` changes. All of the items
in the overlay list are re-created.
"""
self.__listBox.Clear()
for i, overlay in enumerate(self.displayCtx.getOrderedOverlays()):
display = self.displayCtx.getDisplay(overlay)
name = display.name
if name is None: name = ''
self.__listBox.Append(name, overlay)
widget = ListItemWidget(self,
overlay,
display,
self.displayCtx,
self.__listBox,
showVis=self.__showVis,
showGroup=self.__showGroup,
showSave=self.__showSave,
propagateSelect=self.__propagateSelect)
if not self.__filterFunc(overlay):
widget.Disable()
self.__listBox.SetItemWidget(i, widget)
tooltip = overlay.dataSource
if tooltip is None:
tooltip = strings.labels['OverlayListPanel.noDataSource']
self.__listBox.SetItemTooltip(i, tooltip)
display.addListener('name',
self.name,
self.__overlayNameChanged,
overwrite=True)
if len(self.overlayList) > 0:
self.__listBox.SetSelection(
self.displayCtx.getOverlayOrder(
self.displayCtx.selectedOverlay))
self.__listBox.Layout()
def __lbMove(self, ev):
"""Called when an overlay is moved in the :class:`.EditableListBox`.
Reorders the :attr:`.DisplayContext.overlayOrder` to reflect the
change.
"""
self.displayCtx.disableListener('overlayOrder', self.name)
self.displayCtx.overlayOrder.move(ev.oldIdx, ev.newIdx)
self.displayCtx.enableListener('overlayOrder', self.name)
def __lbSelect(self, ev):
"""Called when an overlay is selected in the
:class:`.EditableListBox`. Updates the
:attr:`.DisplayContext.selectedOverlay` property.
"""
self.displayCtx.disableListener('selectedOverlay', self.name)
self.displayCtx.selectedOverlay = self.displayCtx.overlayOrder[ev.idx]
self.displayCtx.enableListener('selectedOverlay', self.name)
def __lbAdd(self, ev):
"""Called when the *add* button on the list box is pressed.
Calls the :func:`.loadoverlay.interactiveLoadOverlays` method.
"""
def onLoad(paths, overlays):
if len(overlays) == 0:
return
self.overlayList.extend(overlays)
self.displayCtx.selectedOverlay = len(self.overlayList) - 1
if self.displayCtx.autoDisplay:
for overlay in overlays:
autodisplay.autoDisplay(overlay,
self.overlayList,
self.displayCtx)
loadoverlay.interactiveLoadOverlays(
onLoad=onLoad,
inmem=self.displayCtx.loadInMemory)
def __lbRemove(self, ev):
"""Called when an item is removed from the overlay listbox.
Removes the corresponding overlay from the :class:`.OverlayList`.
"""
overlay = self.displayCtx.overlayOrder[ev.idx]
overlay = self.overlayList[overlay]
with props.skip(self.overlayList, 'overlays', self.name), \
props.skip(self.displayCtx, 'overlayOrder', self.name):
if not removeoverlay.removeOverlay(self.overlayList,
self.displayCtx,
overlay):
ev.Veto()
# The overlayListChanged method
# must be called asynchronously,
# otherwise it will corrupt the
# EditableListBox state
else:
idle.idle(self.__overlayListChanged)
def __lbDblClick(self, ev):
"""Called when an item label is double clicked on the overlay list
box. Toggles the visibility of the overlay, via the
:attr:`.Display.enabled` property..
"""
idx = self.displayCtx.overlayOrder[ev.idx]
overlay = self.overlayList[idx]
display = self.displayCtx.getDisplay(overlay)
display.enabled = not display.enabled
[docs]class ListItemWidget(wx.Panel):
"""A ``LisItemWidget`` is created by the :class:`OverlayListPanel` for
every overlay in the :class:`.OverlayList`. A ``LisItemWidget`` contains
controls which allow the user to:
- Toggle the visibility of the overlay (via the :attr:`.Display.enabled`
property)
- Add the overlay to a group (see the
:attr:`.DisplayContext.overlayGroups` property, and the :mod:`.group`
module).
- Save the overlay (if it has been modified).
.. note:: While the :class:`.DisplayContext` allows multiple
:class:`.OverlayGroup` instances to be defined (and added to its
:attr:`.DisplayContext.overlayGroups` property), *FSLeyes*
currently only defines a single group . This ``OverlayGroup``
is created in the :func:`.fsleyes.context` function, and overlays
can be added/removed to/from it via the *lock* button on a
``ListItemWidget``. This functionality might change in a future
version of *FSLeyes*.
.. note:: Currently, only :class:`.Image` overlays can be saved. The *save*
button is disabled for all other overlay types.
"""
[docs] def __init__(self,
parent,
overlay,
display,
displayCtx,
listBox,
showVis=True,
showGroup=True,
showSave=True,
propagateSelect=True):
"""Create a ``ListItemWidget``.
:arg parent: The :mod:`wx` parent object.
:arg overlay: The overlay associated with this
``ListItemWidget``.
:arg display: The :class:`.Display` associated with the
overlay.
:arg displayCtx: The :class:`.DisplayContext` instance.
:arg listBox: The :class:`.EditableListBox` that contains this
``ListItemWidget``.
:arg showVis: If ``True`` (the default), a button will be shown
allowing the user to toggle the overlay
visibility.
:arg showGroup: If ``True`` (the default), a button will be shown
allowing the user to toggle overlay grouping.
:arg showSave: If ``True`` (the default), a button will be shown
allowing the user to save the overlay (if it is
not saved).
:arg propagateSelect: If ``True`` (the default), when an overlay is
selected in the list, the
:attr:`.DisplayContext.selectedOverlay` is
updated accordingly.
"""
wx.Panel.__init__(self, parent)
self.enabledFG = wx.SystemSettings.GetColour(wx.SYS_COLOUR_WINDOWTEXT)
self.disabledFG = wx.SystemSettings.GetColour(wx.SYS_COLOUR_GRAYTEXT)
# give unsaved overlays reddish colours
isdark = False
# GetAppearance added in wxwidgets 3.1.3 (~=wxpython 4.1.0)
if hasattr(wx.SystemSettings, 'GetAppearance'):
isdark = wx.SystemSettings.GetAppearance().IsDark()
else:
# Guess whether we have a light or dark theme
colour = wx.SystemSettings.GetColour(wx.SYS_COLOUR_WINDOW).Get()
isdark = all(c < 128 for c in colour[:3])
if isdark:
self.unsavedDefaultBG = '#443333'
self.unsavedSelectedBG = '#551111'
else:
self.unsavedDefaultBG = '#ffeeee'
self.unsavedSelectedBG = '#ffcdcd'
self.__overlay = overlay
self.__display = display
self.__displayCtx = displayCtx
self.__listBox = listBox
self.__propagateSelect = propagateSelect
self.__name = '{}_{}'.format(self.__class__.__name__,
id(self))
self.__sizer = wx.BoxSizer(wx.HORIZONTAL)
self.SetSizer(self.__sizer)
self.Bind(wx.EVT_WINDOW_DESTROY, self.__onDestroy)
btnStyle = wx.BU_EXACTFIT | wx.BU_NOTEXT
if showSave:
self.__saveButton = wx.Button(self, style=btnStyle)
self.__saveButton.SetBitmapLabel(icons.loadBitmap('floppydisk16'))
self.__saveButton.SetToolTip(
wx.ToolTip(fsltooltips.actions[self, 'save']))
self.__sizer.Add(self.__saveButton, flag=wx.EXPAND, proportion=1)
if isinstance(overlay, fslimage.Image):
overlay.register(self.__name,
self.__saveStateChanged,
'saveState')
self.__saveButton.Bind(wx.EVT_BUTTON, self.__onSaveButton)
else:
self.__saveButton.Enable(False)
self.__saveStateChanged()
if showGroup:
self.__lockButton = bmptoggle.BitmapToggleButton(
self, style=btnStyle)
self.__lockButton.SetBitmap(
icons.loadBitmap('chainlinkHighlight16'),
icons.loadBitmap('chainlink16'))
self.__lockButton.SetToolTip(
wx.ToolTip(fsltooltips.actions[self, 'group']))
self.__sizer.Add(self.__lockButton, flag=wx.EXPAND, proportion=1)
# There is currently only one overlay
# group in the application. In the
# future there may be multiple groups.
group = displayCtx.overlayGroups[0]
group .addListener('overlays',
self.__name,
self.__overlayGroupChanged)
self.__lockButton.Bind(wx.EVT_TOGGLEBUTTON, self.__onLockButton)
self.__overlayGroupChanged()
# Set up the show/hide button if needed
if showVis:
self.__visibility = bmptoggle.BitmapToggleButton(
self,
trueBmp=icons.loadBitmap('eyeHighlight16'),
falseBmp=icons.loadBitmap('eye16'),
style=btnStyle)
self.__visibility.SetToolTip(
wx.ToolTip(fsltooltips.properties[display, 'enabled']))
self.__sizer.Add(self.__visibility, flag=wx.EXPAND, proportion=1)
display.addListener('enabled',
self.__name,
self.__displayVisChanged)
self.__visibility.Bind(bmptoggle.EVT_BITMAP_TOGGLE,
self.__onVisButton)
self.__displayVisChanged()
def __overlayGroupChanged(self, *a):
"""Called when the :class:`.OverlayGroup` changes. Updates the *lock*
button based on whether the overlay associated with this
``ListItemWidget`` is in the group or not.
"""
group = self.__displayCtx.overlayGroups[0]
self.__lockButton.SetValue(self.__overlay in group.overlays)
def __onSaveButton(self, ev):
"""Called when the *save* button is pushed. Calls the
:meth:`.Image.save` method.
"""
if self.__propagateSelect:
self.__displayCtx.selectOverlay(self.__overlay)
if not self.__overlay.saveState:
saveoverlay.saveOverlay(self.__overlay, self.__display)
def __onLockButton(self, ev):
"""Called when the *lock* button is pushed. Adds/removes the overlay
to/from the :class:`.OverlayGroup`.
"""
if self.__propagateSelect:
self.__displayCtx.selectOverlay(self.__overlay)
group = self.__displayCtx.overlayGroups[0]
if self.__lockButton.GetValue(): group.addOverlay( self.__overlay)
else: group.removeOverlay(self.__overlay)
def __onVisButton(self, ev):
"""Called when the *visibility* button is pushed. Toggles the overlay
visibility.
"""
if self.__propagateSelect:
self.__displayCtx.selectOverlay(self.__overlay)
idx = self.__listBox.IndexOf(self.__overlay)
enabled = self.__visibility.GetValue()
with props.suppress(self.__display, 'enabled', self.__name):
self.__display.enabled = enabled
if enabled: fgColour = self.enabledFG
else: fgColour = self.disabledFG
self.__listBox.SetItemForegroundColour(idx, fgColour)
def __displayVisChanged(self, *a):
"""Called when the :attr:`.Display.enabled` property of the overlay
changes. Updates the state of the *enabled* buton, and changes the
item foreground colour.
"""
idx = self.__listBox.IndexOf(self.__overlay)
enabled = self.__display.enabled
if enabled: fgColour = self.enabledFG
else: fgColour = self.disabledFG
self.__visibility.SetValue(enabled)
self.__listBox.SetItemForegroundColour(idx, fgColour)
def __onDestroy(self, ev):
"""Called when this ``ListItemWidget`` is destroyed (i.e. when the
associated overlay is removed from the :class:`OverlayListPanel`).
Removes some proprety listeners from the :class:`.Display` and
:class:`.OverlayGroup` instances, and from the overlay if it is an
:class:`.Image` instance.
"""
ev.Skip()
if ev.GetEventObject() is not self:
return
group = self.__displayCtx.overlayGroups[0]
if self.__display.hasListener('enabled', self.__name):
self.__display.removeListener('enabled', self.__name)
if group.hasListener('overlays', self.__name):
group.removeListener('overlays', self.__name)
if isinstance(self.__overlay, fslimage.Image):
# Notifier.deregister will ignore
# non-existent listener de-registration
self.__overlay.deregister(self.__name, 'saveState')
def __saveStateChanged(self, *a):
"""If the overlay is an :class:`.Image` instance, this method is
called when its :attr:`.Image.saved` property changes. Updates the
state of the *save* button.
"""
if not isinstance(self.__overlay, fslimage.Image):
return
idx = self.__listBox.IndexOf(self.__overlay)
self.__saveButton.Enable(not self.__overlay.saveState)
if self.__overlay.saveState:
self.__listBox.SetItemBackgroundColour(idx)
else:
self.__listBox.SetItemBackgroundColour(
idx,
self.unsavedDefaultBG,
self.unsavedSelectedBG),
tooltip = self.__overlay.dataSource
if tooltip is None:
tooltip = strings.labels['OverlayListPanel.noDataSource']
self.__listBox.SetItemTooltip(idx, tooltip)