get prev/next playlist buttons working in the navbar
[hooke.git] / hooke / ui / gui / panel / playlist.py
1 # Copyright\r
2 \r
3 """Playlist panel for Hooke.\r
4 \r
5 Provides a nice GUI interface to the\r
6 :class:`~hooke.plugin.playlist.PlaylistPlugin`.\r
7 """\r
8 \r
9 import types\r
10 \r
11 import wx\r
12 \r
13 from ....util.callback import callback, in_callback\r
14 from . import Panel\r
15 \r
16 \r
17 class Menu (wx.Menu):\r
18     """Popup menu for selecting playlist :class:`Tree` actions.\r
19     """\r
20     def __init__(self, on_delete, *args, **kwargs):\r
21         super(Menu, self).__init__(*args, **kwargs)\r
22         self._c = {\r
23             'delete': self.Append(id=wx.ID_ANY, text='Delete'),\r
24             }\r
25         self.Bind(wx.EVT_MENU, on_delete)\r
26 \r
27 \r
28 class Tree (wx.TreeCtrl):\r
29     """:class:`wx.TreeCtrl` subclass handling playlist and curve selection.\r
30     """\r
31     def __init__(self, config, callbacks, *args, **kwargs):\r
32         super(Tree, self).__init__(*args, **kwargs)\r
33         imglist = wx.ImageList(width=16, height=16, mask=True, initialCount=2)\r
34         imglist.Add(wx.ArtProvider.GetBitmap(\r
35                 wx.ART_FOLDER, wx.ART_OTHER, wx.Size(16, 16)))\r
36         imglist.Add(wx.ArtProvider.GetBitmap(\r
37                 wx.ART_NORMAL_FILE, wx.ART_OTHER, wx.Size(16, 16)))\r
38         self.AssignImageList(imglist)\r
39         self.image = {\r
40             'root': 0,\r
41             'playlist': 0,\r
42             'curve': 1,\r
43             }\r
44         self._c = {\r
45             'menu': Menu(self._on_delete),\r
46             'root': self.AddRoot(text='Playlists', image=self.image['root'])\r
47             }\r
48         self.Bind(wx.EVT_RIGHT_DOWN, self._on_context_menu)\r
49         self.Bind(wx.EVT_TREE_SEL_CHANGED, self._on_select)\r
50 \r
51         self.config = config\r
52         self._callbacks = callbacks\r
53         self._setup_playlists()\r
54 \r
55     def _setup_playlists(self):\r
56         self._playlists = {}    # {name: hooke.playlist.Playlist()}\r
57 \r
58         # In both of the following dicts, curve names are\r
59         # (playlist.name, curve.name) to avoid cross-playlist\r
60         # collisions.  See ._is_curve().\r
61         self._id_for_name = {}  # {name: id}\r
62         self._name_for_id = {}  # {id: name}\r
63 \r
64     def _name(self, name):\r
65         """Cleanup names according to configured preferences.\r
66         """\r
67         if self.config['hide extensions'] == 'True':  # HACK: config should decode\r
68             name,ext = os.path.splitext(name)\r
69         return name\r
70 \r
71     def _is_curve(self, name):  # name from ._id_for_name / ._name_for_id\r
72         """Return `True` if `name` corresponds to a :class:`hooke.curve.Curve`.\r
73         """\r
74         # Playlist names are strings, Curve names are tuples.\r
75         # See ._setup_playlists().\r
76         return not isinstance(name, types.StringTypes)\r
77 \r
78     def _canonical_id(self, _id):\r
79         """Return a canonical form of `_id` suitable for accessing `._name_for_id`.\r
80 \r
81         For some reason, `.GetSelection()`, etc. return items that\r
82         hash differently than the original `.AppendItem()`-returned\r
83         IDs.  This means that `._name_for_id[self.GetSelection()]`\r
84         will raise `KeyError`, even if there is an id `X` in\r
85         `._name_for_id` for which `X == self.GetSelection()` will\r
86         return `True`.  This method "canonicalizes" IDs so that the\r
87         hashing is consistent.\r
88         """\r
89         for c_id in self._name_for_id.keys():\r
90             if c_id == _id:\r
91                 return c_id\r
92         raise KeyError(_id)\r
93 \r
94 \r
95     # Context menu\r
96 \r
97     def _on_context_menu(self, event):\r
98         """Launch a popup :class:`Menu` with per-playlist/curve activities.\r
99         """\r
100         hit_id,hit_flags = self.HitTest(event.GetPosition())\r
101         if (hit_flags & wx.TREE_HITTEST_ONITEM) != 0:\r
102             self._hit_id = self._canonical_id(hit_id)  # store for callbacks\r
103             menu = Menu(self._on_delete)\r
104             self.PopupMenu(menu, event.GetPosition())\r
105             menu.Destroy()\r
106 \r
107     # Add\r
108     #   add_* called directly by HookeFrame\r
109     #   _add_* called on every addition\r
110 \r
111     def add_playlist(self, playlist):\r
112         """Add a :class:`hooke.playlist.Playlist` to the tree.\r
113 \r
114         Calls :meth:`_add_playlist` and triggers a callback.\r
115         """\r
116         self._add_playlist(playlist)\r
117         in_callback(self, playlist)\r
118 \r
119     def _add_playlist(self, playlist):\r
120         """Add a class:`hooke.playlist.Playlist` to the tree.\r
121 \r
122         No callback triggered.\r
123         """\r
124         if playlist.name not in self._playlists:\r
125             pass\r
126         else:\r
127             raise ValueError('duplicate playlist: %s' % playlist.name)\r
128         self._playlists[playlist.name] = playlist\r
129         p_id = self.AppendItem(\r
130             parent=self._c['root'],\r
131             text=self._name(playlist.name),\r
132             image=self.image['playlist'])\r
133         self._id_for_name[playlist.name] = p_id\r
134         self._name_for_id[p_id] = playlist.name\r
135         for curve in playlist:\r
136             self._add_curve(playlist.name, curve)\r
137 \r
138     def add_curve(self, playlist_name, curve):\r
139         """Add a :class:`hooke.curve.Curve` to a curently loaded playlist.\r
140 \r
141         Calls :meth:`_add_curve` and triggers a callback.\r
142         """\r
143         self._add_curve(playlist_name, curve)\r
144         playlist = self._playlists[playlist_name]\r
145         in_callback(self, playlist, curve)\r
146 \r
147     def _add_curve(self, playlist_name, curve):\r
148         """Add a class:`hooke.curve.Curve` to the tree.\r
149 \r
150         No callback triggered.\r
151         """\r
152         p = self._playlists[playlist_name]\r
153         if curve not in p:\r
154             p.append(curve)\r
155         c_id = self.AppendItem(\r
156             parent=self._id_for_name[playlist_name],\r
157             text=self._name(curve.name),\r
158             image=self.image['curve'])\r
159         self._id_for_name[(p.name, curve.name)] = c_id\r
160         self._name_for_id[c_id] = (p.name, curve.name)\r
161 \r
162     @callback\r
163     def generate_new_playlist(self):\r
164         pass  # TODO\r
165 \r
166     def _GetUniquePlaylistName(self, name):  # TODO\r
167         playlist_name = name\r
168         count = 1\r
169         while playlist_name in self.playlists:\r
170             playlist_name = ''.join([name, str(count)])\r
171             count += 1\r
172         return playlist_name\r
173 \r
174     # Delete\r
175     #   delete_* called by _on_delete handler (user click) or HookeFrame\r
176     #   _delete_* called on every deletion\r
177 \r
178     def _on_delete(self, event):\r
179         """Handler for :class:`Menu`'s `Delete` button.\r
180 \r
181         Determines the clicked item and calls the appropriate\r
182         `.delete_*()` method on it.\r
183         """\r
184         #if hasattr(self, '_hit_id'):  # called via ._c['menu']\r
185         _id = self._hit_id\r
186         del(self._hit_id)\r
187         name = self._name_for_id[_id]\r
188         if self._is_curve(name):\r
189             self.delete_curve(playlist_name=name[0], name=name[1])\r
190         else:\r
191             self.delete_playlist(name)\r
192 \r
193     def delete_playlist(self, name):\r
194         """Delete a :class:`hooke.playlist.Playlist` by name.\r
195 \r
196         Called by the :meth:`_on_delete` handler.\r
197 \r
198         Removes the playlist and its curves from the tree, then calls\r
199         :meth:`_delete_playlist`.\r
200         """\r
201         _id = self._id_for_name[name]\r
202         self.Delete(_id)\r
203         playlist = self._playlists[name]\r
204         self._delete_playlist(playlist)\r
205         in_callback(self, playlist)\r
206 \r
207     def _delete_playlist(self, playlist):\r
208         """Adjust name/id caches for the playlist and its curves.\r
209 \r
210         Called on *every* playlist deletion.\r
211         """\r
212         self._playlists.pop(playlist.name)\r
213         _id = self._id_for_name.pop(playlist.name)\r
214         del(self._name_for_id[_id])\r
215         for curve in playlist:\r
216             self._delete_curve(playlist, curve)\r
217         in_callback(self, playlist)\r
218 \r
219     def delete_curve(self, playlist_name, name):\r
220         """Delete a :class:`hooke.curve.Curve` by name.\r
221 \r
222         Called by the :meth:`_on_delete` handler.\r
223 \r
224         Removes the curve from the tree, then calls\r
225         :meth:`_delete_curve`.\r
226         """\r
227         _id = self._id_for_name[(playlist_name, name)]\r
228         self.Delete(_id)\r
229         playlist = self._playlists[playlist_name]\r
230         curve = None\r
231         for i,c in enumerate(playlist):\r
232             if c.name == name:\r
233                 curve = c\r
234                 break\r
235         self._delete_curve(playlist, curve)\r
236         in_callback(self, playlist, curve)\r
237 \r
238     def _delete_curve(self, playlist, curve):\r
239         """Adjust name/id caches.\r
240 \r
241         Called on _every_ curve deletion.\r
242         """\r
243         _id = self._id_for_name.pop((playlist.name, curve.name))\r
244         del(self._name_for_id[_id])\r
245         in_callback(self, playlist, curve)\r
246 \r
247     # Get selection\r
248 \r
249     def get_selected_playlist(self):\r
250         """Return the selected :class:`hooke.playlist.Playlist`.\r
251         """\r
252         _id = self.GetSelection()\r
253         try:\r
254             _id = self._canonical_id(_id)\r
255         except KeyError:  # no playlist selected\r
256             return None\r
257         name = self._name_for_id[_id]\r
258         if self._is_curve(name):\r
259             name = name[0]\r
260         return self._playlists[name]\r
261 \r
262     def get_selected_curve(self):\r
263         """Return the selected :class:`hooke.curve.Curve`.\r
264         """\r
265         _id = self.GetSelection()\r
266         name = self._name_for_id[self._canonical_id(_id)]\r
267         if self._is_curve(name):\r
268             p_name,c_name = name\r
269             playlist = self._playlists[p_name]\r
270             c = playlist.current()\r
271             assert c.name == c_name, '%s != %s' % (c.name, c_name)\r
272         else:\r
273             playlist = self._playlists[name]\r
274         return playlist.current()\r
275 \r
276     # Set selection (via user interaction with this panel)\r
277     #\r
278     # These are hooks for HookeFrame callbacks which will send\r
279     # the results back via 'get curve' calling 'set_selected_curve'.\r
280 \r
281     def _on_select(self, event):\r
282         """Select the clicked-on curve/playlist.\r
283         """\r
284         _id = self.GetSelection()\r
285         name = self._name_for_id[self._canonical_id(_id)]\r
286         if self._is_curve(name):\r
287             p_name,c_name = name\r
288             self._on_set_selected_curve(p_name, c_name)\r
289         else:\r
290             self._on_set_selected_playlist(name)\r
291 \r
292     def _on_set_selected_playlist(self, name):\r
293         in_callback(self, self._playlists[name])\r
294 \r
295     def _on_set_selected_curve(self, playlist_name, name):\r
296         playlist = self._playlists[playlist_name]\r
297         curve = None\r
298         for i,c in enumerate(playlist):\r
299             if c.name == name:\r
300                 curve = c\r
301                 break\r
302         if curve == None:\r
303             raise ValueError(name)\r
304         in_callback(self, playlist, curve)\r
305         \r
306     # Set selection (from the HookeFrame)\r
307 \r
308     def set_selected_curve(self, playlist, curve):\r
309         """Make the curve the playlist's current curve.\r
310         """\r
311         print 'expanding', playlist.name\r
312         self.Expand(self._id_for_name[playlist.name])\r
313         self.Unbind(wx.EVT_TREE_SEL_CHANGED)\r
314         print 'selecting', curve.name\r
315         self.SelectItem(self._id_for_name[(playlist.name, curve.name)])\r
316         self.Bind(wx.EVT_TREE_SEL_CHANGED, self._on_select)\r
317 \r
318     def update_playlist(self, playlist):\r
319         """Absorb changed `._index`, etc.\r
320         """\r
321         self._playlists[playlist.name] = playlist\r
322 \r
323 \r
324 class Playlist (Panel, wx.Panel):\r
325     """:class:`wx.Panel` subclass wrapper for :class:`Tree`.\r
326     """\r
327     def __init__(self, config, callbacks, *args, **kwargs):\r
328         # Use the WANTS_CHARS style so the panel doesn't eat the Return key.\r
329         super(Playlist, self).__init__(*args, **kwargs)\r
330         self.name = 'playlist panel'\r
331 \r
332         self._c = {\r
333             'tree': Tree(\r
334                 config=config,\r
335                 callbacks=callbacks,\r
336                 parent=self,\r
337                 size=wx.Size(160, 250),\r
338                 style=wx.TR_DEFAULT_STYLE | wx.NO_BORDER | wx.TR_HIDE_ROOT),\r
339             }\r
340 \r
341         sizer = wx.BoxSizer(wx.VERTICAL)\r
342         sizer.Add(self._c['tree'], 1, wx.EXPAND)\r
343         self.SetSizer(sizer)\r
344         sizer.Fit(self)\r
345 \r
346         # Expose all Tree's public curve/playlist methods directly.\r
347         # Following DRY and the LoD.\r
348         for attribute_name in dir(self._c['tree']):\r
349             if (attribute_name.startswith('_')\r
350                 or 'playlist' not in attribute_name\r
351                 or 'curve' not in attribute_name):\r
352                 continue  # not an attribute we're interested in\r
353             attr = getattr(self._c['tree'], attribute_name)\r
354             if hasattr(attr, '__call__'):  # attr is a function / method\r
355                 setattr(self, attribute_name, attr)  # expose it\r