test/data/vclamp_jpk/README: Document sample versions
[hooke.git] / hooke / ui / gui / panel / playlist.py
index 53eb9980a44251451dd2ce99d3163319a85c46e4..a2e8813d7d3d639d21234dd215a56222308169d0 100644 (file)
-# Copyright\r
-\r
-"""Playlist panel for Hooke.\r
-\r
-Provides a nice GUI interface to the\r
-:class:`~hooke.plugin.playlist.PlaylistPlugin`.\r
-"""\r
-\r
-import types\r
-\r
-import wx\r
-\r
-from ....util.callback import callback, in_callback\r
-from . import Panel\r
-\r
-\r
-class Menu (wx.Menu):\r
-    """Popup menu for selecting playlist :class:`Tree` actions.\r
-    """\r
-    def __init__(self, on_delete, *args, **kwargs):\r
-        super(Menu, self).__init__(*args, **kwargs)\r
-        self._c = {\r
-            'delete': self.Append(id=wx.ID_ANY, text='Delete'),\r
-            }\r
-        self.Bind(wx.EVT_MENU, on_delete)\r
-\r
-\r
-class Tree (wx.TreeCtrl):\r
-    """:class:`wx.TreeCtrl` subclass handling playlist and curve selection.\r
-    """\r
-    def __init__(self, config, callbacks, *args, **kwargs):\r
-        super(Tree, self).__init__(*args, **kwargs)\r
-        imglist = wx.ImageList(width=16, height=16, mask=True, initialCount=2)\r
-        imglist.Add(wx.ArtProvider.GetBitmap(\r
-                wx.ART_FOLDER, wx.ART_OTHER, wx.Size(16, 16)))\r
-        imglist.Add(wx.ArtProvider.GetBitmap(\r
-                wx.ART_NORMAL_FILE, wx.ART_OTHER, wx.Size(16, 16)))\r
-        self.AssignImageList(imglist)\r
-        self.image = {\r
-            'root': 0,\r
-            'playlist': 0,\r
-            'curve': 1,\r
-            }\r
-        self._c = {\r
-            'menu': Menu(self._on_delete),\r
-            'root': self.AddRoot(text='Playlists', image=self.image['root'])\r
-            }\r
-        self.Bind(wx.EVT_RIGHT_DOWN, self._on_context_menu)\r
-        self.Bind(wx.EVT_TREE_SEL_CHANGED, self._on_curve_select)\r
-        self.Bind(wx.EVT_LEFT_DOWN, self._on_left_down)\r
-        self.Bind(wx.EVT_LEFT_DCLICK, self._on_left_doubleclick)\r
-\r
-        self.config = config\r
-        self._callbacks = callbacks\r
-        self._setup_playlists()\r
-\r
-    def _setup_playlists(self):\r
-        self._playlists = {}    # {name: hooke.playlist.Playlist()}\r
-\r
-        # In both of the following dicts, curve names are\r
-        # (playlist.name, curve.name) to avoid cross-playlist\r
-        # collisions.  See ._is_curve().\r
-        self._id_for_name = {}  # {name: id}\r
-        self._name_for_id = {}  # {id: name}\r
-\r
-    def _name(self, name):\r
-        """Cleanup names according to configured preferences.\r
-        """\r
-        if self.config['hide extensions'] == 'True':  # HACK: config should decode\r
-            name,ext = os.path.splitext(name)\r
-        return name\r
-\r
-    def _is_curve(self, name):  # name from ._id_for_name / ._name_for_id\r
-        """Return `True` if `name` corresponds to a :class:`hooke.curve.Curve`.\r
-        """\r
-        # Playlist names are strings, Curve names are tuples.\r
-        # See ._setup_playlists().\r
-        return not isinstance(name, types.StringTypes)\r
-\r
-    def _canonical_id(self, _id):\r
-        """Return a canonical form of `_id` suitable for accessing `._name_for_id`.\r
-\r
-        For some reason, `.GetSelection()`, etc. return items that\r
-        hash differently than the original `.AppendItem()`-returned\r
-        IDs.  This means that `._name_for_id[self.GetSelection()]`\r
-        will raise `KeyError`, even if there is an id `X` in\r
-        `._name_for_id` for which `X == self.GetSelection()` will\r
-        return `True`.  This method "canonicalizes" IDs so that the\r
-        hashing is consistent.\r
-        """\r
-        for c_id in self._name_for_id.keys():\r
-            if c_id == _id:\r
-                return c_id\r
-        raise KeyError(_id)\r
-\r
-    def _on_curve_select(self, event):\r
-        """Act on playlist/curve selection.\r
-\r
-        Currently just a hook for a potential callback.\r
-        """\r
-        _id = self.GetSelection()\r
-        name = self._name_for_id[self._canonical_id(_id)]\r
-        if self._is_curve(name):\r
-            playlist = self._playlists[name[0]]\r
-            curve = playlist.current()\r
-            in_callback(self, playlist, curve)\r
-\r
-    def _on_left_down(self, event):\r
-        """Select the clicked-on curve/playlist.\r
-        """ # TODO: dup with _on_curve_select?\r
-        hit_id, hit_flags = self.HitTest(event.GetPosition())\r
-        if (hit_flags & wx.TREE_HITTEST_ONITEM) != 0:\r
-            name = self._name_for_id[self._canonical_id(hit_id)]\r
-            if self._is_curve(name):\r
-                self.set_selected_curve(name[0], name[1])\r
-            else:\r
-                self.set_selected_playlist(name)\r
-        event.Skip()\r
-\r
-    def _on_left_doubleclick(self, event):\r
-        pass\r
-        #playlist.index = index\r
-        #event.Skip()\r
-\r
-    def _on_context_menu(self, event):\r
-        """Launch a popup :class:`Menu` with per-playlist/curve activities.\r
-        """\r
-        hit_id,hit_flags = self.HitTest(event.GetPosition())\r
-        if (hit_flags & wx.TREE_HITTEST_ONITEM) != 0:\r
-            self._hit_id = self._canonical_id(hit_id)  # store for callbacks\r
-            menu = Menu(self._on_delete)\r
-            self.PopupMenu(menu, event.GetPosition())\r
-            menu.Destroy()\r
-\r
-    def _on_delete(self, event):\r
-        """Handler for :class:`Menu`'s `Delete` button.\r
-\r
-        Determines the clicked item and calls the appropriate\r
-        `.delete_*()` method on it.\r
-        """\r
-        #if hasattr(self, '_hit_id'):  # called via ._c['menu']\r
-        _id = self._hit_id\r
-        del(self._hit_id)\r
-        name = self._name_for_id[_id]\r
-        if self._is_curve(name):\r
-            self.delete_curve(playlist_name=name[0], name=name[1])\r
-        else:\r
-            self.delete_playlist(name)\r
-\r
-    def add_playlist(self, playlist):\r
-        """Add a :class:`hooke.playlist.Playlist` to the tree.\r
-        """\r
-        if playlist.name not in self._playlists:\r
-            pass\r
-        else:\r
-            raise ValueError('duplicate playlist: %s' % playlist.name)\r
-        self._playlists[playlist.name] = playlist\r
-        p_id = self.AppendItem(\r
-            parent=self._c['root'],\r
-            text=self._name(playlist.name),\r
-            image=self.image['playlist'])\r
-        self._id_for_name[playlist.name] = p_id\r
-        self._name_for_id[p_id] = playlist.name\r
-\r
-        # temporarily disable any add_curve callbacks\r
-        acc = self._callbacks.get('add_curve', None)\r
-        self._callbacks['add_curve'] = None\r
-\r
-        for curve in playlist:\r
-            self.add_curve(playlist.name, curve)\r
-\r
-        # restore the add_curve callback\r
-        self._callbacks['add_curve'] = acc\r
-\r
-        in_callback(self, playlist)\r
-\r
-    def add_curve(self, playlist_name, curve):\r
-        """Add a :class:`hooke.curve.Curve` to a curently loaded playlist.\r
-        """\r
-        p = self._playlists[playlist_name]\r
-        if curve not in p:\r
-            p.append(curve)\r
-        c_id = self.AppendItem(\r
-            parent=self._id_for_name[playlist_name],\r
-            text=self._name(curve.name),\r
-            image=self.image['curve'])\r
-        self._id_for_name[(p.name, curve.name)] = c_id\r
-        self._name_for_id[c_id] = (p.name, curve.name)\r
-        in_callback(self, p, curve)\r
-\r
-    def delete_playlist(self, name):\r
-        """Delete a :class:`hooke.playlist.Playlist` by name.\r
-\r
-        Called by the :meth:`_on_delete` handler.\r
-\r
-        Removes the playlist and its curves from the tree, then calls\r
-        :meth:`_delete_playlist`.\r
-        """\r
-        _id = self._id_for_name[name]\r
-        self.Delete(_id)\r
-        playlist = self._playlists[name]\r
-        self._delete_playlist(playlist)\r
-        in_callback(self, playlist)\r
-\r
-    def _delete_playlist(self, playlist):\r
-        """Adjust name/id caches for the playlist and its curves.\r
-\r
-        Called on *every* playlist deletion.\r
-        """\r
-        self._playlists.pop(playlist.name)\r
-        _id = self._id_for_name.pop(playlist.name)\r
-        del(self._name_for_id[_id])\r
-        for curve in playlist:\r
-            self._delete_curve(playlist, curve)\r
-        in_callback(self, playlist)\r
-\r
-    def delete_curve(self, playlist_name, name):\r
-        """Delete a :class:`hooke.curve.Curve` by name.\r
-\r
-        Called by the :meth:`_on_delete` handler.\r
-\r
-        Removes the curve from the tree, then calls\r
-        :meth:`_delete_curve`.\r
-        """\r
-        _id = self._id_for_name[(playlist_name, name)]\r
-        self.Delete(_id)\r
-        playlist = self._playlists[playlist_name]\r
-        curve = None\r
-        for i,c in enumerate(playlist):\r
-            if c.name == name:\r
-                curve = c\r
-                break\r
-        self._delete_curve(playlist, curve)\r
-        in_callback(self, playlist, curve)\r
-\r
-    def _delete_curve(self, playlist, curve):\r
-        """Adjust name/id caches.\r
-\r
-        Called on _every_ curve deletion.\r
-        """\r
-        _id = self._id_for_name.pop((playlist.name, curve.name))\r
-        del(self._name_for_id[_id])\r
-        in_callback(self, playlist, curve)\r
-\r
-    def get_selected_playlist(self):\r
-        """Return the selected :class:`hooke.playlist.Playlist`.\r
-        """\r
-        _id = self.GetSelection()\r
-        name = self._name_for_id[self._canonical_id(_id)]\r
-        if self._is_curve(name):\r
-            name = name[0]\r
-        return self._playlists[name]\r
-\r
-    def get_selected_curve(self):\r
-        """Return the selected :class:`hooke.curve.Curve`.\r
-        """\r
-        _id = self.GetSelection()\r
-        name = self._name_for_id[self._canonical_id(_id)]\r
-        if self._is_curve(name):\r
-            p_name,c_name = name\r
-            playlist = self._playlists[p_name]\r
-            index = [i for i,c in enumerate(playlist) if c.name == c_name]\r
-            playlist.jump(index)\r
-        else:\r
-            playlist = self._playlists[name]\r
-        return playlist.current()\r
-\r
-    def set_selected_playlist(self, name):\r
-        """Set the selected :class:`hooke.playlist.Playlist` by name.\r
-        """\r
-        playlist = self._playlists[name]\r
-        curve = playlist.current()\r
-        self.set_selected_curve(playlist.name, curve.name)\r
-\r
-    def set_selected_curve(self, playlist_name, name):\r
-        """Set the selected :class:`hooke.curve.Curve` by name.\r
-        """\r
-        playlist = self._playlists[playlist.name]\r
-        for i,curve in enumerate(playlist):\r
-            if curve.name == name:\r
-                playlist.jump(i)\r
-                break\r
-        curve = playlist.current()\r
-        _id = self._id_for_name[(playlist.name, curve.name)]\r
-        self.Expand(self._id_for_name[playlist.name])\r
-        self.SelectItem(_id)\r
-        in_callback(self, playlist, curve) # TODO: dup callback with _on_curve_select\r
-\r
-    @callback\r
-    def generate_new_playlist(self):\r
-        pass\r
-\r
-    def _GetUniquePlaylistName(self, name):\r
-        playlist_name = name\r
-        count = 1\r
-        while playlist_name in self.playlists:\r
-            playlist_name = ''.join([name, str(count)])\r
-            count += 1\r
-        return playlist_name\r
-\r
-\r
-class Playlist (Panel, wx.Panel):\r
-    """:class:`wx.Panel` subclass wrapper for :class:`Tree`.\r
-    """\r
-    def __init__(self, config, callbacks, *args, **kwargs):\r
-        # Use the WANTS_CHARS style so the panel doesn't eat the Return key.\r
-        super(Playlist, self).__init__(*args, **kwargs)\r
-        self.name = 'playlist panel'\r
-\r
-        self._c = {\r
-            'tree': Tree(\r
-                config=config,\r
-                callbacks=callbacks,\r
-                parent=self,\r
-                size=wx.Size(160, 250),\r
-                style=wx.TR_DEFAULT_STYLE | wx.NO_BORDER | wx.TR_HIDE_ROOT),\r
-            }\r
-\r
-        sizer = wx.BoxSizer(wx.VERTICAL)\r
-        sizer.Add(self._c['tree'], 1, wx.EXPAND)\r
-        self.SetSizer(sizer)\r
-        sizer.Fit(self)\r
-\r
-        # Expose all Tree's public curve/playlist methods directly.\r
-        # Following DRY and the LoD.\r
-        for attribute_name in dir(self._c['tree']):\r
-            if (attribute_name.startswith('_')\r
-                or 'playlist' not in attribute_name\r
-                or 'curve' not in attribute_name):\r
-                continue  # not an attribute we're interested in\r
-            attr = getattr(self._c['tree'], attribute_name)\r
-            if hasattr(attr, '__call__'):  # attr is a function / method\r
-                setattr(self, attribute_name, attr)  # expose it\r
+# Copyright (C) 2010-2012 W. Trevor King <wking@tremily.us>
+#
+# This file is part of Hooke.
+#
+# Hooke is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# Hooke is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with Hooke.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Playlist panel for Hooke.
+
+Provides a nice GUI interface to the
+:class:`~hooke.plugin.playlist.PlaylistPlugin`.
+"""
+
+import logging
+import types
+
+import wx
+
+from ....util.callback import callback, in_callback
+from . import Panel
+
+
+class Menu (wx.Menu):
+    """Popup menu for selecting playlist :class:`Tree` actions.
+    """
+    def __init__(self, on_delete, *args, **kwargs):
+        super(Menu, self).__init__(*args, **kwargs)
+        self._c = {
+            'delete': self.Append(id=wx.ID_ANY, text='Delete'),
+            }
+        self.Bind(wx.EVT_MENU, on_delete)
+
+
+class Tree (wx.TreeCtrl):
+    """:class:`wx.TreeCtrl` subclass handling playlist and curve selection.
+    """
+    def __init__(self, *args, **kwargs):
+        self.log = logging.getLogger('hooke')
+        self._panel = kwargs['parent']
+        self._callbacks = self._panel._callbacks # TODO: CallbackClass.set_callback{,s}()
+        super(Tree, self).__init__(*args, **kwargs)
+        imglist = wx.ImageList(width=16, height=16, mask=True, initialCount=2)
+        imglist.Add(wx.ArtProvider.GetBitmap(
+                wx.ART_FOLDER, wx.ART_OTHER, wx.Size(16, 16)))
+        imglist.Add(wx.ArtProvider.GetBitmap(
+                wx.ART_NORMAL_FILE, wx.ART_OTHER, wx.Size(16, 16)))
+        self.AssignImageList(imglist)
+        self.image = {
+            'root': 0,
+            'playlist': 0,
+            'curve': 1,
+            }
+        self._c = {
+            'menu': Menu(self._on_delete),
+            'root': self.AddRoot(text='Playlists', image=self.image['root'])
+            }
+        self.Bind(wx.EVT_RIGHT_DOWN, self._on_context_menu)
+        self.Bind(wx.EVT_TREE_SEL_CHANGED, self._on_select)
+
+        self._setup_playlists()
+
+    def _setup_playlists(self):
+        self._playlists = {}    # {name: hooke.playlist.Playlist()}
+
+        # In both of the following dicts, curve names are
+        # (playlist.name, curve.name) to avoid cross-playlist
+        # collisions.  See ._is_curve().
+        self._id_for_name = {}  # {name: id}
+        self._name_for_id = {}  # {id: name}
+
+    def _is_curve(self, name):  # name from ._id_for_name / ._name_for_id
+        """Return `True` if `name` corresponds to a :class:`hooke.curve.Curve`.
+        """
+        # Playlist names are strings, Curve names are tuples.
+        # See ._setup_playlists().
+        return not isinstance(name, types.StringTypes)
+
+    def _canonical_id(self, _id):
+        """Return a canonical form of `_id` suitable for accessing `._name_for_id`.
+
+        For some reason, `.GetSelection()`, etc. return items that
+        hash differently than the original `.AppendItem()`-returned
+        IDs.  This means that `._name_for_id[self.GetSelection()]`
+        will raise `KeyError`, even if there is an id `X` in
+        `._name_for_id` for which `X == self.GetSelection()` will
+        return `True`.  This method "canonicalizes" IDs so that the
+        hashing is consistent.
+        """
+        for c_id in self._name_for_id.keys():
+            if c_id == _id:
+                return c_id
+        raise KeyError(_id)
+
+
+    # Context menu
+
+    def _on_context_menu(self, event):
+        """Launch a popup :class:`Menu` with per-playlist/curve activities.
+        """
+        hit_id,hit_flags = self.HitTest(event.GetPosition())
+        if (hit_flags & wx.TREE_HITTEST_ONITEM) != 0:
+            self._hit_id = self._canonical_id(hit_id)  # store for callbacks
+            menu = Menu(self._on_delete)
+            self.PopupMenu(menu, event.GetPosition())
+            menu.Destroy()
+
+    # Add
+    #   add_* called directly by HookeFrame
+    #   _add_* called on every addition
+
+    def add_playlist(self, playlist):
+        """Add a :class:`hooke.playlist.Playlist` to the tree.
+
+        Calls :meth:`_add_playlist` and triggers a callback.
+        """
+        self._add_playlist(playlist)
+        in_callback(self, playlist)
+
+    def _add_playlist(self, playlist):
+        """Add a class:`hooke.playlist.Playlist` to the tree.
+
+        No callback triggered.
+        """
+        if playlist.name not in self._playlists:
+            pass
+        else:
+            raise ValueError('duplicate playlist: %s' % playlist.name)
+        self._playlists[playlist.name] = playlist
+        p_id = self.AppendItem(
+            parent=self._c['root'],
+            text=self._panel._hooke_frame._file_name(playlist.name),
+            image=self.image['playlist'])
+        self._id_for_name[playlist.name] = p_id
+        self._name_for_id[p_id] = playlist.name
+        for curve in playlist:
+            self._add_curve(playlist.name, curve)
+
+    def add_curve(self, playlist_name, curve):
+        """Add a :class:`hooke.curve.Curve` to a curently loaded playlist.
+
+        Calls :meth:`_add_curve` and triggers a callback.
+        """
+        self._add_curve(playlist_name, curve)
+        playlist = self._playlists[playlist_name]
+        in_callback(self, playlist, curve)
+
+    def _add_curve(self, playlist_name, curve):
+        """Add a class:`hooke.curve.Curve` to the tree.
+
+        No callback triggered.
+        """
+        p = self._playlists[playlist_name]
+        if curve not in p:
+            p.append(curve)
+        c_id = self.AppendItem(
+            parent=self._id_for_name[playlist_name],
+            text=self._panel._hooke_frame._file_name(curve.name),
+            image=self.image['curve'])
+        self._id_for_name[(p.name, curve.name)] = c_id
+        self._name_for_id[c_id] = (p.name, curve.name)
+
+    @callback
+    def generate_new_playlist(self):
+        pass  # TODO
+
+    def _GetUniquePlaylistName(self, name):  # TODO
+        playlist_name = name
+        count = 1
+        while playlist_name in self.playlists:
+            playlist_name = ''.join([name, str(count)])
+            count += 1
+        return playlist_name
+
+    # Delete
+    #   delete_* called by _on_delete handler (user click) or HookeFrame
+    #   _delete_* called on every deletion
+
+    def _on_delete(self, event):
+        """Handler for :class:`Menu`'s `Delete` button.
+
+        Determines the clicked item and calls the appropriate
+        `.delete_*()` method on it.
+        """
+        #if hasattr(self, '_hit_id'):  # called via ._c['menu']
+        _id = self._hit_id
+        del(self._hit_id)
+        name = self._name_for_id[_id]
+        if self._is_curve(name):
+            self._delete_curve(playlist_name=name[0], name=name[1])
+        else:
+            self._delete_playlist(name)
+
+    def _delete_playlist(self, name):
+        """Delete a :class:`hooke.playlist.Playlist` by name.
+
+        Called by the :meth:`_on_delete` handler.  Calls the
+        approptiate interface callback.
+        """
+        _id = self._id_for_name[name]
+        playlist = self._playlists[name]
+        in_callback(self, playlist)
+
+    def delete_playlist(self, playlist):
+        """Respond to playlist deletion.
+
+        Called on *every* playlist deletion.
+        """
+        self._playlists.pop(playlist.name)
+        _id = self._id_for_name.pop(playlist.name)
+        self.Delete(_id)
+        del(self._name_for_id[_id])
+        for curve in playlist:
+            self._delete_curve(playlist, curve)
+
+    def _delete_curve(self, playlist_name, name):
+        """Delete a :class:`hooke.curve.Curve` by name.
+
+        Called by the :meth:`_on_delete` handler.  Calls the
+        approptiate interface callback.
+        """
+        _id = self._id_for_name[(playlist_name, name)]
+        playlist = self._playlists[playlist_name]
+        curve = None
+        for i,c in enumerate(playlist):
+            if c.name == name:
+                curve = c
+                break
+        if curve is None:
+            raise ValueError(name)
+        in_callback(self, playlist, curve)
+
+    def delete_curve(self, playlist_name, name):
+        """Respond to curve deletions.
+
+        Called on *every* curve deletion.
+        """
+        _id = self._id_for_name.pop((playlist_name, name))
+        self.Delete(_id)
+        del(self._name_for_id[_id])
+
+    # Get selection
+
+    def get_selected_playlist(self):
+        """Return the selected :class:`hooke.playlist.Playlist`.
+        """
+        _id = self.GetSelection()
+        try:
+            _id = self._canonical_id(_id)
+        except KeyError:  # no playlist selected
+            return None
+        name = self._name_for_id[_id]
+        if self._is_curve(name):
+            name = name[0]
+        return self._playlists[name]
+
+    def get_selected_curve(self):
+        """Return the selected :class:`hooke.curve.Curve`.
+        """
+        _id = self.GetSelection()
+        name = self._name_for_id[self._canonical_id(_id)]
+        if self._is_curve(name):
+            p_name,c_name = name
+            playlist = self._playlists[p_name]
+            c = playlist.current()
+            assert c.name == c_name, '%s != %s' % (c.name, c_name)
+        else:
+            playlist = self._playlists[name]
+        return playlist.current()
+
+    # Set selection (via user interaction with this panel)
+    #
+    # These are hooks for HookeFrame callbacks which will send
+    # the results back via 'get curve' calling 'set_selected_curve'.
+
+    def _on_select(self, event):
+        """Select the clicked-on curve/playlist.
+        """
+        _id = self.GetSelection()
+        name = self._name_for_id[self._canonical_id(_id)]
+        if self._is_curve(name):
+            p_name,c_name = name
+            self._on_set_selected_curve(p_name, c_name)
+        else:
+            self._on_set_selected_playlist(name)
+
+    def _on_set_selected_playlist(self, name):
+        self.log.debug('playlist tree selecting playlist %s' % name)
+        in_callback(self, self._playlists[name])
+
+    def _on_set_selected_curve(self, playlist_name, name):
+        self.log.debug('playlist tree selecting curve %s in %s'
+                       % (name, playlist_name))
+        playlist = self._playlists[playlist_name]
+        curve = None
+        for i,c in enumerate(playlist):
+            if c.name == name:
+                curve = c
+                break
+        if curve == None:
+            raise ValueError(name)
+        in_callback(self, playlist, curve)
+        
+    # Set selection (from the HookeFrame)
+
+    def set_selected_curve(self, playlist, curve):
+        """Make the curve the playlist's current curve.
+        """
+        self.log.debug('playlist tree expanding %s' % playlist.name)
+        self.Expand(self._id_for_name[playlist.name])
+        self.Unbind(wx.EVT_TREE_SEL_CHANGED)
+        self.log.debug('playlist tree selecting %s' % curve.name)
+        self.SelectItem(self._id_for_name[(playlist.name, curve.name)])
+        self.Bind(wx.EVT_TREE_SEL_CHANGED, self._on_select)
+
+    def update_playlist(self, playlist):
+        """Absorb changed `.index()`, etc.
+        """
+        self._playlists[playlist.name] = playlist
+        cnames = set()
+        for curve in playlist:
+            if (playlist.name, curve.name) not in self._id_for_name:
+                self._add_curve(playlist.name, curve)
+            cnames.add(curve.name)
+        for name in self._id_for_name.keys():
+            if not self._is_curve(name):
+                continue
+            pname,cname = name
+            if pname != playlist.name:
+                continue
+            if cname not in cnames:
+                self.delete_curve(playlist_name=pname, name=cname)
+
+    def is_playlist_loaded(self, playlist):
+        """Return `True` if `playlist` is loaded, `False` otherwise.
+        """
+        return self.is_playlist_name_loaded(playlist.name)
+
+    def is_playlist_name_loaded(self, name):
+        """Return `True` if a playlist named `name` is loaded, `False`
+        otherwise.
+        """
+        return name in self._playlists
+
+
+class Playlist (Panel, wx.Panel):
+    """:class:`wx.Panel` subclass wrapper for :class:`Tree`.
+    """
+    def __init__(self, callbacks=None, **kwargs):
+        # Use the WANTS_CHARS style so the panel doesn't eat the Return key.
+        super(Playlist, self).__init__(
+            name='playlist', callbacks=callbacks, **kwargs)
+        self._c = {
+            'tree': Tree(
+                parent=self,
+                size=wx.Size(160, 250),
+                style=wx.TR_DEFAULT_STYLE | wx.NO_BORDER | wx.TR_HIDE_ROOT),
+            }
+
+        sizer = wx.BoxSizer(wx.VERTICAL)
+        sizer.Add(self._c['tree'], 1, wx.EXPAND)
+        self.SetSizer(sizer)
+        sizer.Fit(self)
+
+        # Expose all Tree's public curve/playlist methods directly.
+        # Following DRY and the LoD.
+        for attribute_name in dir(self._c['tree']):
+            if (attribute_name.startswith('_')
+                or ('playlist' not in attribute_name
+                    and 'curve' not in attribute_name)):
+                continue  # not an attribute we're interested in
+            attr = getattr(self._c['tree'], attribute_name)
+            if hasattr(attr, '__call__'):  # attr is a function / method
+                setattr(self, attribute_name, attr)  # expose it