From 3e40cce8560f4cc7c1ff848c0c1a36b06192a957 Mon Sep 17 00:00:00 2001
From: Paul McCarthy <pauld.mccarthy@gmail.com>
Date: Thu, 19 Nov 2015 16:33:43 +0000
Subject: [PATCH] Action/ToggleAction classes moved into their own module. The
 actions.__init__ module contains aliases for all the global Action classes.
 FSLEyesFrame no longer stores a dict of {AuiPaneInfo : ViewPanel} mappings,
 because AuiPaneInfo objects are evidently not persistent, so I cannot use
 them as dict keys. New action - ClearPerspectiveAction, which delets all
 user-saved perspectives. SavePerspectiveAction is functional.  Perspectives
 module is functional, but needs more work w.r.t. managing built-in
 perspectives.

---
 fsl/data/strings.py                     |  23 +-
 fsl/fsleyes/actions/__init__.py         | 240 +++------------
 fsl/fsleyes/actions/action.py           | 210 +++++++++++++
 fsl/fsleyes/actions/clearperspective.py |  50 +++
 fsl/fsleyes/actions/copyoverlay.py      |  12 +-
 fsl/fsleyes/actions/loadcolourmap.py    |   6 +-
 fsl/fsleyes/actions/loadperspective.py  |   6 +-
 fsl/fsleyes/actions/openfile.py         |   6 +-
 fsl/fsleyes/actions/openstandard.py     |   6 +-
 fsl/fsleyes/actions/saveoverlay.py      |  10 +-
 fsl/fsleyes/actions/saveperspective.py  |  29 +-
 fsl/fsleyes/frame.py                    | 131 +++++---
 fsl/fsleyes/perspectives.py             | 391 ++++++++++++++++++++----
 fsl/utils/settings.py                   |  19 +-
 14 files changed, 801 insertions(+), 338 deletions(-)
 create mode 100644 fsl/fsleyes/actions/action.py
 create mode 100644 fsl/fsleyes/actions/clearperspective.py

diff --git a/fsl/data/strings.py b/fsl/data/strings.py
index 48f017d3f..52722919c 100644
--- a/fsl/data/strings.py
+++ b/fsl/data/strings.py
@@ -55,6 +55,10 @@ messages = TypeDict({
     'perspectives.savePerspective'     : 'Enter a name for the perspective',
     'perspectives.applyingPerspective' : 'Applying {} perspective ...',
 
+    'ClearPerspectiveAction.confirmClear' : 'All saved perspectives will be '
+                                            'cleared! Are you sure you want '
+                                            'to continue?',
+
     'overlay.loadOverlays.loading'     : 'Loading {} ...',
     'overlay.loadOverlays.error'       : 'An error occurred loading the image '
                                          '{}\n\nDetails: {}',
@@ -193,17 +197,21 @@ titles = TypeDict({
     'MelodicClassificationPanel.saveDialog' : 'Save FIX/Melview file...',
     'MelodicClassificationPanel.loadError'  : 'Error loading FIX/Melview file',
     'MelodicClassificationPanel.saveError'  : 'Error saving FIX/Melview file',
+
+
+    'ClearPerspectiveAction.confirmClear'  : 'Clear all perspectives?',
 })
 
 
 actions = TypeDict({
 
-    'OpenFileAction'        : 'Add overlay file',
-    'OpenStandardAction'    : 'Add standard',
-    'CopyOverlayAction'     : 'Copy overlay',
-    'SaveOverlayAction'     : 'Save overlay',
-    'LoadColourMapAction'   : 'Load custom colour map',
-    'SavePerspectiveAction' : 'Save current perspective',
+    'OpenFileAction'         : 'Add overlay file',
+    'OpenStandardAction'     : 'Add standard',
+    'CopyOverlayAction'      : 'Copy overlay',
+    'SaveOverlayAction'      : 'Save overlay',
+    'LoadColourMapAction'    : 'Load custom colour map',
+    'SavePerspectiveAction'  : 'Save current perspective',
+    'ClearPerspectiveAction' : 'Clear all perspectives',
 
     'FSLEyesFrame.closeViewPanel' : 'Close',
 
@@ -785,6 +793,7 @@ melodic = TypeDict({
 })
 
 perspectives = {
-    'melview' : 'Melodic mode',
+    'default' : 'Default layout',
+    'melodic' : 'Melodic mode',
     'feat'    : 'FEAT mode',
 }
diff --git a/fsl/fsleyes/actions/__init__.py b/fsl/fsleyes/actions/__init__.py
index 489ed76de..a6a96d961 100644
--- a/fsl/fsleyes/actions/__init__.py
+++ b/fsl/fsleyes/actions/__init__.py
@@ -4,12 +4,12 @@
 #
 # Author: Paul McCarthy <pauldmccarthy@gmail.com>
 #
-"""This package provides a collection of actions, two package-level
-classes - the :class:`Action` class and the :class:`ActionProvider` class,
-and the :func:`action` and :func:`toggleAction` decorators.
+"""This package provides a collection of actions, classes - the
+:class:`.Action` class and the :class:`.ActionProvider` class, and the
+:func:`action` and :func:`toggleAction` decorators.
 
 
-The :class:`Action` class represents some sort of action which may be
+The :class:`.Action` class represents some sort of action which may be
 performed, enabled and disabled, and may be bound to a GUI menu item or
 button. The :class:`ActionProvider` class represents some entity which can
 perform one or more actions.  As the :class:`.FSLEyesPanel` class derives from
@@ -58,16 +58,16 @@ and :meth:`ActionProvider.disableAction` methods::
 
 
 It is useful to know that each method on the ``t`` instance has actually been
-replaced with an :class:`Action` instance, which encapsulates the method. Using
-this knowledge, you can access the ``Action`` instances directly::
+replaced with an :class:`.Action` instance, which encapsulates the method.
+Using this knowledge, you can access the ``Action`` instances directly::
 
     >>> t.doFirstThing.enabled = True
     >>> t.doFirstThing()
     First thing done
 
 
-The :meth:`Action.bindToWidget` method allows a widget to be bound to an
-:class:`Action`. For example::
+The :meth:`.Action.bindToWidget` method allows a widget to be bound to an
+:class:`.Action`. For example::
 
     # We're assuming here that a wx.App, and
     # a parent window, has been created
@@ -76,8 +76,8 @@ The :meth:`Action.bindToWidget` method allows a widget to be bound to an
 
 
 All bound widgets of an ``Action`` can be accessed through the
-:meth:`Action.getBoundWidgets` method, and can be unbound via the
-:meth:`Action.unbindAllWidgets` method.
+:meth:`.Action.getBoundWidgets` method, and can be unbound via the
+:meth:`.Action.unbindAllWidgets` method.
 
 
 This module also provides two classes which allow a widget to be automatically
@@ -100,6 +100,9 @@ Finally, some 'global' actions are also provided in this package:
     ~fsl.fsleyes.actions.openfile
     ~fsl.fsleyes.actions.openstandard
     ~fsl.fsleyes.actions.saveoverlay
+    ~fsl.fsleyes.actions.saveperspective
+    ~fsl.fsleyes.actions.loadperspective
+    ~fsl.fsleyes.actions.clearperspective
 """
 
 
@@ -111,6 +114,28 @@ import props
 
 import fsl.data.strings as strings
 
+import action
+import copyoverlay
+import openfile
+import openstandard
+import saveoverlay
+import loadcolourmap
+import saveperspective
+import loadperspective
+import clearperspective
+
+
+Action                 = action          .Action
+ToggleAction           = action          .ToggleAction
+CopyOverlayAction      = copyoverlay     .CopyOverlayAction
+OpenFileAction         = openfile        .OpenFileAction
+OpenStandardAction     = openstandard    .OpenStandardAction
+SaveOverlayAction      = saveoverlay     .SaveOverlayAction
+LoadColourMapAction    = loadcolourmap   .LoadColourMapAction
+SavePerspectiveAction  = saveperspective .SavePerspectiveAction
+LoadPerspectiveAction  = loadperspective .LoadPerspectiveAction
+ClearPerspectiveAction = clearperspective.ClearPerspectiveAction
+
 
 log = logging.getLogger(__name__)
 
@@ -125,194 +150,6 @@ def toggleAction(func):
     return ActionFactory(func, ToggleAction) 
 
 
-class Action(props.HasProperties):
-    """Represents an action of some sort. 
-    """
-    
-    
-    enabled = props.Boolean(default=True)
-    """Controls whether the action is currently enabled or disabled.
-    When this property is ``False`` calls to the action will
-    result in a :exc:`ActionDisabledError`.
-    """
-
-    
-    def __init__(self, func, instance=None):
-        """Create an ``Action``.
-        
-        :arg func:     The action function.
-
-        :arg instance: Object associated with the function, if this ``Action``
-                       is encapsulating an instance method.
-        """
-        self.__instance     = instance
-        self.__func         = func
-        self.__name         = func.__name__ 
-        self.__boundWidgets = []
-
-        self.addListener('enabled',
-                         'Action_{}_internal'.format(id(self)),
-                         self.__enabledChanged)
-
-        
-    def __str__(self):
-        """Returns a string representation of this ``Action``. """
-        return '{}({})'.format(type(self).__name__, self.__name)
-
-    
-    def __repr__(self):
-        """Returns a string representation of this ``Action``. """
-        return self.__str__()
-
-    
-    def __call__(self, *args, **kwargs):
-        """Calls this action. An :exc:`ActionDisabledError` will be raised
-        if :attr:`enabled` is ``False``.
-        """
-
-        if not self.enabled:
-            raise ActionDisabledError('Action {} is disabled'.format(
-                self.__name))
-
-        log.debug('Action {}.{} called'.format(
-            type(self.__instance).__name__,
-            self.__name))
-
-        if self.__instance is not None:
-            args = [self.__instance] + list(args)
-            
-        return self.__func(*args, **kwargs)
-
-    
-    def destroy(self):
-        """Must be called when this ``Action`` is no longer needed. """
-        self.unbindAllWidgets()
-        self.__func     = None
-        self.__instance = None
-
-        
-    def bindToWidget(self, parent, evType, widget):
-        """Binds this action to the given :mod:`wx` widget.
-
-        :arg parent: The :mod:`wx` object on which the event should be bound.
-        :arg evType: The :mod:`wx` event type.
-        :arg widget: The :mod:`wx` widget.
-        """
-
-        def wrappedAction(ev):
-            self()
-            
-        parent.Bind(evType, wrappedAction, widget)
-        widget.Enable(self.enabled)
-        self.__boundWidgets.append((parent, evType, widget))
-
-
-    def unbindAllWidgets(self):
-        """Unbinds all widgets which have been bound via :meth:`bindToWidget`.
-        """
-
-        import wx
-        
-        for parent, evType, widget in self.__boundWidgets:
-
-            # Only attempt to unbind if the parent
-            # and widget have not been destroyed
-            try:
-                parent.Unbind(evType, source=widget)
-            except wx.PyDeadObjectError:
-                pass
-            
-        self.__boundWidgets = []
-
-        
-    def getBoundWidgets(self):
-        """Returns a list containing all widgets which have been bound to
-        this ``Action``.
-        """
-        return [w for _, _, w in self.__boundWidgets]
-
-
-    def __enabledChanged(self, *args):
-        """Internal method which is called when the :attr:`enabled` property
-        changes. Enables/disables any bound widgets.
-        """
-
-        for _, _, widget in self.__boundWidgets:
-            widget.Enable(self.enabled)
-
-    
-class ToggleAction(Action):
-    """A ``ToggleAction`` an ``Action`` which is intended to encapsulate
-    actions that toggle some sort of state. For example, a ``ToggleAction``
-    could be used to encapsulate an action which opens and/or closes a dialog
-    window.
-    """
-
-
-    toggled = props.Boolean(default=False)
-    """Boolean which tracks the current state of the ``ToggleAction``. """
-
-
-    def __init__(self, *args, **kwargs):
-        """Create a ``ToggleAction``. All arguments are passed to
-        :meth:`Action.__init__`.
-        """
-        
-        Action.__init__(self, *args, **kwargs)
-        
-        self.addListener('toggled',
-                         'ToggleAction_{}_internal'.format(id(self)),
-                         self.__toggledChanged)
-
-        
-    def __call__(self, *args, **kwargs):
-        """Call this ``ToggleAction`. The value of the :attr:`toggled` property
-        is flipped.
-        """
-
-        # Copy the toggled value before running
-        # the action, in case it gets inadvertently
-        # changed
-        toggled      = self.toggled
-        result       = Action.__call__(self, *args, **kwargs)
-        self.toggled = not toggled
-            
-        return result
-
-
-    def bindToWidget(self, parent, evType, widget):
-        """Bind this ``ToggleAction`` to a widget. If the widget is a
-        ``wx.MenuItem``, its ``Check`` is called whenever the :attr:`toggled`
-        state changes.
-        """
-
-        import wx
-        
-        Action.bindToWidget(self, parent, evType, widget)
-
-        if isinstance(widget, wx.MenuItem):
-            widget.Check(self.toggled)
-
-        
-    def __toggledChanged(self, *a):
-        """Internal method called when :attr:`toggled` changes. Updates the
-        state of any bound widgets.
-        """
-        
-        import wx
-        import pwidgets.bitmaptoggle as bmptoggle
-        
-        for widget in self.getBoundWidgets():
-
-            if isinstance(widget, wx.MenuItem):
-                widget.Check(self.toggled)
-                
-            elif isinstance(widget, (wx.CheckBox,
-                                     wx.ToggleButton,
-                                     bmptoggle.BitmapToggleButton)):
-                widget.SetValue(self.toggled)
-
-
 class ActionProvider(object):
     """The ``ActionProvider`` class is intended to be used as a base class for
     classes which contain actions. The :func:`action` and :func:`toggleAction`
@@ -360,13 +197,6 @@ class ActionProvider(object):
         return acts
 
 
-class ActionDisabledError(Exception):
-    """Exception raised when an attempt is made to call a disabled
-    :class:`Action`.
-    """
-    pass
-
-
 class ActionFactory(object):
     """The ``ActionFactory`` is used by the :func:`action` and
     :func:`toggleAction` decorators. Its job is to create :class:`Action`
diff --git a/fsl/fsleyes/actions/action.py b/fsl/fsleyes/actions/action.py
new file mode 100644
index 000000000..93f0c1295
--- /dev/null
+++ b/fsl/fsleyes/actions/action.py
@@ -0,0 +1,210 @@
+#!/usr/bin/env python
+#
+# action.py - The Action and ToggleAction classes.
+#
+# Author: Paul McCarthy <pauldmccarthy@gmail.com>
+#
+"""This module provides the :class:`Action` and :class:`ToggleAction` classes.
+See the :mod:`.actions` package documentation for more details.
+"""
+
+import logging
+
+import props
+
+
+log = logging.getLogger(__name__)
+
+
+class ActionDisabledError(Exception):
+    """Exception raised when an attempt is made to call a disabled
+    :class:`Action`.
+    """
+    pass
+
+
+class Action(props.HasProperties):
+    """Represents an action of some sort. """
+    
+    
+    enabled = props.Boolean(default=True)
+    """Controls whether the action is currently enabled or disabled.
+    When this property is ``False`` calls to the action will
+    result in a :exc:`ActionDisabledError`.
+    """
+
+    
+    def __init__(self, func, instance=None):
+        """Create an ``Action``.
+        
+        :arg func:     The action function.
+
+        :arg instance: Object associated with the function, if this ``Action``
+                       is encapsulating an instance method.
+        """
+        self.__instance     = instance
+        self.__func         = func
+        self.__name         = func.__name__ 
+        self.__boundWidgets = []
+
+        self.addListener('enabled',
+                         'Action_{}_internal'.format(id(self)),
+                         self.__enabledChanged)
+
+        
+    def __str__(self):
+        """Returns a string representation of this ``Action``. """
+        return '{}({})'.format(type(self).__name__, self.__name)
+
+    
+    def __repr__(self):
+        """Returns a string representation of this ``Action``. """
+        return self.__str__()
+
+    
+    def __call__(self, *args, **kwargs):
+        """Calls this action. An :exc:`ActionDisabledError` will be raised
+        if :attr:`enabled` is ``False``.
+        """
+
+        if not self.enabled:
+            raise ActionDisabledError('Action {} is disabled'.format(
+                self.__name))
+
+        log.debug('Action {}.{} called'.format(
+            type(self.__instance).__name__,
+            self.__name))
+
+        if self.__instance is not None:
+            args = [self.__instance] + list(args)
+            
+        return self.__func(*args, **kwargs)
+
+    
+    def destroy(self):
+        """Must be called when this ``Action`` is no longer needed. """
+        self.unbindAllWidgets()
+        self.__func     = None
+        self.__instance = None
+
+        
+    def bindToWidget(self, parent, evType, widget):
+        """Binds this action to the given :mod:`wx` widget.
+
+        :arg parent: The :mod:`wx` object on which the event should be bound.
+        :arg evType: The :mod:`wx` event type.
+        :arg widget: The :mod:`wx` widget.
+        """
+
+        def wrappedAction(ev):
+            self()
+            
+        parent.Bind(evType, wrappedAction, widget)
+        widget.Enable(self.enabled)
+        self.__boundWidgets.append((parent, evType, widget))
+
+
+    def unbindAllWidgets(self):
+        """Unbinds all widgets which have been bound via :meth:`bindToWidget`.
+        """
+
+        import wx
+        
+        for parent, evType, widget in self.__boundWidgets:
+
+            # Only attempt to unbind if the parent
+            # and widget have not been destroyed
+            try:
+                parent.Unbind(evType, source=widget)
+            except wx.PyDeadObjectError:
+                pass
+            
+        self.__boundWidgets = []
+
+        
+    def getBoundWidgets(self):
+        """Returns a list containing all widgets which have been bound to
+        this ``Action``.
+        """
+        return [w for _, _, w in self.__boundWidgets]
+
+
+    def __enabledChanged(self, *args):
+        """Internal method which is called when the :attr:`enabled` property
+        changes. Enables/disables any bound widgets.
+        """
+
+        for _, _, widget in self.__boundWidgets:
+            widget.Enable(self.enabled)
+
+    
+class ToggleAction(Action):
+    """A ``ToggleAction`` an ``Action`` which is intended to encapsulate
+    actions that toggle some sort of state. For example, a ``ToggleAction``
+    could be used to encapsulate an action which opens and/or closes a dialog
+    window.
+    """
+
+
+    toggled = props.Boolean(default=False)
+    """Boolean which tracks the current state of the ``ToggleAction``. """
+
+
+    def __init__(self, *args, **kwargs):
+        """Create a ``ToggleAction``. All arguments are passed to
+        :meth:`Action.__init__`.
+        """
+        
+        Action.__init__(self, *args, **kwargs)
+        
+        self.addListener('toggled',
+                         'ToggleAction_{}_internal'.format(id(self)),
+                         self.__toggledChanged)
+
+        
+    def __call__(self, *args, **kwargs):
+        """Call this ``ToggleAction`. The value of the :attr:`toggled` property
+        is flipped.
+        """
+
+        # Copy the toggled value before running
+        # the action, in case it gets inadvertently
+        # changed
+        toggled      = self.toggled
+        result       = Action.__call__(self, *args, **kwargs)
+        self.toggled = not toggled
+            
+        return result
+
+
+    def bindToWidget(self, parent, evType, widget):
+        """Bind this ``ToggleAction`` to a widget. If the widget is a
+        ``wx.MenuItem``, its ``Check`` is called whenever the :attr:`toggled`
+        state changes.
+        """
+
+        import wx
+        
+        Action.bindToWidget(self, parent, evType, widget)
+
+        if isinstance(widget, wx.MenuItem):
+            widget.Check(self.toggled)
+
+        
+    def __toggledChanged(self, *a):
+        """Internal method called when :attr:`toggled` changes. Updates the
+        state of any bound widgets.
+        """
+        
+        import wx
+        import pwidgets.bitmaptoggle as bmptoggle
+        
+        for widget in self.getBoundWidgets():
+
+            if isinstance(widget, wx.MenuItem):
+                widget.Check(self.toggled)
+                
+            elif isinstance(widget, (wx.CheckBox,
+                                     wx.ToggleButton,
+                                     bmptoggle.BitmapToggleButton)):
+                widget.SetValue(self.toggled)
diff --git a/fsl/fsleyes/actions/clearperspective.py b/fsl/fsleyes/actions/clearperspective.py
new file mode 100644
index 000000000..12abd6def
--- /dev/null
+++ b/fsl/fsleyes/actions/clearperspective.py
@@ -0,0 +1,50 @@
+#!/usr/bin/env python
+#
+# clearperspectives.py - The ClearPerspectiveAction class.
+#
+# Author: Paul McCarthy <pauldmccarthy@gmail.com>
+#
+"""This module provides the :class:`ClearPerspectiveAction`, which allows
+the user to clear/delete all saved perspectives.
+"""
+
+
+import wx
+
+import fsl.data.strings         as strings
+import                             action
+import fsl.fsleyes.perspectives as perspectives
+
+
+class ClearPerspectiveAction(action.Action):
+    """The ``ClearPerspectiveAction`` allows the user to delete all saved
+    perspectives. 
+    """
+    
+    def __init__(self, frame):
+        """Create a ``ClearPerspectiveAction``. """
+        action.Action.__init__(self, func=self.__clearPerspectives)
+
+        self.__frame = frame
+
+
+    def __clearPerspectives(self):
+        """Deletes all saved perspectives. Gets the user to confirm that
+        they want to proceed before doing so.
+        """
+
+        dlg = wx.MessageDialog(
+            wx.GetTopLevelWindows()[0],
+            message=strings.messages[self, 'confirmClear'],
+            caption=strings.titles[  self, 'confirmClear'],
+            style=(wx.ICON_WARNING |
+                   wx.YES_NO       |
+                   wx.NO_DEFAULT))
+
+        if dlg.ShowModal() != wx.ID_YES:
+            return
+
+        for p in perspectives.getAllPerspectives():
+            perspectives.removePerspective(p)
+
+        self.__frame.refreshPerspectiveMenu()
diff --git a/fsl/fsleyes/actions/copyoverlay.py b/fsl/fsleyes/actions/copyoverlay.py
index 7c78fb11d..18107fe92 100644
--- a/fsl/fsleyes/actions/copyoverlay.py
+++ b/fsl/fsleyes/actions/copyoverlay.py
@@ -9,13 +9,13 @@ which creates a copy of the currently selected overlay.
 """
 
 
-import numpy               as np
+import numpy          as np
 
-import fsl.fsleyes.actions as actions
-import fsl.data.image      as fslimage
+import                   action
+import fsl.data.image as fslimage
 
 
-class CopyOverlayAction(actions.Action):
+class CopyOverlayAction(action.Action):
     """The ``CopyOverlayAction`` does as its name suggests - it creates a
     copy of the currently selected overlay.
     """
@@ -27,7 +27,7 @@ class CopyOverlayAction(actions.Action):
         :arg overlayList: The :class:`.OverlayList`.
         :arg displayCtx:  The :class:`.DisplayContext`.
         """
-        actions.Action.__init__(self, self.__copyOverlay)
+        action.Action.__init__(self, self.__copyOverlay)
 
         self.__overlayList = overlayList
         self.__displayCtx  = displayCtx
@@ -50,7 +50,7 @@ class CopyOverlayAction(actions.Action):
 
         self.__displayCtx .removeListener('selectedOverlay', self.__name)
         self.__overlayList.removeListener('overlays',        self.__name)
-        actions.Action.destroy(self)
+        action.Action.destroy(self)
 
         
     def __selectedOverlayChanged(self, *a):
diff --git a/fsl/fsleyes/actions/loadcolourmap.py b/fsl/fsleyes/actions/loadcolourmap.py
index 3f6c416bd..5742db668 100644
--- a/fsl/fsleyes/actions/loadcolourmap.py
+++ b/fsl/fsleyes/actions/loadcolourmap.py
@@ -12,7 +12,7 @@ import logging
 import os.path as op
 
 import fsl.data.strings       as strings
-import fsl.fsleyes.actions    as actions
+import                           action
 import fsl.fsleyes.colourmaps as fslcmap
 
 
@@ -22,7 +22,7 @@ log = logging.getLogger(__name__)
 _stringID = 'actions.loadcolourmap.'
 
 
-class LoadColourMapAction(actions.Action):
+class LoadColourMapAction(action.Action):
     """The ``LoadColourMapAction`` allows the user to select a colour
     map file and give it a name.
 
@@ -38,7 +38,7 @@ class LoadColourMapAction(actions.Action):
         :arg overlayList: The :class:`.OverlayList`.
         :arg displayCtx:  The :class:`.DisplayContext`. 
         """
-        actions.Action.__init__(self, self.__loadColourMap)
+        action.Action.__init__(self, self.__loadColourMap)
 
         self.__overlayList = overlayList
         self.__displayCtx  = displayCtx 
diff --git a/fsl/fsleyes/actions/loadperspective.py b/fsl/fsleyes/actions/loadperspective.py
index ee7e37fcf..e4e0b2f4a 100644
--- a/fsl/fsleyes/actions/loadperspective.py
+++ b/fsl/fsleyes/actions/loadperspective.py
@@ -8,11 +8,11 @@
 """
 
 
-import fsl.fsleyes.actions      as actions
+import                             action
 import fsl.fsleyes.perspectives as perspectives
 
 
-class LoadPerspectiveAction(actions.Action):
+class LoadPerspectiveAction(action.Action):
     """
     """
 
@@ -23,7 +23,7 @@ class LoadPerspectiveAction(actions.Action):
         self.__frame       = frame
         self.__perspective = perspective
          
-        actions.Action.__init__(self, self.__loadPerspective)
+        action.Action.__init__(self, self.__loadPerspective)
 
         
     def __loadPerspective(self):
diff --git a/fsl/fsleyes/actions/openfile.py b/fsl/fsleyes/actions/openfile.py
index 7e0e127c5..d74adfec3 100644
--- a/fsl/fsleyes/actions/openfile.py
+++ b/fsl/fsleyes/actions/openfile.py
@@ -9,10 +9,10 @@ load overlay files into the :class:`.OverlayList`.
 """
 
 
-import fsl.fsleyes.actions as actions
+import action
 
 
-class OpenFileAction(actions.Action):
+class OpenFileAction(action.Action):
     """The ``OpenFileAction`` allows the user to add files to the
     :class:`.OverlayList`. This functionality is provided by the
     :meth:`.OverlayList.addOverlays` method.
@@ -24,7 +24,7 @@ class OpenFileAction(actions.Action):
         :arg overlayList: The :class:`.OverlayList`.
         :arg displayCtx:  The :class:`.DisplayContext`.
         """
-        actions.Action.__init__(self, self.__openFile)
+        action.Action.__init__(self, self.__openFile)
 
         self.__overlayList = overlayList
         self.__displayCtx  = displayCtx
diff --git a/fsl/fsleyes/actions/openstandard.py b/fsl/fsleyes/actions/openstandard.py
index 3603264c2..66032d814 100644
--- a/fsl/fsleyes/actions/openstandard.py
+++ b/fsl/fsleyes/actions/openstandard.py
@@ -13,10 +13,10 @@ to load in standard space images from the ``$FSLDIR/data/standard/`` directory.
 import os
 import os.path as op
 
-import fsl.fsleyes.actions as actions
+import action
 
 
-class OpenStandardAction(actions.Action):
+class OpenStandardAction(action.Action):
     """The ``OpenStandardAction`` prompts the user to open one or more
     overlays, using ``$FSLDIR/data/standard/`` as the default directory.
     """
@@ -28,7 +28,7 @@ class OpenStandardAction(actions.Action):
         :arg overlayList: The :class:`.OverlayList`.
         :arg displayCtx:  The :class:`.DisplayContext`.
         """ 
-        actions.Action.__init__(self, self.__openStandard)
+        action.Action.__init__(self, self.__openStandard)
 
         self.__overlayList = overlayList
         self.__displayCtx  = displayCtx
diff --git a/fsl/fsleyes/actions/saveoverlay.py b/fsl/fsleyes/actions/saveoverlay.py
index 639fb2f0f..f3afb29a5 100644
--- a/fsl/fsleyes/actions/saveoverlay.py
+++ b/fsl/fsleyes/actions/saveoverlay.py
@@ -9,11 +9,11 @@ to save the currently selected overlay.
 """
 
 
-import fsl.data.image      as fslimage
-import fsl.fsleyes.actions as actions
+import fsl.data.image as fslimage
+import                   action
 
 
-class SaveOverlayAction(actions.Action):
+class SaveOverlayAction(action.Action):
     """The ``SaveOverlayAction`` allows the user to save the currently
     selected overlay, if it has been edited, or only exists in memory.
     """
@@ -25,7 +25,7 @@ class SaveOverlayAction(actions.Action):
         :arg overlayList: The :class:`.OverlayList`.
         :arg displayCtx:  The :class:`.DisplayContext`. 
         """
-        actions.Action.__init__(self, self.__saveOverlay)
+        action.Action.__init__(self, self.__saveOverlay)
 
         self.__overlayList = overlayList
         self.__displayCtx  = displayCtx
@@ -48,7 +48,7 @@ class SaveOverlayAction(actions.Action):
 
         self.__displayCtx .removeListener('selectedOverlay', self.__name)
         self.__overlayList.removeListener('overlays',        self.__name)
-        actions.Action.destroy(self)
+        action.Action.destroy(self)
 
         
     def __selectedOverlayChanged(self, *a):
diff --git a/fsl/fsleyes/actions/saveperspective.py b/fsl/fsleyes/actions/saveperspective.py
index 4f517c1eb..f121ccaea 100644
--- a/fsl/fsleyes/actions/saveperspective.py
+++ b/fsl/fsleyes/actions/saveperspective.py
@@ -1,34 +1,41 @@
 #!/usr/bin/env python
 #
-# saveperspective.py -
+# saveperspective.py - The SavePerspectiveAction
 #
 # Author: Paul McCarthy <pauldmccarthy@gmail.com>
 #
-"""
+"""This module provides the :class:`SavePerspectiveAction` class, an action
+which allows the user to save the current perspective.
 """
 
 import wx
 
 import fsl.data.strings         as strings
-import fsl.fsleyes.actions      as actions
+import                             action
 import fsl.fsleyes.perspectives as perspectives
 
 
-class SavePerspectiveAction(actions.Action):
-    """
+class SavePerspectiveAction(action.Action):
+    """The ``SavePerspectiveAction`` allows the user to save the current
+    :class:`.FSLEyesFrame` layout as a perspective, so it can be restored
+    at a later time. See the :mod:`.perspectives` module.
     """
 
     def __init__(self, frame):
-        """
+        """Create a ``SavePerspectiveAction``.
+
+        :arg frame: The :class:`.FSLEyesFrame`.
         """
 
         self.__frame = frame
          
-        actions.Action.__init__(self, self.__savePerspective)
+        action.Action.__init__(self, self.__savePerspective)
 
         
     def __savePerspective(self):
-        """
+        """Save the current :class:`.FSLEyesFrame` layout as a perspective.
+        The user is prompted to enter a name, and the current frame layout
+        is saved via the :func:`.perspectives.savePerspective` function.
         """
 
         dlg = wx.TextEntryDialog(
@@ -43,4 +50,10 @@ class SavePerspectiveAction(actions.Action):
         if name.strip() == '':
             return
 
+        # TODO Prevent using built-in perspective names
+
+        # TODO Name collision - confirm overwrite
+
         perspectives.savePerspective(self.__frame, name)
+
+        self.__frame.refreshPerspectiveMenu()
diff --git a/fsl/fsleyes/frame.py b/fsl/fsleyes/frame.py
index 990493b92..a10174cf5 100644
--- a/fsl/fsleyes/frame.py
+++ b/fsl/fsleyes/frame.py
@@ -10,7 +10,6 @@ for FSLeyes.
 
 
 import logging
-import collections
 
 import wx
 import wx.lib.agw.aui     as aui
@@ -20,12 +19,6 @@ import fsl.utils.settings as fslsettings
 
 import views
 import actions
-import actions.copyoverlay     as copyoverlay
-import actions.openfile        as openfile
-import actions.openstandard    as openstandard
-import actions.saveoverlay     as saveoverlay
-import actions.loadperspective as loadperspective
-import actions.saveperspective as saveperspective 
 import perspectives
 
 import displaycontext
@@ -84,6 +77,7 @@ class FSLEyesFrame(wx.Frame):
        addViewPanel
        removeViewPanel 
        getAuiManager
+       refreshPerspectiveMenu
     """
 
     
@@ -129,17 +123,20 @@ class FSLEyesFrame(wx.Frame):
 
         # Keeping track of all open view panels
         # 
-        # The __viewPanels dict contains
-        # {AuiPaneInfo : ViewPanel} mappings
+        # The __viewPanels list contains all
+        # [ViewPanel] instances
         #
         # The other dicts contain
         # {ViewPanel : something} mappings
         # 
-        self.__viewPanels     = collections.OrderedDict()
+        self.__viewPanels     = []
         self.__viewPanelDCs   = {}
         self.__viewPanelMenus = {}
         self.__viewPanelIDs   = {}
 
+        self.__menuBar   = None
+        self.__perspMenu = None
+        
         self.__makeMenuBar()
         self.__restoreState(restore)
 
@@ -151,7 +148,7 @@ class FSLEyesFrame(wx.Frame):
         """Returns a list of all :class:`.ViewPanel` instances that are
         currenlty displayed in this ``FSLEyesFrame``.
         """
-        return list(self.__viewPanels.values())
+        return list(self.__viewPanels)
 
 
     def getViewPanelInfo(self, viewPanel):
@@ -171,8 +168,11 @@ class FSLEyesFrame(wx.Frame):
     def removeViewPanel(self, viewPanel):
         """Removes the given :class:`.ViewPanel` from this ``FSLEyesFrame``.
         """
+
         paneInfo = self.__auiManager.GetPane(viewPanel)
-        self.__onViewPanelClose(    paneInfo=paneInfo)
+        
+        self.__onViewPanelClose(panel=viewPanel)
+        
         self.__auiManager.ClosePane(paneInfo)
         self.__auiManager.Update() 
 
@@ -244,7 +244,8 @@ class FSLEyesFrame(wx.Frame):
         # first key is the AuiPaneInfo of
         # the first panel that was added.
         else:
-            self.__viewPanels.keys()[0].CaptionVisible(True)
+            self.__auiManager.GetPane(self.__viewPanels[0])\
+                             .CaptionVisible(True)
 
         # If this is not the first view panel,
         # give it a sensible initial size.
@@ -261,9 +262,9 @@ class FSLEyesFrame(wx.Frame):
             else:
                 paneInfo.Right().BestSize(width / 3, -1)
 
-        self.__viewPanels[  paneInfo] = panel
-        self.__viewPanelDCs[panel]    = childDC
-        self.__viewPanelIDs[panel]    = panelId
+        self.__viewPanels.append(panel)
+        self.__viewPanelDCs[     panel] = childDC
+        self.__viewPanelIDs[     panel] = panelId
         
         self.__auiManager.AddPane(panel, paneInfo)
         self.__addViewPanelMenu(  panel, title)
@@ -273,6 +274,11 @@ class FSLEyesFrame(wx.Frame):
         self.Thaw()
 
 
+    def refreshPerspectiveMenu(self):
+        """Re-creates the *View -> Perspectives* sub-menu. """
+        self.__makePerspectiveMenu()
+
+
     def __addViewPanelMenu(self, panel, title):
         """Called by :meth:`addViewPanel`. Adds a menu item for the newly
         created :class:`.ViewPanel` instance.
@@ -341,7 +347,7 @@ class FSLEyesFrame(wx.Frame):
         self.Bind(wx.EVT_MENU, closeViewPanel, closeItem)
     
 
-    def __onViewPanelClose(self, ev=None, paneInfo=None):
+    def __onViewPanelClose(self, ev=None, panel=None):
         """Called when the user closes a :class:`.ViewPanel`.
 
         The :meth:`__addViewPanelMenu` method adds a *Close* menu item
@@ -357,16 +363,26 @@ class FSLEyesFrame(wx.Frame):
 
         if ev is not None:
             ev.Skip()
+            
+            # Undocumented - the window associated with an
+            # AuiPaneInfo is available as an attribute called
+            # 'window'. Honestly, I don't know why there is
+            # not a method available on the AuiPaneInfo or
+            # AuiManager to retrieve a managed Window given
+            # the associated AuiPaneInfo object.
             paneInfo = ev.GetPane()
-
-        panel = self .__viewPanels.pop(paneInfo, None)        
+            panel    = paneInfo.window
+            
+        elif panel is not None:
+            paneInfo = self.__auiManager.GetPane(panel)
 
         if panel is None:
             return
 
-        self       .__viewPanelIDs  .pop(panel)
-        dctx = self.__viewPanelDCs  .pop(panel)
-        menu = self.__viewPanelMenus.pop(panel, None)
+        self       .__viewPanels    .remove(panel)
+        self       .__viewPanelIDs  .pop(   panel)
+        dctx = self.__viewPanelDCs  .pop(   panel)
+        menu = self.__viewPanelMenus.pop(   panel, None)
 
         log.debug('Destroying {} ({}) and '
                   'associated DisplayContext ({})'.format(
@@ -392,7 +408,7 @@ class FSLEyesFrame(wx.Frame):
         wasCentre = paneInfo.dock_direction_get() == aui.AUI_DOCK_CENTRE
         
         if numPanels >= 1 and wasCentre:
-            paneInfo = self.__viewPanels.keys()[0]
+            paneInfo = self.__auiManager.GetPane(self.__viewPanels[0])
             paneInfo.Centre().Dockable(False).CaptionVisible(numPanels > 1)
 
         
@@ -414,7 +430,7 @@ class FSLEyesFrame(wx.Frame):
         # It's nice to explicitly clean
         # up our FSLEyesPanels, otherwise
         # they'll probably complain
-        for panel in self.__viewPanels.values():
+        for panel in self.__viewPanels:
             panel.destroy()
 
         
@@ -566,7 +582,6 @@ class FSLEyesFrame(wx.Frame):
         if restore:
             self.addViewPanel(views.OrthoPanel)
 
-
             
     def __makeMenuBar(self):
         """Constructs a bunch of menu items for this ``FSLEyesFrame``."""
@@ -578,6 +593,9 @@ class FSLEyesFrame(wx.Frame):
         viewMenu        = wx.Menu()
         perspectiveMenu = wx.Menu() 
         settingsMenu    = wx.Menu()
+
+        self.__menuBar   = menuBar
+        self.__perspMenu = perspectiveMenu
         
         menuBar.Append(fileMenu,     'File')
         menuBar.Append(viewMenu,     'View')
@@ -588,10 +606,10 @@ class FSLEyesFrame(wx.Frame):
         self.__settingsMenu = settingsMenu
 
         # Global actions
-        actionz = [openfile    .OpenFileAction,
-                   openstandard.OpenStandardAction,
-                   copyoverlay .CopyOverlayAction,
-                   saveoverlay .SaveOverlayAction]
+        actionz = [actions.OpenFileAction,
+                   actions.OpenStandardAction,
+                   actions.CopyOverlayAction,
+                   actions.SaveOverlayAction]
  
         for action in actionz:
             menuItem  = fileMenu.Append(wx.ID_ANY, strings.actions[action])
@@ -614,17 +632,54 @@ class FSLEyesFrame(wx.Frame):
 
         # Perspectives
         viewMenu.AppendSubMenu(perspectiveMenu, 'Perspectives')
-        for persp in perspectives.getAllPerspectives():
+        self.__makePerspectiveMenu()
+
+        
+    def __makePerspectiveMenu(self):
+        """Re-creates the *View->Perspectives* menu. """
+
+        perspMenu = self.__perspMenu
+
+        # Remove any existing menu items
+        for item in perspMenu.GetMenuItems():
+            perspMenu.DeleteItem(item)
+
+        builtIns = perspectives.BUILT_IN_PERSPECTIVES.keys()
+        saved    = perspectives.getAllPerspectives()
+
+        # Add a menu item to load each built-in perspectives
+        for persp in builtIns:
+            menuItem  = perspMenu.Append(
+                wx.ID_ANY, strings.perspectives.get(persp, persp))
+            
+            actionObj = actions.LoadPerspectiveAction(self, persp)
+            actionObj.bindToWidget(self, wx.EVT_MENU, menuItem)
+
+        if len(builtIns) > 0:
+            perspMenu.AppendSeparator()
+
+        # Add a menu item to load each saved perspective
+        for persp in saved:
             
-            menuItem  = perspectiveMenu.Append(
+            menuItem  = perspMenu.Append(
                 wx.ID_ANY, strings.perspectives.get(persp, persp))
-            actionObj = loadperspective.LoadPerspectiveAction(self, persp)
+            actionObj = actions.LoadPerspectiveAction(self, persp)
             actionObj.bindToWidget(self, wx.EVT_MENU, menuItem)
 
-        # Save perspective
-        perspectiveMenu.AppendSeparator()
-        savePerspAction   = saveperspective.SavePerspectiveAction(self)
-        savePerspMenuItem = perspectiveMenu.Append(
-            wx.ID_ANY, strings.actions[savePerspAction])
+        # Add menu items for other perspective
+        # operations, but separate them from the
+        # existing perspectives
+        if len(saved) > 0:
+            perspMenu.AppendSeparator()
 
-        savePerspAction.bindToWidget(self, wx.EVT_MENU, savePerspMenuItem)
+        # TODO: Delete a single perspective?
+        #       Save to/load from file? 
+        perspActions = [actions.SavePerspectiveAction,
+                        actions.ClearPerspectiveAction]
+
+        for pa in perspActions:
+
+            actionObj     = pa(self)
+            perspMenuItem = perspMenu.Append(wx.ID_ANY, strings.actions[pa])
+            
+            actionObj.bindToWidget(self, wx.EVT_MENU, perspMenuItem)
diff --git a/fsl/fsleyes/perspectives.py b/fsl/fsleyes/perspectives.py
index 29c0f804e..f8ca8d6de 100644
--- a/fsl/fsleyes/perspectives.py
+++ b/fsl/fsleyes/perspectives.py
@@ -1,16 +1,31 @@
 #!/usr/bin/env python
 #
-# perspectives.py -
+# perspectives.py - The perspectives API.
 #
 # Author: Paul McCarthy <pauldmccarthy@gmail.com>
 #
+"""This module provides functions for managing *perspectives*, view and
+control panel layouts for *FSLeyes*.
 
-import logging
+.. autosummary::
+   :nosignatures:
+
+   getAllPerspectives
+   loadPerspective
+   savePerspective
+   removePerspective
+   serialisePerspective
+   deserialisePerspective
+"""
 
 
+import logging
+import textwrap
+import collections
+
 import fsl.utils.settings   as fslsettings
-import fsl.fsleyes.views    as views
-import fsl.fsleyes.controls as controls
+import fsl.utils.dialog     as fsldlg
+import fsl.data.strings     as strings
 
 
 log = logging.getLogger(__name__)
@@ -26,91 +41,357 @@ def getAllPerspectives():
     perspectives = perspectives.split(',')
     perspectives = [p.strip() for p in perspectives]
     perspectives = [p         for p in perspectives if p != '']
-    
-    return perspectives
 
+    uniq = []
+    for p in perspectives:
+        if p not in uniq:
+            uniq.append(p)
+    
+    return uniq
 
 
 def loadPerspective(frame, name):
+    """
+    """
     log.debug('Loading perspective {}'.format(name))
 
     persp = fslsettings.read('fsleyes.perspectives.{}'.format(name), None)
+    log.debug('Serialised perspective:\n{}'.format(persp))
+              
+    persp = deserialisePerspective(persp)
+
+    frameChildren, frameLayout, vpChildrens, vpLayouts = persp
+
+    dlg = fsldlg.SimpleMessageDialog(
+        frame,
+        strings.messages['perspectives.applyingPerspective'].format(name))
+    dlg.Show()
 
+    # Clear all existing view
+    # panels from the frame
+    for vp in frame.getViewPanels():
+        print 'Removing view panel {}'.format(type(vp).__name__)
+        frame.removeViewPanel(vp)
 
+    # Add all of the view panels
+    # specified in the perspective
+    for vp in frameChildren:
+        frame.addViewPanel(vp)
+
+    # Apply the layout to those view panels
+    frame.getAuiManager().LoadPerspective(frameLayout)
+
+    # For each view panel, add all of the
+    # control panels, and lay them out
+    viewPanels = frame.getViewPanels()
+    for vp, vpChildren, vpLayout in zip(viewPanels, vpChildrens, vpLayouts):
+        
+        for child in vpChildren:
+            
+            _addControlPanel(vp, child)
+            vp.getAuiManager().LoadPerspective(vpLayout)
+
+    dlg.Close()
+    dlg.Destroy()
+
+            
 def savePerspective(frame, name):
+    """
+    """
     
     log.debug('Saving current perspective with name {}'.format(name))
     persp = serialisePerspective(frame)
     fslsettings.write('fsleyes.perspectives.{}'.format(name), persp)
 
+    _addToPerspectivesList(name)
+
+    log.debug('Serialised perspective:\n{}'.format(persp))
+
+    
+def removePerspective(name):
+    """
+    """
+    log.debug('Deleting perspective with name {}'.format(name))
+
+    fslsettings.delete('fsleyes.perspectives.{}'.format(name))
+    _removeFromPerspectivesList(name)
 
     
 def serialisePerspective(frame):
-    log.debug('Serialising current perspective')
+    """
+    """
+
+    # Written against wx.lib.agw.aui.AuiManager as it
+    # exists in wxPython 3.0.2.0.
+    #
+    # FSLEyes uses a hierarchy of AuiManager instances
+    # for its layout - the FSLEyesFrame uses an AuiManager
+    # to lay out ViewPanel instances, and each of these
+    # ViewPanels use their own AuiManager to lay out
+    # control panels. The layout for a single AuiManager
+    # can be serialised to a string via the
+    # AuiManager.SavePerspective and AuiManager.SavePaneInfo
+    # methods.
+    #
+    # An Aui perspective string consists of:
+    #   - A name.
+    #
+    #   - A set of key-value set of key-value pairs defining
+    #     the top level panel layout.
+    #
+    #   - A set of key-value pairs for each pane,
+    #     defining its layout. the AuiManager.SavePaneInfo
+    #     method returns this for a single pane.
+    # 
+    # These are all encoded in a single string, with
+    # the above components separated with '|'
+    # characters, and the pane-level key-value pairs
+    # separated with a ';' character. For example:
+    #
+    # layoutName|key1=value1|name=Pane1;caption=Pane 1|\
+    # name=Pane2;caption=Pane 2|doc_size(5,0,0)=22|
+    #
+    # The following code is going to query each of the
+    # AuiManagers, and extract the following:
+    # 
+    #    - A layout string for the FSLEyesFrame
+    #
+    #    - A string containing a comma-separated list of
+    #      ViewPanels (class names, in the same order as
+    #      they are specified in the frame layout string)
+    #
+    #    - For each ViewPanel:
+    #
+    #       - A layout string for the ViewPanel
+    #       - A string containing a comma-separated list
+    #         of ControlPanels (class names, in the same
+    #         order as specified in the ViewPanel layout
+    #         string)
+    #
+    # 
+    # We'll start by defining this silly function, which
+    # takes an ``AuiManager`` layout string, and a list
+    # of the children which are being managed by the
+    # AuiManager, and makes sure that the order of the
+    # child pane layout specifications in the string is
+    # the same as the order of the children in the list.
+    def patchLayoutString(auiMgr, panels):
+
+        layoutStr = auiMgr.SavePerspective()
+
+        # The different sections of the string
+        # returned by SavePerspective are
+        # separated with a '|' character.
+        sections = layoutStr.split('|')
+        sections = [s.strip() for s in sections]
+        sections = [s for s in sections if s != '']
+
+        # Here, we identify sections which specify
+        # the layout of a child pane, remove them,
+        # and patch them back in, in the order that
+        # the child panels are specified in the list.
+        pi = 0
+        for si, s in enumerate(sections):
+            if s.find('name=') > -1:
+                panel        = panels[pi]
+                panelInfo    = auiMgr.GetPane(panel)
+                panelLayout  = auiMgr.SavePaneInfo(panelInfo)
+                pi          += 1
+                sections[si] = panelLayout
+
+        # Now the panel layouts in our layout string
+        # are in the same order as our list of view
+        # panels - we can re-join the layout string
+        # sections, and we're done.
+        return '|'.join(sections) + '|'
 
+    # Now we can start extracting the layout information.
+    # We start with the FSLEyesFrame layout.
     auiMgr     = frame.getAuiManager()
     viewPanels = frame.getViewPanels()
 
-    frameLayout = auiMgr.SavePerspective()
+    # Generate the frame layout string, and a
+    # list of the children of the frame
+    frameLayout   = patchLayoutString(auiMgr, viewPanels)
+    frameChildren = [type(vp).__name__ for vp in viewPanels]
+    frameChildren = ','.join(frameChildren) + ','
 
-    # The different sections of the string
-    # returned by SavePerspective are
-    # separated with a '|' character.
-    lines = frameLayout.split('|')
-    lines = [l.strip() for l in lines]
-    lines = [l for l in lines if l != '']
-
-    # Even though the layout for each view
-    # panel is included in the perspective
-    # string, we are going to remove them,
-    # and patch them back in, in the loop
-    # below. This is so we can match view
-    # panel layouts with their child control
-    # panel layouts, and be sure that each 
-    # view panel is paired to the correct
-    # set of control panels.
-    lines = [l for l in lines if l.find('name=') == -1]
+    # We are going to build a list of layout strings,
+    # one for each ViewPanel, and a corresponding list
+    # of control panels displayed on each ViewPanel.
+    vpLayouts   = [] 
+    vpChildrens = []
 
     for vp in viewPanels:
 
-        # Get the layout for this view panel
-        # (which we just removed, above)
-        vpLayout = auiMgr.SavePaneInfo(frame.getViewPanelInfo(vp))
+        # Get the auiManager and layout for this view panel.
+        # This is a little bit complicated, as ViewPanels
+        # differentiate between the main 'centre' panel, and
+        # all other secondary (control) panels. The layout
+        # string needs to contain layout information for
+        # all of these panels, but we only care about the
+        # control panels.
+        vpAuiMgr    = vp.getAuiManager()
+        ctrlPanels  = vp.getPanels()
+        centrePanel = vp.getCentrePanel()
 
-        # Each ViewPanel is itself managed by
-        # an AuiManager, which manages the layout
-        # of the control panels that have been
-        # added to the ViewPanel. Here, we get
-        # the layout for this view panel.
-        vpAuiMgr      = vp.getAuiManager()
-        vpInnerLayout = vpAuiMgr.SavePerspective()
+        # The process is now identical to that used
+        # for the frame layout and children, above.
+        vpLayout    = patchLayoutString(vpAuiMgr, [centrePanel] + ctrlPanels)
+        vpChildren  = [type(cp).__name__ for cp in ctrlPanels]
+        vpChildren  = ','.join(vpChildren) + ','
 
-        # After the Frame-level layout for a view
-        # panel, we add in the ViewPanel-level
-        # layout for the control panels within
-        # that view panel.
-        lines.append(vpLayout)
-        lines.append(vpInnerLayout)
+        vpLayouts  .append(vpLayout)
+        vpChildrens.append(vpChildren)
 
-    # Both the frame-level, and the viewpanel-level
-    # layouts use '|' characters to separate their
-    # sections. To avoid confusing the two, we're
-    # replacing the pipes in the frame-level layout 
-    # with newlines.
-    layout = '\n'.join(lines)
+    # We serialise all of these pieces of information
+    # as a single newline-separated string.
+    perspective = [frameChildren, frameLayout]
+    for vpChildren, vpLayout in zip(vpChildrens, vpLayouts):
+        perspective.append(vpChildren)
+        perspective.append(vpLayout)
 
-    return layout
+    # And we're done!
+    return '\n'.join(perspective)
 
 
 def deserialisePerspective(persp):
     """
     """
-    # Returns:
-    #  - Layout string for Frame
-    #  - List of ViewPanels
-    # 
-    #  - For each ViewPanel:
-    #     - Layout string for ViewPanel
-    #     - List of ControlPanels
 
+    import fsl.fsleyes.views    as views
+    import fsl.fsleyes.controls as controls
+    
+    # This function deserialises a string which was
+    # generated by the serialisePerspective function.
+    # It returns:
+    # 
+    #  - A list of ViewPanel class types - the
+    #    children of the FSLEyesFrame.
+    # 
+    #  - A layout string for the FSLEyesFrame.
+    # 
+    #  - A list of lists, each inner list containing
+    #    a collection of ControlPanel class types -
+    #    the children of the corresponding ViewPanel.
+    # 
+    #  - A list of layout strings, one for each
+    #    ViewPanel.
+    
     lines = persp.split('\n')
+    lines = [l.strip() for l in lines]
+    lines = [l         for l in lines if l != '']
+
+    frameChildren = lines[0]
+    frameLayout   = lines[1]
+
+    # The children strings are comma-separated
+    # class names. The frame children are ViewPanels,
+    # which are all defined in the fsl.fsleyes.views
+    # package.
+    frameChildren = frameChildren.split(',')
+    frameChildren = [fc.strip() for fc in frameChildren]
+    frameChildren = [fc         for fc in frameChildren if fc != '']
+    frameChildren = [getattr(views, fc) for fc in frameChildren]
+
+    # Collate the children/layouts for each view panel
+    vpChildren = []
+    vpLayouts  = []
+    for i in range(2, len(frameChildren) + 2, 2):
+        vpChildren.append(lines[i]) 
+        vpLayouts .append(lines[i + 1])
+
+    # And the ViewPanel children are control panels,
+    # all defined in the fsl.fsleyes.controls package.
+    for i in range(len(vpChildren)):
+
+        vpChildren[i] = vpChildren[i].split(',')
+        vpChildren[i] = [vpc.strip() for vpc in vpChildren[i]]
+        vpChildren[i] = [vpc         for vpc in vpChildren[i] if vpc != ''] 
+        vpChildren[i] = [getattr(controls, vpc) for vpc in vpChildren[i]]
+
+    return frameChildren, frameLayout, vpChildren, vpLayouts
+
+
+def _addToPerspectivesList(persp):
+    """
+    """
+    perspectives = getAllPerspectives()
+
+    if persp not in perspectives:
+        perspectives.append(persp)
+
+    perspectives = ','.join(perspectives)
+
+    log.debug('Updating stored perspective list: {}'.format(perspectives))
+    fslsettings.write('fsleyes.perspectives', perspectives)
+
+
+def _removeFromPerspectivesList(persp):
+    """
+    """
+    
+    perspectives = getAllPerspectives()
+
+    try:               perspectives.remove(persp)
+    except ValueError: return
+
+    perspectives = ','.join(perspectives)
+
+    log.debug('Updating stored perspective list: {}'.format(perspectives))
+    fslsettings.write('fsleyes.perspectives', perspectives) 
+
+
+def _addControlPanel(viewPanel, panelType):
+    """
+    """
+    import fsl.fsleyes.controls as controls
+
+    args = {
+        controls.CanvasSettingsPanel       : {'canvasPanel' : viewPanel},
+        controls.HistogramControlPanel     : {'plotPanel'   : viewPanel},
+        controls.LightBoxToolBar           : {'lb'          : viewPanel},
+        controls.OrthoEditToolBar          : {'ortho'       : viewPanel},
+        controls.OrthoToolBar              : {'ortho'       : viewPanel},
+        controls.OverlayDisplayToolBar     : {'viewPanel'   : viewPanel},
+        controls.PlotListPanel             : {'plotPanel'   : viewPanel},
+        controls.PowerSpectrumControlPanel : {'plotPanel'   : viewPanel},
+        controls.ShellPanel                : {'canvasPanel' : viewPanel},
+        controls.TimeSeriesControlPanel    : {'plotPanel'   : viewPanel},
+    }
+
+    args = args.get(panelType, {})
+
+    viewPanel.togglePanel(panelType, **args)
+
+    
+BUILT_IN_PERSPECTIVES = collections.OrderedDict((
+    ('default',
+     textwrap.dedent("""
+                     OrthoPanel,
+                     layout2|name=OrthoPanel;caption=Ortho View 1;state=67376064;dir=5;layer=0;row=0;pos=0;prop=100000;bestw=20;besth=20;minw=-1;minh=-1;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=-1;floath=-1;notebookid=-1;transparent=255|dock_size(5,0,0)=22|
+                     LocationPanel,OverlayListPanel,OverlayDisplayToolBar,OrthoToolBar,
+                     layout2|name=Panel;caption=;state=768;dir=5;layer=0;row=0;pos=0;prop=100000;bestw=20;besth=20;minw=-1;minh=-1;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=-1;floath=-1;notebookid=-1;transparent=255|name=LocationPanel;caption=Location;state=67373052;dir=3;layer=0;row=0;pos=1;prop=100000;bestw=440;besth=109;minw=440;minh=109;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=440;floath=125;notebookid=-1;transparent=255|name=OverlayListPanel;caption=Overlay list;state=67373052;dir=3;layer=0;row=0;pos=0;prop=100000;bestw=197;besth=80;minw=197;minh=80;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=197;floath=96;notebookid=-1;transparent=255|name=OverlayDisplayToolBar;caption=Display toolbar;state=67382012;dir=1;layer=11;row=0;pos=0;prop=100000;bestw=860;besth=49;minw=-1;minh=-1;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=-1;floath=-1;notebookid=-1;transparent=255|name=OrthoToolBar;caption=Ortho view toolbar;state=67382012;dir=1;layer=10;row=0;pos=0;prop=100000;bestw=755;besth=34;minw=-1;minh=-1;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=-1;floath=-1;notebookid=-1;transparent=255|dock_size(5,0,0)=22|dock_size(3,0,0)=130|dock_size(1,10,0)=36|dock_size(1,11,0)=51|
+                     """)),
+
+    ('melodic',
+     textwrap.dedent("""
+                     LightBoxPanel,TimeSeriesPanel,PowerSpectrumPanel,
+                     layout2|name=LightBoxPanel;caption=Lightbox View 1;state=67377088;dir=5;layer=0;row=0;pos=0;prop=100000;bestw=853;besth=-1;minw=-1;minh=-1;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=-1;floath=-1;notebookid=-1;transparent=255|name=TimeSeriesPanel;caption=Time series 2;state=67377148;dir=3;layer=0;row=0;pos=0;prop=100000;bestw=-1;besth=472;minw=-1;minh=-1;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=-1;floath=-1;notebookid=-1;transparent=255|name=PowerSpectrumPanel;caption=Power spectra 3;state=67377148;dir=3;layer=0;row=0;pos=1;prop=100000;bestw=-1;besth=472;minw=-1;minh=-1;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=-1;floath=-1;notebookid=-1;transparent=255|dock_size(5,0,0)=22|dock_size(3,0,0)=493|
+                     OverlayListPanel,LightBoxToolBar,OverlayDisplayToolBar,LocationPanel,MelodicClassificationPanel,LookupTablePanel,
+                     layout2|name=Panel;caption=;state=768;dir=5;layer=0;row=0;pos=0;prop=100000;bestw=20;besth=20;minw=-1;minh=-1;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=-1;floath=-1;notebookid=-1;transparent=255|name=OverlayListPanel;caption=Overlay list;state=67373052;dir=3;layer=0;row=0;pos=0;prop=100000;bestw=197;besth=80;minw=197;minh=80;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=197;floath=96;notebookid=-1;transparent=255|name=LightBoxToolBar;caption=Lightbox view toolbar;state=67382012;dir=1;layer=10;row=0;pos=0;prop=100000;bestw=757;besth=43;minw=-1;minh=-1;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=-1;floath=-1;notebookid=-1;transparent=255|name=OverlayDisplayToolBar;caption=Display toolbar;state=67382012;dir=1;layer=11;row=0;pos=0;prop=100000;bestw=860;besth=49;minw=-1;minh=-1;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=-1;floath=-1;notebookid=-1;transparent=255|name=LocationPanel;caption=Location;state=67373052;dir=3;layer=0;row=0;pos=1;prop=100000;bestw=440;besth=109;minw=440;minh=109;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=440;floath=125;notebookid=-1;transparent=255|name=MelodicClassificationPanel;caption=Melodic IC classification;state=67373052;dir=2;layer=0;row=0;pos=1;prop=100000;bestw=400;besth=100;minw=400;minh=100;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=400;floath=116;notebookid=-1;transparent=255|name=LookupTablePanel;caption=Lookup tables;state=67373052;dir=2;layer=0;row=0;pos=0;prop=100000;bestw=358;besth=140;minw=358;minh=140;maxw=-1;maxh=-1;floatx=3614;floaty=658;floatw=358;floath=156;notebookid=-1;transparent=255|dock_size(5,0,0)=22|dock_size(3,0,0)=130|dock_size(1,10,0)=45|dock_size(1,11,0)=10|dock_size(2,0,0)=402|
+                     ,
+                     layout2|name=FigureCanvasWxAgg;caption=;state=768;dir=5;layer=0;row=0;pos=0;prop=100000;bestw=640;besth=480;minw=-1;minh=-1;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=-1;floath=-1;notebookid=-1;transparent=255|dock_size(5,0,0)=642|
+                     ,
+                     layout2|name=FigureCanvasWxAgg;caption=;state=768;dir=5;layer=0;row=0;pos=0;prop=100000;bestw=640;besth=480;minw=-1;minh=-1;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=-1;floath=-1;notebookid=-1;transparent=255|dock_size(5,0,0)=642|
+                     """)),
+
+    ('feat',
+     textwrap.dedent("""
+                     OrthoPanel,TimeSeriesPanel,
+                     layout2|name=OrthoPanel;caption=Ortho View 1;state=67377088;dir=5;layer=0;row=0;pos=0;prop=100000;bestw=853;besth=-1;minw=-1;minh=-1;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=-1;floath=-1;notebookid=-1;transparent=255|name=TimeSeriesPanel;caption=Time series 2;state=67377148;dir=3;layer=0;row=0;pos=0;prop=100000;bestw=-1;besth=472;minw=-1;minh=-1;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=-1;floath=-1;notebookid=-1;transparent=255|dock_size(5,0,0)=22|dock_size(3,0,0)=261|
+                     LocationPanel,ClusterPanel,OverlayListPanel,AtlasPanel,
+                     layout2|name=Panel;caption=;state=768;dir=5;layer=0;row=0;pos=0;prop=100000;bestw=20;besth=20;minw=-1;minh=-1;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=-1;floath=-1;notebookid=-1;transparent=255|name=LocationPanel;caption=Location;state=67373052;dir=2;layer=1;row=0;pos=1;prop=98544;bestw=440;besth=109;minw=440;minh=109;maxw=-1;maxh=-1;floatx=1051;floaty=349;floatw=440;floath=125;notebookid=-1;transparent=255|name=ClusterPanel;caption=Cluster browser;state=67373052;dir=3;layer=0;row=0;pos=0;prop=100000;bestw=390;besth=96;minw=390;minh=96;maxw=-1;maxh=-1;floatx=276;floaty=578;floatw=390;floath=112;notebookid=-1;transparent=255|name=OverlayListPanel;caption=Overlay list;state=67373052;dir=2;layer=1;row=0;pos=2;prop=87792;bestw=197;besth=80;minw=197;minh=80;maxw=-1;maxh=-1;floatx=1047;floaty=492;floatw=197;floath=96;notebookid=-1;transparent=255|name=AtlasPanel;caption=Atlases;state=67373052;dir=2;layer=1;row=0;pos=0;prop=113664;bestw=318;besth=84;minw=318;minh=84;maxw=-1;maxh=-1;floatx=1091;floaty=143;floatw=318;floath=100;notebookid=-1;transparent=255|dock_size(5,0,0)=10|dock_size(3,0,0)=130|dock_size(2,1,0)=566|
+                     ,
+                     layout2|name=FigureCanvasWxAgg;caption=;state=768;dir=5;layer=0;row=0;pos=0;prop=100000;bestw=640;besth=480;minw=-1;minh=-1;maxw=-1;maxh=-1;floatx=-1;floaty=-1;floatw=-1;floath=-1;notebookid=-1;transparent=255|dock_size(5,0,0)=10| 
+                     """))))
diff --git a/fsl/utils/settings.py b/fsl/utils/settings.py
index 502af2807..e3b759d47 100644
--- a/fsl/utils/settings.py
+++ b/fsl/utils/settings.py
@@ -4,8 +4,8 @@
 #
 # Author: Paul McCarthy <pauldmccarthy@gmail.com>
 #
-"""This module provides a simple API to :func:`read` and :func:`write`
-persistent application settings.
+"""This module provides a simple API to :func:`read`, :func:`write`, and
+:func:`delete` persistent application settings.
 
  .. note:: Currently the configuration management API provided by :mod:`wx`
            (http://docs.wxwidgets.org/trunk/overview_config.html) is used for
@@ -69,3 +69,18 @@ def write(name, value):
     log.debug('Writing {}: {}'.format(name, value))
 
     config.Write(name, value)
+
+
+def delete(name):
+    """Delete the setting with the given ``name``. """
+    try:    import wx
+    except: return
+
+    if wx.GetApp() is None:
+        return
+
+    config = wx.Config(_CONFIG_ID)
+
+    log.debug('Deleting {}'.format(name))
+
+    config.DeleteEntry(name)
-- 
GitLab