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