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