Major rework of gui.panel.playlist.
[hooke.git] / hooke / ui / gui / panel / playlist.py
index 022950d15f0b9852ed579439aa8e8cde2f9017c3..2982744990dce7c182b6ec7255c1f5c09bcf227f 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
-class Playlist(wx.Panel):\r
+from ....util.callback import callback, in_callback\r
 \r
-    def __init__(self, parent):\r
-        # Use the WANTS_CHARS style so the panel doesn't eat the Return key.\r
-        wx.Panel.__init__(self, parent, -1, style=wx.WANTS_CHARS|wx.NO_BORDER, size=(160, 200))\r
 \r
-        self.PlaylistsTree = wx.TreeCtrl(self, -1, wx.Point(0, 0), wx.Size(160, 250), wx.TR_DEFAULT_STYLE | wx.NO_BORDER | wx.TR_HIDE_ROOT)\r
-        imglist = wx.ImageList(16, 16, True, 2)\r
-        imglist.Add(wx.ArtProvider.GetBitmap(wx.ART_FOLDER, wx.ART_OTHER, wx.Size(16, 16)))\r
-        imglist.Add(wx.ArtProvider.GetBitmap(wx.ART_NORMAL_FILE, wx.ART_OTHER, wx.Size(16, 16)))\r
-        self.PlaylistsTree.AssignImageList(imglist)\r
-        self.PlaylistsTree.AddRoot('Playlists', 0)\r
-        self.PlaylistsTree.Bind(wx.EVT_RIGHT_DOWN , self.OnContextMenu)\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
-        self.Playlists = {}\r
 \r
-        sizer = wx.BoxSizer(wx.VERTICAL)\r
-        sizer.Add(self.PlaylistsTree, 1, wx.EXPAND)\r
-        self.SetSizer(sizer)\r
-        sizer.Fit(self)\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('Playlists', 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 OnContextMenu(self, event):\r
-        hit_item, hit_flags = self.PlaylistsTree.HitTest(event.GetPosition())\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(_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[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
+        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.PlaylistsTree.SelectItem(hit_item)\r
-            # only do this part the first time so the events are only bound once\r
-            # Yet another alternate way to do IDs. Some prefer them up top to\r
-            # avoid clutter, some prefer them close to the object of interest\r
-            # for clarity.\r
-            if not hasattr(self, 'ID_popupAdd'):\r
-                #self.ID_popupAdd = wx.NewId()\r
-                self.ID_popupDelete = wx.NewId()\r
-                #self.Bind(wx.EVT_MENU, self.OnPopupAdd, id=self.ID_popupAdd)\r
-                self.Bind(wx.EVT_MENU, self.OnPopupDelete, id=self.ID_popupDelete)\r
-            # make a menu\r
-            menu = wx.Menu()\r
-            #items = [['Add', self.ID_popupAdd] , ['Delete', self.ID_popupDelete]]\r
-            items = [['Delete', self.ID_popupDelete]]\r
-            for item in items:\r
-                menu.Append(item[1], item[0])\r
-            # Popup the menu.  If an item is selected then its handler\r
-            # will be called before PopupMenu returns.\r
-            self.PopupMenu(menu)\r
+            self._hit_id = hit_id  # store for the callbacks\r
+            self.PopupMenu(\r
+                Menu(self._on_delete), event.GetPoint())\r
             menu.Destroy()\r
 \r
-    def OnPopupAdd(self, event):\r
-        pass\r
+    def _on_delete(self, event):\r
+        """Handler for :class:`Menu`'s `Delete` button.\r
 \r
-    def OnPopupDelete(self, event):\r
-        item = self.PlaylistsTree.GetSelection()\r
-        playlist = self.Parent.GetActivePlaylist()\r
-        if self.PlaylistsTree.ItemHasChildren(item):\r
-            playlist_name = self.PlaylistsTree.GetItemText(item)\r
-            notebook = self.Parent.plotNotebook\r
-            index = self.Parent._GetPlaylistTab(playlist_name)\r
-            notebook.SetSelection(index)\r
-            notebook.DeletePage(notebook.GetSelection())\r
-            self.Parent.DeleteFromPlaylists(playlist_name)\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
-            if playlist is not None:\r
-                if playlist.count == 1:\r
-                    notebook = self.Parent.plotNotebook\r
-                    index = self.Parent._GetPlaylistTab(playlist.name)\r
-                    notebook.SetSelection(index)\r
-                    notebook.DeletePage(notebook.GetSelection())\r
-                    self.Parent.DeleteFromPlaylists(playlist.name)\r
-                else:\r
-                    file_name = self.PlaylistsTree.GetItemText(item)\r
-                    playlist.delete_file(file_name)\r
-                    self.PlaylistsTree.Delete(item)\r
-                    self.Parent.UpdatePlaylistsTreeSelection()\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
+\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
+        p.append(curve)\r
+        c_id = 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[(playlist.name, curve.name)] = c_id\r
+        in_callback(self, p, curve)\r
+\r
+    def delete_playlist(self, name):\r
+        """Delete a :class:`hooke.playlist.Playlist` by name.\r
+        """\r
+        _id = self._id_for_name.pop(name)\r
+        self.Delete(_id)\r
+        playlist = self._playlists.pop(name)\r
+        del(self._name_for_id[_id])\r
+        for curve in playlist:\r
+            _id = self._id_for_name.pop((name, curve.name))\r
+            del(self._name_for_id[_id])\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
+        if playlist is not None:\r
+            if playlist.count == 1:\r
+                notebook = self.Parent.plotNotebook\r
+                index = self.Parent._GetPlaylistTab(playlist.name)\r
+                notebook.SetSelection(index)\r
+                notebook.DeletePage(notebook.GetSelection())\r
+                self.Parent.DeleteFromPlaylists(playlist.name)\r
+            else:\r
+                file_name = self.GetItemText(item)\r
+                playlist.delete_file(file_name)\r
+                self.Delete(item)\r
+                self.Parent.UpdatePlaylistsTreeSelection()\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(_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(_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 (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
+        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