1 # Copyright (C) 2010-2012 W. Trevor King <wking@drexel.edu>
3 # This file is part of Hooke.
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
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
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/>.
18 """Playlist panel for Hooke.
20 Provides a nice GUI interface to the
21 :class:`~hooke.plugin.playlist.PlaylistPlugin`.
29 from ....util.callback import callback, in_callback
34 """Popup menu for selecting playlist :class:`Tree` actions.
36 def __init__(self, on_delete, *args, **kwargs):
37 super(Menu, self).__init__(*args, **kwargs)
39 'delete': self.Append(id=wx.ID_ANY, text='Delete'),
41 self.Bind(wx.EVT_MENU, on_delete)
44 class Tree (wx.TreeCtrl):
45 """:class:`wx.TreeCtrl` subclass handling playlist and curve selection.
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)
64 'menu': Menu(self._on_delete),
65 'root': self.AddRoot(text='Playlists', image=self.image['root'])
67 self.Bind(wx.EVT_RIGHT_DOWN, self._on_context_menu)
68 self.Bind(wx.EVT_TREE_SEL_CHANGED, self._on_select)
70 self._setup_playlists()
72 def _setup_playlists(self):
73 self._playlists = {} # {name: hooke.playlist.Playlist()}
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}
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`.
84 # Playlist names are strings, Curve names are tuples.
85 # See ._setup_playlists().
86 return not isinstance(name, types.StringTypes)
88 def _canonical_id(self, _id):
89 """Return a canonical form of `_id` suitable for accessing `._name_for_id`.
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.
99 for c_id in self._name_for_id.keys():
107 def _on_context_menu(self, event):
108 """Launch a popup :class:`Menu` with per-playlist/curve activities.
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())
118 # add_* called directly by HookeFrame
119 # _add_* called on every addition
121 def add_playlist(self, playlist):
122 """Add a :class:`hooke.playlist.Playlist` to the tree.
124 Calls :meth:`_add_playlist` and triggers a callback.
126 self._add_playlist(playlist)
127 in_callback(self, playlist)
129 def _add_playlist(self, playlist):
130 """Add a class:`hooke.playlist.Playlist` to the tree.
132 No callback triggered.
134 if playlist.name not in self._playlists:
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)
148 def add_curve(self, playlist_name, curve):
149 """Add a :class:`hooke.curve.Curve` to a curently loaded playlist.
151 Calls :meth:`_add_curve` and triggers a callback.
153 self._add_curve(playlist_name, curve)
154 playlist = self._playlists[playlist_name]
155 in_callback(self, playlist, curve)
157 def _add_curve(self, playlist_name, curve):
158 """Add a class:`hooke.curve.Curve` to the tree.
160 No callback triggered.
162 p = self._playlists[playlist_name]
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)
173 def generate_new_playlist(self):
176 def _GetUniquePlaylistName(self, name): # TODO
179 while playlist_name in self.playlists:
180 playlist_name = ''.join([name, str(count)])
185 # delete_* called by _on_delete handler (user click) or HookeFrame
186 # _delete_* called on every deletion
188 def _on_delete(self, event):
189 """Handler for :class:`Menu`'s `Delete` button.
191 Determines the clicked item and calls the appropriate
192 `.delete_*()` method on it.
194 #if hasattr(self, '_hit_id'): # called via ._c['menu']
197 name = self._name_for_id[_id]
198 if self._is_curve(name):
199 self._delete_curve(playlist_name=name[0], name=name[1])
201 self._delete_playlist(name)
203 def _delete_playlist(self, name):
204 """Delete a :class:`hooke.playlist.Playlist` by name.
206 Called by the :meth:`_on_delete` handler. Calls the
207 approptiate interface callback.
209 _id = self._id_for_name[name]
210 playlist = self._playlists[name]
211 in_callback(self, playlist)
213 def delete_playlist(self, playlist):
214 """Respond to playlist deletion.
216 Called on *every* playlist deletion.
218 self._playlists.pop(playlist.name)
219 _id = self._id_for_name.pop(playlist.name)
221 del(self._name_for_id[_id])
222 for curve in playlist:
223 self._delete_curve(playlist, curve)
225 def _delete_curve(self, playlist_name, name):
226 """Delete a :class:`hooke.curve.Curve` by name.
228 Called by the :meth:`_on_delete` handler. Calls the
229 approptiate interface callback.
231 _id = self._id_for_name[(playlist_name, name)]
232 playlist = self._playlists[playlist_name]
234 for i,c in enumerate(playlist):
239 raise ValueError(name)
240 in_callback(self, playlist, curve)
242 def delete_curve(self, playlist_name, name):
243 """Respond to curve deletions.
245 Called on *every* curve deletion.
247 _id = self._id_for_name.pop((playlist_name, name))
249 del(self._name_for_id[_id])
253 def get_selected_playlist(self):
254 """Return the selected :class:`hooke.playlist.Playlist`.
256 _id = self.GetSelection()
258 _id = self._canonical_id(_id)
259 except KeyError: # no playlist selected
261 name = self._name_for_id[_id]
262 if self._is_curve(name):
264 return self._playlists[name]
266 def get_selected_curve(self):
267 """Return the selected :class:`hooke.curve.Curve`.
269 _id = self.GetSelection()
270 name = self._name_for_id[self._canonical_id(_id)]
271 if self._is_curve(name):
273 playlist = self._playlists[p_name]
274 c = playlist.current()
275 assert c.name == c_name, '%s != %s' % (c.name, c_name)
277 playlist = self._playlists[name]
278 return playlist.current()
280 # Set selection (via user interaction with this panel)
282 # These are hooks for HookeFrame callbacks which will send
283 # the results back via 'get curve' calling 'set_selected_curve'.
285 def _on_select(self, event):
286 """Select the clicked-on curve/playlist.
288 _id = self.GetSelection()
289 name = self._name_for_id[self._canonical_id(_id)]
290 if self._is_curve(name):
292 self._on_set_selected_curve(p_name, c_name)
294 self._on_set_selected_playlist(name)
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])
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]
305 for i,c in enumerate(playlist):
310 raise ValueError(name)
311 in_callback(self, playlist, curve)
313 # Set selection (from the HookeFrame)
315 def set_selected_curve(self, playlist, curve):
316 """Make the curve the playlist's current curve.
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)
325 def update_playlist(self, playlist):
326 """Absorb changed `.index()`, etc.
328 self._playlists[playlist.name] = playlist
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):
338 if pname != playlist.name:
340 if cname not in cnames:
341 self.delete_curve(playlist_name=pname, name=cname)
343 def is_playlist_loaded(self, playlist):
344 """Return `True` if `playlist` is loaded, `False` otherwise.
346 return self.is_playlist_name_loaded(playlist.name)
348 def is_playlist_name_loaded(self, name):
349 """Return `True` if a playlist named `name` is loaded, `False`
352 return name in self._playlists
355 class Playlist (Panel, wx.Panel):
356 """:class:`wx.Panel` subclass wrapper for :class:`Tree`.
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)
365 size=wx.Size(160, 250),
366 style=wx.TR_DEFAULT_STYLE | wx.NO_BORDER | wx.TR_HIDE_ROOT),
369 sizer = wx.BoxSizer(wx.VERTICAL)
370 sizer.Add(self._c['tree'], 1, wx.EXPAND)
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