Fix focus/toggle problem in episode selector (bug 503)
[gpodder.git] / src / gpodder / gtkui / desktop / episodeselector.py
blobdfa892529ec51a4c455ce696fd4694411aeb1da4
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2009 Thomas Perl and the gPodder Team
6 # gPodder is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
11 # gPodder is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 import gtk
21 import pango
22 from xml.sax import saxutils
24 import gpodder
26 _ = gpodder.gettext
28 from gpodder import util
29 from gpodder.liblogger import log
31 from gpodder.gtkui.interface.common import BuilderWidget
33 class gPodderEpisodeSelector(BuilderWidget):
34 """Episode selection dialog
36 Optional keyword arguments that modify the behaviour of this dialog:
38 - callback: Function that takes 1 parameter which is a list of
39 the selected episodes (or empty list when none selected)
40 - remove_callback: Function that takes 1 parameter which is a list
41 of episodes that should be "removed" (see below)
42 (default is None, which means remove not possible)
43 - remove_action: Label for the "remove" action (default is "Remove")
44 - remove_finished: Callback after all remove callbacks have finished
45 (default is None, also depends on remove_callback)
46 It will get a list of episode URLs that have been
47 removed, so the main UI can update those
48 - episodes: List of episodes that are presented for selection
49 - selected: (optional) List of boolean variables that define the
50 default checked state for the given episodes
51 - selected_default: (optional) The default boolean value for the
52 checked state if no other value is set
53 (default is False)
54 - columns: List of (name, sort_name, sort_type, caption) pairs for the
55 columns, the name is the attribute name of the episode to be
56 read from each episode object. The sort name is the
57 attribute name of the episode to be used to sort this column.
58 If the sort_name is None it will use the attribute name for
59 sorting. The sort type is the type of the sort column.
60 The caption attribute is the text that appear as column caption
61 (default is [('title_markup', None, None, 'Episode'),])
62 - title: (optional) The title of the window + heading
63 - instructions: (optional) A one-line text describing what the
64 user should select / what the selection is for
65 - stock_ok_button: (optional) Will replace the "OK" button with
66 another GTK+ stock item to be used for the
67 affirmative button of the dialog (e.g. can
68 be gtk.STOCK_DELETE when the episodes to be
69 selected will be deleted after closing the
70 dialog)
71 - selection_buttons: (optional) A dictionary with labels as
72 keys and callbacks as values; for each
73 key a button will be generated, and when
74 the button is clicked, the callback will
75 be called for each episode and the return
76 value of the callback (True or False) will
77 be the new selected state of the episode
78 - size_attribute: (optional) The name of an attribute of the
79 supplied episode objects that can be used to
80 calculate the size of an episode; set this to
81 None if no total size calculation should be
82 done (in cases where total size is useless)
83 (default is 'length')
84 - tooltip_attribute: (optional) The name of an attribute of
85 the supplied episode objects that holds
86 the text for the tooltips when hovering
87 over an episode (default is 'description')
89 """
90 finger_friendly_widgets = ['btnCancel', 'btnOK', 'btnCheckAll', 'btnCheckNone', 'treeviewEpisodes']
92 COLUMN_INDEX = 0
93 COLUMN_TOOLTIP = 1
94 COLUMN_TOGGLE = 2
95 COLUMN_ADDITIONAL = 3
97 def new( self):
98 self._config.connect_gtk_window(self.gPodderEpisodeSelector, 'episode_selector', True)
99 if not hasattr( self, 'callback'):
100 self.callback = None
102 if not hasattr(self, 'remove_callback'):
103 self.remove_callback = None
105 if not hasattr(self, 'remove_action'):
106 self.remove_action = _('Remove')
108 if not hasattr(self, 'remove_finished'):
109 self.remove_finished = None
111 if not hasattr( self, 'episodes'):
112 self.episodes = []
114 if not hasattr( self, 'size_attribute'):
115 self.size_attribute = 'length'
117 if not hasattr(self, 'tooltip_attribute'):
118 self.tooltip_attribute = 'description'
120 if not hasattr( self, 'selection_buttons'):
121 self.selection_buttons = {}
123 if not hasattr( self, 'selected_default'):
124 self.selected_default = False
126 if not hasattr( self, 'selected'):
127 self.selected = [self.selected_default]*len(self.episodes)
129 if len(self.selected) < len(self.episodes):
130 self.selected += [self.selected_default]*(len(self.episodes)-len(self.selected))
132 if not hasattr( self, 'columns'):
133 self.columns = (('title_markup', None, None, _('Episode')),)
135 if hasattr( self, 'title'):
136 self.gPodderEpisodeSelector.set_title( self.title)
137 self.labelHeading.set_markup( '<b><big>%s</big></b>' % saxutils.escape( self.title))
139 if hasattr( self, 'instructions'):
140 self.labelInstructions.set_text( self.instructions)
141 self.labelInstructions.show_all()
143 if hasattr(self, 'stock_ok_button'):
144 if self.stock_ok_button == 'gpodder-download':
145 self.btnOK.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_BUTTON))
146 self.btnOK.set_label(_('Download'))
147 else:
148 self.btnOK.set_label(self.stock_ok_button)
149 self.btnOK.set_use_stock(True)
151 # check/uncheck column
152 toggle_cell = gtk.CellRendererToggle()
153 toggle_cell.connect( 'toggled', self.toggle_cell_handler)
154 toggle_column = gtk.TreeViewColumn('', toggle_cell, active=self.COLUMN_TOGGLE)
155 toggle_column.set_clickable(True)
156 self.treeviewEpisodes.append_column(toggle_column)
158 next_column = self.COLUMN_ADDITIONAL
159 for name, sort_name, sort_type, caption in self.columns:
160 renderer = gtk.CellRendererText()
161 if next_column < self.COLUMN_ADDITIONAL + 1:
162 renderer.set_property('ellipsize', pango.ELLIPSIZE_END)
163 column = gtk.TreeViewColumn(caption, renderer, markup=next_column)
164 column.set_clickable(False)
165 column.set_resizable( True)
166 # Only set "expand" on the first column
167 if next_column < self.COLUMN_ADDITIONAL + 1:
168 column.set_expand(True)
169 if sort_name is not None:
170 column.set_sort_column_id(next_column+1)
171 else:
172 column.set_sort_column_id(next_column)
173 self.treeviewEpisodes.append_column( column)
174 next_column += 1
176 if sort_name is not None:
177 # add the sort column
178 column = gtk.TreeViewColumn()
179 column.set_clickable(False)
180 column.set_visible(False)
181 self.treeviewEpisodes.append_column( column)
182 next_column += 1
184 column_types = [ int, str, bool ]
185 # add string column type plus sort column type if it exists
186 for name, sort_name, sort_type, caption in self.columns:
187 column_types.append(str)
188 if sort_name is not None:
189 column_types.append(sort_type)
190 self.model = gtk.ListStore( *column_types)
192 tooltip = None
193 for index, episode in enumerate( self.episodes):
194 if self.tooltip_attribute is not None:
195 try:
196 tooltip = getattr(episode, self.tooltip_attribute)
197 except:
198 log('Episode object %s does not have tooltip attribute: "%s"', episode, self.tooltip_attribute, sender=self)
199 tooltip = None
200 row = [ index, tooltip, self.selected[index] ]
201 for name, sort_name, sort_type, caption in self.columns:
202 if not hasattr(episode, name):
203 log('Warning: Missing attribute "%s"', name, sender=self)
204 row.append(None)
205 else:
206 row.append(getattr( episode, name))
208 if sort_name is not None:
209 if not hasattr(episode, sort_name):
210 log('Warning: Missing attribute "%s"', sort_name, sender=self)
211 row.append(None)
212 else:
213 row.append(getattr( episode, sort_name))
214 self.model.append( row)
216 if self.remove_callback is not None:
217 self.btnRemoveAction.show()
218 self.btnRemoveAction.set_label(self.remove_action)
220 # connect to tooltip signals
221 if self.tooltip_attribute is not None:
222 try:
223 self.treeviewEpisodes.set_property('has-tooltip', True)
224 self.treeviewEpisodes.connect('query-tooltip', self.treeview_episodes_query_tooltip)
225 except:
226 log('I cannot set has-tooltip/query-tooltip (need at least PyGTK 2.12)', sender=self)
227 self.last_tooltip_episode = None
228 self.episode_list_can_tooltip = True
230 self.treeviewEpisodes.connect('button-press-event', self.treeview_episodes_button_pressed)
231 self.treeviewEpisodes.set_rules_hint( True)
232 self.treeviewEpisodes.set_model( self.model)
233 self.treeviewEpisodes.columns_autosize()
235 # Focus the toggle column for Tab-focusing (bug 503)
236 path, column = self.treeviewEpisodes.get_cursor()
237 self.treeviewEpisodes.set_cursor(path, toggle_column)
239 self.calculate_total_size()
241 def treeview_episodes_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
242 # With get_bin_window, we get the window that contains the rows without
243 # the header. The Y coordinate of this window will be the height of the
244 # treeview header. This is the amount we have to subtract from the
245 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
246 (x_bin, y_bin) = treeview.get_bin_window().get_position()
247 y -= x_bin
248 y -= y_bin
249 (path, column, rx, ry) = treeview.get_path_at_pos(x, y) or (None,)*4
251 if not self.episode_list_can_tooltip or column != treeview.get_columns()[1]:
252 self.last_tooltip_episode = None
253 return False
255 if path is not None:
256 model = treeview.get_model()
257 iter = model.get_iter(path)
258 index = model.get_value(iter, self.COLUMN_INDEX)
259 description = model.get_value(iter, self.COLUMN_TOOLTIP)
260 if self.last_tooltip_episode is not None and self.last_tooltip_episode != index:
261 self.last_tooltip_episode = None
262 return False
263 self.last_tooltip_episode = index
265 description = util.remove_html_tags(description)
266 if description is not None:
267 if len(description) > 400:
268 description = description[:398]+'[...]'
269 tooltip.set_text(description)
270 return True
271 else:
272 return False
274 self.last_tooltip_episode = None
275 return False
277 def treeview_episodes_button_pressed(self, treeview, event):
278 if event.button == 3:
279 menu = gtk.Menu()
281 if len(self.selection_buttons):
282 for label in self.selection_buttons:
283 item = gtk.MenuItem(label)
284 item.connect('activate', self.custom_selection_button_clicked, label)
285 menu.append(item)
286 menu.append(gtk.SeparatorMenuItem())
288 item = gtk.MenuItem(_('Select all'))
289 item.connect('activate', self.on_btnCheckAll_clicked)
290 menu.append(item)
292 item = gtk.MenuItem(_('Select none'))
293 item.connect('activate', self.on_btnCheckNone_clicked)
294 menu.append(item)
296 menu.show_all()
297 # Disable tooltips while we are showing the menu, so
298 # the tooltip will not appear over the menu
299 self.episode_list_can_tooltip = False
300 menu.connect('deactivate', lambda menushell: self.episode_list_allow_tooltips())
301 menu.popup(None, None, None, event.button, event.time)
303 return True
305 def episode_list_allow_tooltips(self):
306 self.episode_list_can_tooltip = True
308 def calculate_total_size( self):
309 if self.size_attribute is not None:
310 (total_size, count) = (0, 0)
311 for episode in self.get_selected_episodes():
312 try:
313 total_size += int(getattr( episode, self.size_attribute))
314 count += 1
315 except:
316 log( 'Cannot get size for %s', episode.title, sender = self)
318 text = []
319 if count == 0:
320 text.append(_('Nothing selected'))
321 elif count == 1:
322 text.append(_('One episode'))
323 else:
324 text.append(_('%d episodes') % count)
325 if total_size > 0:
326 text.append(_('size: %s') % util.format_filesize(total_size))
327 self.labelTotalSize.set_text(', '.join(text))
328 self.btnOK.set_sensitive(count>0)
329 self.btnRemoveAction.set_sensitive(count>0)
330 if count > 0:
331 self.btnCancel.set_label(gtk.STOCK_CANCEL)
332 else:
333 self.btnCancel.set_label(gtk.STOCK_CLOSE)
334 else:
335 self.btnOK.set_sensitive(False)
336 self.btnRemoveAction.set_sensitive(False)
337 for index, row in enumerate(self.model):
338 if self.model.get_value(row.iter, self.COLUMN_TOGGLE) == True:
339 self.btnOK.set_sensitive(True)
340 self.btnRemoveAction.set_sensitive(True)
341 break
342 self.labelTotalSize.set_text('')
344 def toggle_cell_handler( self, cell, path):
345 model = self.treeviewEpisodes.get_model()
346 model[path][self.COLUMN_TOGGLE] = not model[path][self.COLUMN_TOGGLE]
348 self.calculate_total_size()
350 def custom_selection_button_clicked(self, button, label):
351 callback = self.selection_buttons[label]
353 for index, row in enumerate( self.model):
354 new_value = callback( self.episodes[index])
355 self.model.set_value( row.iter, self.COLUMN_TOGGLE, new_value)
357 self.calculate_total_size()
359 def on_btnCheckAll_clicked( self, widget):
360 for row in self.model:
361 self.model.set_value( row.iter, self.COLUMN_TOGGLE, True)
363 self.calculate_total_size()
365 def on_btnCheckNone_clicked( self, widget):
366 for row in self.model:
367 self.model.set_value( row.iter, self.COLUMN_TOGGLE, False)
369 self.calculate_total_size()
371 def on_remove_action_activate(self, widget):
372 episodes = self.get_selected_episodes(remove_episodes=True)
374 urls = []
375 for episode in episodes:
376 urls.append(episode.url)
377 self.remove_callback(episode)
379 if self.remove_finished is not None:
380 self.remove_finished(urls)
381 self.calculate_total_size()
383 # Close the window when there are no episodes left
384 model = self.treeviewEpisodes.get_model()
385 if model.get_iter_first() is None:
386 self.on_btnCancel_clicked(None)
388 def on_row_activated(self, treeview, path, view_column):
389 model = treeview.get_model()
390 iter = model.get_iter(path)
391 value = model.get_value(iter, self.COLUMN_TOGGLE)
392 model.set_value(iter, self.COLUMN_TOGGLE, not value)
394 def get_selected_episodes( self, remove_episodes=False):
395 selected_episodes = []
397 for index, row in enumerate( self.model):
398 if self.model.get_value( row.iter, self.COLUMN_TOGGLE) == True:
399 selected_episodes.append( self.episodes[self.model.get_value( row.iter, self.COLUMN_INDEX)])
401 if remove_episodes:
402 for episode in selected_episodes:
403 index = self.episodes.index(episode)
404 iter = self.model.get_iter_first()
405 while iter is not None:
406 if self.model.get_value(iter, self.COLUMN_INDEX) == index:
407 self.model.remove(iter)
408 break
409 iter = self.model.iter_next(iter)
411 return selected_episodes
413 def on_btnOK_clicked( self, widget):
414 self.gPodderEpisodeSelector.destroy()
415 if self.callback is not None:
416 self.callback( self.get_selected_episodes())
418 def on_btnCancel_clicked( self, widget):
419 self.gPodderEpisodeSelector.destroy()
420 if self.callback is not None:
421 self.callback([])