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