1 # Copyright (C) 2010 Massimo Sandal <devicerandom@gmail.com>
2 # W. Trevor King <wking@drexel.edu>
4 # This file is part of Hooke.
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.
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.
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/>.
20 """Playlist panel for Hooke.
22 Provides a nice GUI interface to the
23 :class:`~hooke.plugin.playlist.PlaylistPlugin`.
31 from ....util.callback import callback, in_callback
36 """Popup menu for selecting playlist :class:`Tree` actions.
38 def __init__(self, on_delete, *args, **kwargs):
39 super(Menu, self).__init__(*args, **kwargs)
41 'delete': self.Append(id=wx.ID_ANY, text='Delete'),
43 self.Bind(wx.EVT_MENU, on_delete)
46 class Tree (wx.TreeCtrl):
47 """:class:`wx.TreeCtrl` subclass handling playlist and curve selection.
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)
66 'menu': Menu(self._on_delete),
67 'root': self.AddRoot(text='Playlists', image=self.image['root'])
69 self.Bind(wx.EVT_RIGHT_DOWN, self._on_context_menu)
70 self.Bind(wx.EVT_TREE_SEL_CHANGED, self._on_select)
72 self._setup_playlists()
74 def _setup_playlists(self):
75 self._playlists = {} # {name: hooke.playlist.Playlist()}
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}
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`.
86 # Playlist names are strings, Curve names are tuples.
87 # See ._setup_playlists().
88 return not isinstance(name, types.StringTypes)
90 def _canonical_id(self, _id):
91 """Return a canonical form of `_id` suitable for accessing `._name_for_id`.
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.
101 for c_id in self._name_for_id.keys():
109 def _on_context_menu(self, event):
110 """Launch a popup :class:`Menu` with per-playlist/curve activities.
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())
120 # add_* called directly by HookeFrame
121 # _add_* called on every addition
123 def add_playlist(self, playlist):
124 """Add a :class:`hooke.playlist.Playlist` to the tree.
126 Calls :meth:`_add_playlist` and triggers a callback.
128 self._add_playlist(playlist)
129 in_callback(self, playlist)
131 def _add_playlist(self, playlist):
132 """Add a class:`hooke.playlist.Playlist` to the tree.
134 No callback triggered.
136 if playlist.name not in self._playlists:
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)
150 def add_curve(self, playlist_name, curve):
151 """Add a :class:`hooke.curve.Curve` to a curently loaded playlist.
153 Calls :meth:`_add_curve` and triggers a callback.
155 self._add_curve(playlist_name, curve)
156 playlist = self._playlists[playlist_name]
157 in_callback(self, playlist, curve)
159 def _add_curve(self, playlist_name, curve):
160 """Add a class:`hooke.curve.Curve` to the tree.
162 No callback triggered.
164 p = self._playlists[playlist_name]
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)
175 def generate_new_playlist(self):
178 def _GetUniquePlaylistName(self, name): # TODO
181 while playlist_name in self.playlists:
182 playlist_name = ''.join([name, str(count)])
187 # delete_* called by _on_delete handler (user click) or HookeFrame
188 # _delete_* called on every deletion
190 def _on_delete(self, event):
191 """Handler for :class:`Menu`'s `Delete` button.
193 Determines the clicked item and calls the appropriate
194 `.delete_*()` method on it.
196 #if hasattr(self, '_hit_id'): # called via ._c['menu']
199 name = self._name_for_id[_id]
200 if self._is_curve(name):
201 self.delete_curve(playlist_name=name[0], name=name[1])
203 self.delete_playlist(name)
205 def delete_playlist(self, name):
206 """Delete a :class:`hooke.playlist.Playlist` by name.
208 Called by the :meth:`_on_delete` handler.
210 Removes the playlist and its curves from the tree, then calls
211 :meth:`_delete_playlist`.
213 _id = self._id_for_name[name]
215 playlist = self._playlists[name]
216 self._delete_playlist(playlist)
217 in_callback(self, playlist)
219 def _delete_playlist(self, playlist):
220 """Adjust name/id caches for the playlist and its curves.
222 Called on *every* playlist deletion.
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)
231 def delete_curve(self, playlist_name, name):
232 """Delete a :class:`hooke.curve.Curve` by name.
234 Called by the :meth:`_on_delete` handler.
236 Removes the curve from the tree, then calls
237 :meth:`_delete_curve`.
239 _id = self._id_for_name[(playlist_name, name)]
241 playlist = self._playlists[playlist_name]
243 for i,c in enumerate(playlist):
247 self._delete_curve(playlist, curve)
248 in_callback(self, playlist, curve)
250 def _delete_curve(self, playlist, curve):
251 """Adjust name/id caches.
253 Called on _every_ curve deletion.
255 _id = self._id_for_name.pop((playlist.name, curve.name))
256 del(self._name_for_id[_id])
257 in_callback(self, playlist, curve)
261 def get_selected_playlist(self):
262 """Return the selected :class:`hooke.playlist.Playlist`.
264 _id = self.GetSelection()
266 _id = self._canonical_id(_id)
267 except KeyError: # no playlist selected
269 name = self._name_for_id[_id]
270 if self._is_curve(name):
272 return self._playlists[name]
274 def get_selected_curve(self):
275 """Return the selected :class:`hooke.curve.Curve`.
277 _id = self.GetSelection()
278 name = self._name_for_id[self._canonical_id(_id)]
279 if self._is_curve(name):
281 playlist = self._playlists[p_name]
282 c = playlist.current()
283 assert c.name == c_name, '%s != %s' % (c.name, c_name)
285 playlist = self._playlists[name]
286 return playlist.current()
288 # Set selection (via user interaction with this panel)
290 # These are hooks for HookeFrame callbacks which will send
291 # the results back via 'get curve' calling 'set_selected_curve'.
293 def _on_select(self, event):
294 """Select the clicked-on curve/playlist.
296 _id = self.GetSelection()
297 name = self._name_for_id[self._canonical_id(_id)]
298 if self._is_curve(name):
300 self._on_set_selected_curve(p_name, c_name)
302 self._on_set_selected_playlist(name)
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])
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]
313 for i,c in enumerate(playlist):
318 raise ValueError(name)
319 in_callback(self, playlist, curve)
321 # Set selection (from the HookeFrame)
323 def set_selected_curve(self, playlist, curve):
324 """Make the curve the playlist's current curve.
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)
333 def update_playlist(self, playlist):
334 """Absorb changed `.index()`, etc.
336 self._playlists[playlist.name] = playlist
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):
350 if pname != playlist.name:
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
357 def is_playlist_loaded(self, playlist):
358 """Return `True` if `playlist` is loaded, `False` otherwise.
360 return self.is_playlist_name_loaded(playlist.name)
362 def is_playlist_name_loaded(self, name):
363 """Return `True` if a playlist named `name` is loaded, `False`
366 return name in self._playlists
369 class Playlist (Panel, wx.Panel):
370 """:class:`wx.Panel` subclass wrapper for :class:`Tree`.
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)
379 size=wx.Size(160, 250),
380 style=wx.TR_DEFAULT_STYLE | wx.NO_BORDER | wx.TR_HIDE_ROOT),
383 sizer = wx.BoxSizer(wx.VERTICAL)
384 sizer.Add(self._c['tree'], 1, wx.EXPAND)
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