#!/usr/bin/env python
#
# widgetlist.py - A widget which displays a list of groupable widgets.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`WidgetList` class, which displays a list
of widgets.
"""


import wx
import wx.lib.newevent      as wxevent
import wx.lib.scrolledpanel as scrolledpanel

import fsleyes_widgets.togglepanel as togglepanel


class WidgetList(scrolledpanel.ScrolledPanel):
    """A scrollable list of widgets.

    The ``WidgetList`` provides a number of features:

      - Widgets can be grouped.
      - A label can be shown next to each widget.
      - Widget groups can be collapsed/expanded.
      - Widgets and groups can be dynamically added/removed.


    The most important methods are:

     .. autosummary::
        :nosignatures:

        AddWidget
        AddGroup


    A ``WidgetList`` looks something like this:

     .. image:: images/widgetlist.png
        :scale: 50%
        :align: center


    A ``WidgetList`` emits a :data:`WidgetListChangeEvent` whenever its
    contents change.
    """


    _defaultOddColour   = None
    """Background colour for widgets on odd rows.
    Iniitalised in :meth:`__init__`.
    """


    _defaultEvenColour  = None
    """Background colour for widgets on even rows.
    Iniitalised in :meth:`__init__`.
    """


    _defaultGroupColour = None
    """Border and title background colour for widget groups.
    Iniitalised in :meth:`__init__`.
    """


    def __init__(self, parent, style=0, minHeight=-1):
        """Create a ``WidgetList``.

        :arg parent:    The :mod:`wx` parent object.
        :arg style:     Passed through to ``wx.ScrolledPanel.__init__``
        :arg minHeight: Minimum height of each row
        """

        odd   = wx.SystemSettings.GetColour(wx.SYS_COLOUR_LISTBOX)
        even  = odd.ChangeLightness(90)
        group = odd

        if WidgetList._defaultOddColour is None:
            WidgetList._defaultOddColour = odd
        if WidgetList._defaultEvenColour is None:
            WidgetList._defaultEvenColour = even
        if WidgetList._defaultGroupColour is None:
            WidgetList._defaultGroupColour = group

        self.__minHeight   = minHeight
        self.__widgSizer   = wx.BoxSizer(wx.VERTICAL)
        self.__sizer       = wx.BoxSizer(wx.VERTICAL)

        self.__groupSizer  = wx.BoxSizer(wx.VERTICAL)
        self.__widgets     = {}
        self.__groups      = {}

        self.__oddColour   = WidgetList._defaultOddColour
        self.__evenColour  = WidgetList._defaultEvenColour
        self.__groupColour = WidgetList._defaultGroupColour

        self.__sizer.Add(self.__widgSizer,  flag=wx.EXPAND)
        self.__sizer.Add(self.__groupSizer, flag=wx.EXPAND)

        self.__oneExpanded = style & WL_ONE_EXPANDED

        # The SP.__init__ method seemingly
        # induces a call to DoGetBestSize,
        # which assumes that all of the
        # things above exist. So we call
        # init after we've created those
        # things.
        scrolledpanel.ScrolledPanel.__init__(self, parent)

        self.SetSizer(self.__sizer)
        self.SetupScrolling()
        self.SetAutoLayout(1)


    def DoGetBestSize(self):
        """Returns the best size for the widget list, with all group
        widgets expanded.
        """

        width, height = self.__widgSizer.GetSize().Get()
        for name, group in self.__groups.items():
            w, h    = group.parentPanel.GetBestSize().Get()
            w      += 20
            h      += 10

            if w > width:
                width = w
            height += h

        return wx.Size(width, height)


    def __makeWidgetKey(self, widget):
        """Widgets are stored in a dictionary - this method generates a
        string to use as a key, based on the widget ``id``.
        """
        return str(id(widget))


    def __setLabelWidths(self, widgets):
        """Calculates the maximum width of all widget labels, and sets all
        labels to that width.

        This ensures that all labels/widgets line are horizontally aligned.
        """

        if len(widgets) == 0:
            return

        dc        = wx.ClientDC(widgets[0].label)
        lblWidths = [dc.GetTextExtent(w.displayName)[0] for w in widgets]
        maxWidth  = max(lblWidths)

        for w in widgets:
            w.label.SetMinSize((maxWidth + 10, -1))
            w.label.SetMaxSize((maxWidth + 10, -1))


    def __setColours(self):
        """Called whenever the widget list needs to be refreshed.

        Makes sure that odd/even widgets and their labels have the correct
        background colour.
        """
        def setWidgetColours(widgDict):
            for i, widg in enumerate(widgDict.values()):

                if i % 2: colour = self.__oddColour
                else:     colour = self.__evenColour
                widg.SetBackgroundColour(colour)

        setWidgetColours(self.__widgets)

        for group in self.__groups.values():

            setWidgetColours(group.widgets)
            group.parentPanel.SetBackgroundColour(self.__groupColour)
            group.colPanel   .SetBackgroundColour(self.__groupColour)


    def __refresh(self, *args, **kwargs):
        """Updates widget colours (see :meth:`__setColours`), and lays out
        the widget list.

        :arg postEvent: If ``True`` (the default), a
                        :data:`WidgetListChangeEvent` is posted.
        """
        self.__setColours()
        self.FitInside()
        self.Layout()

        if kwargs.get('postEvent', True):
            wx.PostEvent(self, WidgetListChangeEvent())


    def SetColours(self, odd=None, even=None, group=None):
        """Sets the colours used on this ``WidgetList``.

        Each argument is assumed to be a tuple of ``(r, g, b)`` values,
        each in the range ``[0 - 255]``.

        :arg odd:   Background colour for widgets on odd rows.
        :arg even:  Background colour for widgets on even rows.
        :arg group: Border/title colour for widget groups.
        """
        if odd   is not None: self.__oddColour   = odd
        if even  is not None: self.__evenColour  = even
        if group is not None: self.__groupColour = group
        self.__setColours()


    def GetGroups(self):
        """Returns a list containing the name of every group in this
        ``WidgetList``.
        """
        return list(self.__groups.keys())


    def HasGroup(self, groupName):
        """Returns ``True`` if this ``WidgetList`` contains a group
        with the specified name.
        """
        return groupName in self.__groups


    def RenameGroup(self, groupName, newDisplayName):
        """Changes the display name of the specified group.

         .. note:: This method only changes the *display name* of a group,
                   not the group identifier name. See the :meth:`AddGroup`
                   method.

        :arg groupName:      Name of the group.

        :arg newDisplayName: New display name for the group.
        """
        group = self.__groups[groupName]
        group.displayName = newDisplayName
        group.colPanel.SetLabel(newDisplayName)


    def AddGroup(self, groupName, displayName=None):
        """Add a new group to this ``WidgetList``.

        A :exc:`ValueError` is raised if a group with the specified name
        already exists.

        :arg groupName:   The name of the group - this is used as an
                          identifier for the group.

        :arg displayName: A string to be shown in the title bar for the
                          group. This can be changed later via the
                          :meth:`RenameGroup` method.
        """

        if displayName is None:
            displayName = groupName

        if groupName in self.__groups:
            raise ValueError('A group with name {} '
                             'already exists'.format(groupName))

        parentPanel = wx.Panel(self, style=wx.SUNKEN_BORDER)
        colPanel    = togglepanel.TogglePanel(parentPanel, label=displayName)

        widgPanel   = colPanel.GetPane()
        widgSizer   = wx.BoxSizer(wx.VERTICAL)

        widgPanel.SetSizer(widgSizer)

        gapSizer = wx.BoxSizer(wx.VERTICAL)

        # A spacer exists at the top,
        # and between, every group.
        gapSizer.Add((-1, 5))
        gapSizer.Add(parentPanel, border=10, flag=(wx.EXPAND |
                                                   wx.LEFT   |
                                                   wx.RIGHT))
        self.__groupSizer.Add(gapSizer, flag=wx.EXPAND)

        parentSizer = wx.BoxSizer(wx.VERTICAL)
        parentSizer.Add(colPanel,
                        border=5,
                        flag=wx.EXPAND | wx.BOTTOM,
                        proportion=0)
        parentPanel.SetSizer(parentSizer)

        group = _Group(groupName,
                       displayName,
                       gapSizer,
                       parentPanel,
                       colPanel,
                       widgPanel,
                       widgSizer)

        self.__groups[groupName] = group
        self.__refresh()

        # Mouse wheel listener needed
        # on all children under linux/GTK
        if wx.Platform == '__WXGTK__':
            parentPanel.Bind(wx.EVT_MOUSEWHEEL, self.__onMouseWheel)
            colPanel   .Bind(wx.EVT_MOUSEWHEEL, self.__onMouseWheel)

        colPanel.Bind(togglepanel.EVT_TOGGLEPANEL_EVENT, self.__onGroupExpand)


    def GetWidgets(self, groupName=None):
        """Returns a list containing all of the widgets that have been added
        to this ``WidgetList``.

        :arg groupName: If provided, only widgets in the specified group will
                        be returned. Otherwise, ungrouped widgets are returned.
        """
        if groupName is None: widgDict = self.__widgets
        else:                 widgDict = self.__groups[groupName].widgets
        widgets = [w.widget for w in widgDict.values()]
        return widgets


    def AddWidget(self, widget, displayName, tooltip=None, groupName=None):
        """Add an arbitrary widget to the property list.

        If the ``groupName`` is not provided, the widget is added to a list
        of *top level* widgets, which appear at the top of the list, above
        any groups. Otherwise, the widget is added to the collapsible panel
        corresponding to the specified group.

        A :exc:`ValueError` is raised if the widget is already contained
        in the list.

        :arg widget:      The widget to add to the list.

        :arg displayName: The widget label/display name.

        :arg tooltip:     A tooltip for the widget.

        :arg groupName:   Name of the group to which the widget should be
                          added.

         .. note:: The provided ``widget`` may also be a :class:`wx.Sizer`
                   instances, although support for this is basic. Specifically,
                   only one level of nesting is possible, i.e. the provided
                   ``wx.Sizer`` may not have any other ``wx.Sizer``
                   instances as its children.
        """

        if groupName is None:
            widgDict    = self.__widgets
            parent      = self
            parentSizer = self.__widgSizer
        else:
            group       = self.__groups[groupName]
            widgDict    = group.widgets
            parent      = group.widgPanel
            parentSizer = group.sizer

        key = self.__makeWidgetKey(widget)

        if key in widgDict:
            raise ValueError('Widgets {} already exist'.format(key))

        widgPanel = wx.Panel(parent)
        widgSizer = wx.BoxSizer(wx.HORIZONTAL)
        widgPanel.SetSizer(widgSizer)

        if isinstance(widget, wx.Sizer):
            for child in widget.GetChildren():
                child.GetWindow().Reparent(widgPanel)
        else:
            w, h = widget.GetBestSize().Get()
            if self.__minHeight > h:
                h = self.__minHeight
            widget.SetMinSize( (w, h))
            widget.Reparent(widgPanel)

        label = wx.StaticText(widgPanel,
                              label=displayName,
                              style=wx.ALIGN_RIGHT)

        widgSizer.Add(label,  flag=wx.EXPAND)
        widgSizer.Add(widget, flag=wx.EXPAND, proportion=1)

        parentSizer.Add(widgPanel,
                        flag=wx.EXPAND | wx.LEFT | wx.RIGHT,
                        border=5)

        widg = _Widget(displayName,
                       tooltip,
                       label,
                       widget,
                       widgPanel,
                       widgSizer)

        if tooltip is not None:
            widg.SetTooltip(tooltip)

        # Under linux/GTK, mouse events are
        # captured by child windows, so if
        # we want scrolling to work, we need
        # to capture scroll events on every
        # child. Under OSX/cocoa, this is
        # not necessary.
        if wx.Platform == '__WXGTK__':
            widg.Bind(wx.EVT_MOUSEWHEEL, self.__onMouseWheel)

        widgDict[key] = widg

        self.__setLabelWidths(list(widgDict.values()))
        self.__refresh()


    def __onMouseWheel(self, ev):
        """Only called if running on GTK. Scrolls the widget list according
        to the mouse wheel rotation.
        """

        posx, posy = self.GetViewStart()
        rotation   = ev.GetWheelRotation()

        if   rotation > 0: delta =  5
        elif rotation < 0: delta = -5
        else:              return

        if ev.GetWheelAxis() == wx.MOUSE_WHEEL_VERTICAL: posy -= delta
        else:                                            posx += delta

        self.Scroll(posx, posy)


    def __onGroupExpand(self, ev):
        """Called when the user expands or collapses a group. Enforces
        the :data:`WL_ONE_EXPANDED` style if it is enabled, and refreshes
        the panel.
        """

        panel = ev.GetEventObject()

        if panel.IsExpanded() and self.__oneExpanded:
            for group in self.__groups.values():
                if group.colPanel is not panel:
                    group.colPanel.Collapse()

        self.__refresh()


    def AddSpace(self, groupName=None):
        """Adds some empty vertical space to the widget list.

        :arg groupName: Name of the group tio which the space should be added.
                        If not specified, the space is added to the *top level*
                        widget list - see the :meth:`AddWidget` method.
        """
        if groupName is None: parentSizer = self.__widgSizer
        else:                 parentSizer = self.__groups[groupName].sizer

        parentSizer.Add((-1, 10))


    def RemoveWidget(self, widget, groupName=None):
        """Removes and destroys the specified widget from this ``WidgetList``.

        :arg widget:    The widget to remove.

        :arg groupName: Name of the group in which the widget is contained.
        """
        key = self.__makeWidgetKey(widget)

        if groupName is None:
            parentSizer = self.__widgSizer
            widgDict    = self.__widgets
        else:
            group       = self.__groups[groupName]
            parentSizer = group.sizer
            widgDict    = group.widgets

        widg = widgDict.pop(key)
        parentSizer.Detach(widg.panel)

        widg.Destroy()
        self.__refresh()


    def RemoveGroup(self, groupName):
        """Removes the specified group, and destroys all of the widgets
        contained within it.
        """
        group = self.__groups.pop(groupName)

        self.__groupSizer.Detach(group.gapSizer)
        group.parentPanel.Destroy()
        self.__refresh()


    def Clear(self):
        """Removes and destroys all widgets and groups. """
        for key in list(self.__widgets.keys()):
            widg = self.__widgets.pop(key)
            self.__widgSizer.Detach(widg.sizer)
            widg.Destroy()

        for group in self.GetGroups():
            self.RemoveGroup(group)
        self.__refresh()


    def ClearGroup(self, groupName):
        """Removes and destroys all widgets in the specified group, but
        does not remove the group.
        """
        group = self.__groups[groupName]
        group.sizer.Clear(True)
        group.widgets.clear()
        self.__refresh()


    def GroupSize(self, groupName):
        """Returns the number of widgets that have been added to the
        specified group.
        """
        return len(self.__groups[groupName].widgets)


    def IsExpanded(self, groupName):
        """Returns ``True`` if the panel for the specified group is currently
        expanded, ``False`` if it is collapsed
        """
        return self.__groups[groupName].colPanel.IsExpanded()


    def Expand(self, groupName, expand=True):
        """Expands or collapses the panel for the specified group. """
        panel = self.__groups[groupName].colPanel
        if expand: panel.Expand()
        else:      panel.Collapse()
        self.__refresh()


class _Widget:
    """The ``_Widget`` class is used internally by the :class:`WidgetList`
    to organise references to each widget in the list.
    """
    def __init__(self,
                 displayName,
                 tooltip,
                 label,
                 widget,
                 panel,
                 sizer):
        self.displayName = displayName
        self.tooltip     = tooltip
        self.label       = label
        self.widget      = widget
        self.panel       = panel
        self.sizer       = sizer


    def SetBackgroundColour(self, colour):
        self.panel.SetBackgroundColour(colour)
        self.label.SetBackgroundColour(colour)


    def SetTooltip(self, tooltip):

        self.label.SetToolTip(wx.ToolTip(tooltip))

        if isinstance(self.widget, wx.Sizer):
            for child in self.widget.GetChildren():
                child.GetWindow().SetToolTip(wx.ToolTip(tooltip))
        else:
            self.widget.SetToolTip(wx.ToolTip(tooltip))


    def Bind(self, evType, callback):
        self.panel.Bind(evType, callback)
        self.label.Bind(evType, callback)

        if isinstance(self.widget, wx.Sizer):
            for c in self.widget.GetChildren():
                c.GetWindow().Bind(evType, callback)
        else:
            self.widget.Bind(evType, callback)


    def Destroy(self):
        self.label.Destroy()
        if isinstance(self.widget, wx.Sizer):
            self.widget.Clear(True)
        else:
            self.widget.Destroy()


class _Group:
    """The ``_Group`` class is used internally by :class:`WidgetList`
    instances to represent groups of widgets that are in the list.
    """
    def __init__(self,
                 groupName,
                 displayName,
                 gapSizer,
                 parentPanel,
                 colPanel,
                 widgPanel,
                 sizer):
        self.groupName   = groupName
        self.displayName = displayName
        self.gapSizer    = gapSizer
        self.parentPanel = parentPanel
        self.colPanel    = colPanel
        self.widgPanel   = widgPanel
        self.sizer       = sizer
        self.widgets     = {}



_WidgetListChangeEvent, _EVT_WL_CHANGE_EVENT = wxevent.NewEvent()


WidgetListChangeEvent = _WidgetListChangeEvent
"""Event emitted by a :class:`WidgetList` when its contents change. """


EVT_WL_CHANGE_EVENT = _EVT_WL_CHANGE_EVENT
"""Identifier for the :data:`WidgetListChangeEvent`. """


WL_ONE_EXPANDED = 1
""":class:`WidgetList` style flag. When applied, at most one group will
be expanded at any one time.
"""

[(619, 'wx.lib.newevent.NewEvent', 'wxevent.NewEvent', True, 'import wx.lib.newevent as wxevent\n'), (77, 'wx.SystemSettings.GetColour', 'wx.SystemSettings.GetColour', False, 'import wx\n'), (89, 'wx.BoxSizer', 'wx.BoxSizer', False, 'import wx\n'), (90, 'wx.BoxSizer', 'wx.BoxSizer', False, 'import wx\n'), (92, 'wx.BoxSizer', 'wx.BoxSizer', False, 'import wx\n'), (111, 'wx.lib.scrolledpanel.ScrolledPanel.__init__', 'scrolledpanel.ScrolledPanel.__init__', True, 'import wx.lib.scrolledpanel as scrolledpanel\n'), (133, 'wx.Size', 'wx.Size', False, 'import wx\n'), (153, 'wx.ClientDC', 'wx.ClientDC', False, 'import wx\n'), (266, 'wx.Panel', 'wx.Panel', False, 'import wx\n'), (267, 'fsleyes_widgets.togglepanel.TogglePanel', 'togglepanel.TogglePanel', True, 'import fsleyes_widgets.togglepanel as togglepanel\n'), (270, 'wx.BoxSizer', 'wx.BoxSizer', False, 'import wx\n'), (274, 'wx.BoxSizer', 'wx.BoxSizer', False, 'import wx\n'), (284, 'wx.BoxSizer', 'wx.BoxSizer', False, 'import wx\n'), (366, 'wx.Panel', 'wx.Panel', False, 'import wx\n'), (367, 'wx.BoxSizer', 'wx.BoxSizer', False, 'import wx\n'), (380, 'wx.StaticText', 'wx.StaticText', False, 'import wx\n'), (568, 'wx.ToolTip', 'wx.ToolTip', False, 'import wx\n'), (574, 'wx.ToolTip', 'wx.ToolTip', False, 'import wx\n'), (572, 'wx.ToolTip', 'wx.ToolTip', False, 'import wx\n')]