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