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