Use dict-based format strings for numbers (bug 1165)
[gpodder.git] / src / gpodder / gtkui / desktop / episodeselector.py
blob31016682b71cc1626db807659d78305fd81e8d45
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2010 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
27 N_ = gpodder.ngettext
29 from gpodder import util
30 from gpodder.liblogger import log
32 from gpodder.gtkui.interface.common import BuilderWidget
34 class gPodderEpisodeSelector(BuilderWidget):
35 """Episode selection dialog
37 Optional keyword arguments that modify the behaviour of this dialog:
39 - callback: Function that takes 1 parameter which is a list of
40 the selected episodes (or empty list when none selected)
41 - remove_callback: Function that takes 1 parameter which is a list
42 of episodes that should be "removed" (see below)
43 (default is None, which means remove not possible)
44 - remove_action: Label for the "remove" action (default is "Remove")
45 - remove_finished: Callback after all remove callbacks have finished
46 (default is None, also depends on remove_callback)
47 It will get a list of episode URLs that have been
48 removed, so the main UI can update those
49 - episodes: List of episodes that are presented for selection
50 - selected: (optional) List of boolean variables that define the
51 default checked state for the given episodes
52 - selected_default: (optional) The default boolean value for the
53 checked state if no other value is set
54 (default is False)
55 - columns: List of (name, sort_name, sort_type, caption) pairs for the
56 columns, the name is the attribute name of the episode to be
57 read from each episode object. The sort name is the
58 attribute name of the episode to be used to sort this column.
59 If the sort_name is None it will use the attribute name for
60 sorting. The sort type is the type of the sort column.
61 The caption attribute is the text that appear as column caption
62 (default is [('title_markup', None, None, 'Episode'),])
63 - title: (optional) The title of the window + heading
64 - instructions: (optional) A one-line text describing what the
65 user should select / what the selection is for
66 - stock_ok_button: (optional) Will replace the "OK" button with
67 another GTK+ stock item to be used for the
68 affirmative button of the dialog (e.g. can
69 be gtk.STOCK_DELETE when the episodes to be
70 selected will be deleted after closing the
71 dialog)
72 - selection_buttons: (optional) A dictionary with labels as
73 keys and callbacks as values; for each
74 key a button will be generated, and when
75 the button is clicked, the callback will
76 be called for each episode and the return
77 value of the callback (True or False) will
78 be the new selected state of the episode
79 - size_attribute: (optional) The name of an attribute of the
80 supplied episode objects that can be used to
81 calculate the size of an episode; set this to
82 None if no total size calculation should be
83 done (in cases where total size is useless)
84 (default is 'length')
85 - tooltip_attribute: (optional) The name of an attribute of
86 the supplied episode objects that holds
87 the text for the tooltips when hovering
88 over an episode (default is 'description')
90 """
91 finger_friendly_widgets = ['btnCancel', 'btnOK', 'btnCheckAll', 'btnCheckNone', 'treeviewEpisodes']
93 COLUMN_INDEX = 0
94 COLUMN_TOOLTIP = 1
95 COLUMN_TOGGLE = 2
96 COLUMN_ADDITIONAL = 3
98 def new( self):
99 self._config.connect_gtk_window(self.gPodderEpisodeSelector, 'episode_selector', True)
100 if not hasattr( self, 'callback'):
101 self.callback = None
103 if not hasattr(self, 'remove_callback'):
104 self.remove_callback = None
106 if not hasattr(self, 'remove_action'):
107 self.remove_action = _('Remove')
109 if not hasattr(self, 'remove_finished'):
110 self.remove_finished = None
112 if not hasattr( self, 'episodes'):
113 self.episodes = []
115 if not hasattr( self, 'size_attribute'):
116 self.size_attribute = 'length'
118 if not hasattr(self, 'tooltip_attribute'):
119 self.tooltip_attribute = 'description'
121 if not hasattr( self, 'selection_buttons'):
122 self.selection_buttons = {}
124 if not hasattr( self, 'selected_default'):
125 self.selected_default = False
127 if not hasattr( self, 'selected'):
128 self.selected = [self.selected_default]*len(self.episodes)
130 if len(self.selected) < len(self.episodes):
131 self.selected += [self.selected_default]*(len(self.episodes)-len(self.selected))
133 if not hasattr( self, 'columns'):
134 self.columns = (('title_markup', None, None, _('Episode')),)
136 if hasattr( self, 'title'):
137 self.gPodderEpisodeSelector.set_title( self.title)
138 self.labelHeading.set_markup( '<b><big>%s</big></b>' % saxutils.escape( self.title))
140 if hasattr( self, 'instructions'):
141 self.labelInstructions.set_text( self.instructions)
142 self.labelInstructions.show_all()
144 if hasattr(self, 'stock_ok_button'):
145 if self.stock_ok_button == 'gpodder-download':
146 self.btnOK.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_BUTTON))
147 self.btnOK.set_label(_('Download'))
148 else:
149 self.btnOK.set_label(self.stock_ok_button)
150 self.btnOK.set_use_stock(True)
152 # check/uncheck column
153 toggle_cell = gtk.CellRendererToggle()
154 toggle_cell.connect( 'toggled', self.toggle_cell_handler)
155 toggle_column = gtk.TreeViewColumn('', toggle_cell, active=self.COLUMN_TOGGLE)
156 toggle_column.set_clickable(True)
157 self.treeviewEpisodes.append_column(toggle_column)
159 next_column = self.COLUMN_ADDITIONAL
160 for name, sort_name, sort_type, caption in self.columns:
161 renderer = gtk.CellRendererText()
162 if next_column < self.COLUMN_ADDITIONAL + 1:
163 renderer.set_property('ellipsize', pango.ELLIPSIZE_END)
164 column = gtk.TreeViewColumn(caption, renderer, markup=next_column)
165 column.set_clickable(False)
166 column.set_resizable( True)
167 # Only set "expand" on the first column
168 if next_column < self.COLUMN_ADDITIONAL + 1:
169 column.set_expand(True)
170 if sort_name is not None:
171 column.set_sort_column_id(next_column+1)
172 else:
173 column.set_sort_column_id(next_column)
174 self.treeviewEpisodes.append_column( column)
175 next_column += 1
177 if sort_name is not None:
178 # add the sort column
179 column = gtk.TreeViewColumn()
180 column.set_clickable(False)
181 column.set_visible(False)
182 self.treeviewEpisodes.append_column( column)
183 next_column += 1
185 column_types = [ int, str, bool ]
186 # add string column type plus sort column type if it exists
187 for name, sort_name, sort_type, caption in self.columns:
188 column_types.append(str)
189 if sort_name is not None:
190 column_types.append(sort_type)
191 self.model = gtk.ListStore( *column_types)
193 tooltip = None
194 for index, episode in enumerate( self.episodes):
195 if self.tooltip_attribute is not None:
196 try:
197 tooltip = getattr(episode, self.tooltip_attribute)
198 except:
199 log('Episode object %s does not have tooltip attribute: "%s"', episode, self.tooltip_attribute, sender=self)
200 tooltip = None
201 row = [ index, tooltip, self.selected[index] ]
202 for name, sort_name, sort_type, caption in self.columns:
203 if not hasattr(episode, name):
204 log('Warning: Missing attribute "%s"', name, sender=self)
205 row.append(None)
206 else:
207 row.append(getattr( episode, name))
209 if sort_name is not None:
210 if not hasattr(episode, sort_name):
211 log('Warning: Missing attribute "%s"', sort_name, sender=self)
212 row.append(None)
213 else:
214 row.append(getattr( episode, sort_name))
215 self.model.append( row)
217 if self.remove_callback is not None:
218 self.btnRemoveAction.show()
219 self.btnRemoveAction.set_label(self.remove_action)
221 # connect to tooltip signals
222 if self.tooltip_attribute is not None:
223 try:
224 self.treeviewEpisodes.set_property('has-tooltip', True)
225 self.treeviewEpisodes.connect('query-tooltip', self.treeview_episodes_query_tooltip)
226 except:
227 log('I cannot set has-tooltip/query-tooltip (need at least PyGTK 2.12)', sender=self)
228 self.last_tooltip_episode = None
229 self.episode_list_can_tooltip = True
231 self.treeviewEpisodes.connect('button-press-event', self.treeview_episodes_button_pressed)
232 self.treeviewEpisodes.set_rules_hint( True)
233 self.treeviewEpisodes.set_model( self.model)
234 self.treeviewEpisodes.columns_autosize()
236 # Focus the toggle column for Tab-focusing (bug 503)
237 path, column = self.treeviewEpisodes.get_cursor()
238 if path is not None:
239 self.treeviewEpisodes.set_cursor(path, toggle_column)
241 self.calculate_total_size()
243 def treeview_episodes_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
244 # With get_bin_window, we get the window that contains the rows without
245 # the header. The Y coordinate of this window will be the height of the
246 # treeview header. This is the amount we have to subtract from the
247 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
248 (x_bin, y_bin) = treeview.get_bin_window().get_position()
249 y -= x_bin
250 y -= y_bin
251 (path, column, rx, ry) = treeview.get_path_at_pos(x, y) or (None,)*4
253 if not self.episode_list_can_tooltip or column != treeview.get_columns()[1]:
254 self.last_tooltip_episode = None
255 return False
257 if path is not None:
258 model = treeview.get_model()
259 iter = model.get_iter(path)
260 index = model.get_value(iter, self.COLUMN_INDEX)
261 description = model.get_value(iter, self.COLUMN_TOOLTIP)
262 if self.last_tooltip_episode is not None and self.last_tooltip_episode != index:
263 self.last_tooltip_episode = None
264 return False
265 self.last_tooltip_episode = index
267 description = util.remove_html_tags(description)
268 if description is not None:
269 if len(description) > 400:
270 description = description[:398]+'[...]'
271 tooltip.set_text(description)
272 return True
273 else:
274 return False
276 self.last_tooltip_episode = None
277 return False
279 def treeview_episodes_button_pressed(self, treeview, event):
280 if event.button == 3:
281 menu = gtk.Menu()
283 if len(self.selection_buttons):
284 for label in self.selection_buttons:
285 item = gtk.MenuItem(label)
286 item.connect('activate', self.custom_selection_button_clicked, label)
287 menu.append(item)
288 menu.append(gtk.SeparatorMenuItem())
290 item = gtk.MenuItem(_('Select all'))
291 item.connect('activate', self.on_btnCheckAll_clicked)
292 menu.append(item)
294 item = gtk.MenuItem(_('Select none'))
295 item.connect('activate', self.on_btnCheckNone_clicked)
296 menu.append(item)
298 menu.show_all()
299 # Disable tooltips while we are showing the menu, so
300 # the tooltip will not appear over the menu
301 self.episode_list_can_tooltip = False
302 menu.connect('deactivate', lambda menushell: self.episode_list_allow_tooltips())
303 menu.popup(None, None, None, event.button, event.time)
305 return True
307 def episode_list_allow_tooltips(self):
308 self.episode_list_can_tooltip = True
310 def calculate_total_size( self):
311 if self.size_attribute is not None:
312 (total_size, count) = (0, 0)
313 for episode in self.get_selected_episodes():
314 try:
315 total_size += int(getattr( episode, self.size_attribute))
316 count += 1
317 except:
318 log( 'Cannot get size for %s', episode.title, sender = self)
320 text = []
321 if count == 0:
322 text.append(_('Nothing selected'))
323 text.append(N_('%(count)d episode', '%(count)d episodes', count) % {'count':count})
324 if total_size > 0:
325 text.append(_('size: %s') % util.format_filesize(total_size))
326 self.labelTotalSize.set_text(', '.join(text))
327 self.btnOK.set_sensitive(count>0)
328 self.btnRemoveAction.set_sensitive(count>0)
329 if count > 0:
330 self.btnCancel.set_label(gtk.STOCK_CANCEL)
331 else:
332 self.btnCancel.set_label(gtk.STOCK_CLOSE)
333 else:
334 self.btnOK.set_sensitive(False)
335 self.btnRemoveAction.set_sensitive(False)
336 for index, row in enumerate(self.model):
337 if self.model.get_value(row.iter, self.COLUMN_TOGGLE) == True:
338 self.btnOK.set_sensitive(True)
339 self.btnRemoveAction.set_sensitive(True)
340 break
341 self.labelTotalSize.set_text('')
343 def toggle_cell_handler( self, cell, path):
344 model = self.treeviewEpisodes.get_model()
345 model[path][self.COLUMN_TOGGLE] = not model[path][self.COLUMN_TOGGLE]
347 self.calculate_total_size()
349 def custom_selection_button_clicked(self, button, label):
350 callback = self.selection_buttons[label]
352 for index, row in enumerate( self.model):
353 new_value = callback( self.episodes[index])
354 self.model.set_value( row.iter, self.COLUMN_TOGGLE, new_value)
356 self.calculate_total_size()
358 def on_btnCheckAll_clicked( self, widget):
359 for row in self.model:
360 self.model.set_value( row.iter, self.COLUMN_TOGGLE, True)
362 self.calculate_total_size()
364 def on_btnCheckNone_clicked( self, widget):
365 for row in self.model:
366 self.model.set_value( row.iter, self.COLUMN_TOGGLE, False)
368 self.calculate_total_size()
370 def on_remove_action_activate(self, widget):
371 episodes = self.get_selected_episodes(remove_episodes=True)
373 urls = []
374 for episode in episodes:
375 urls.append(episode.url)
376 self.remove_callback(episode)
378 if self.remove_finished is not None:
379 self.remove_finished(urls)
380 self.calculate_total_size()
382 # Close the window when there are no episodes left
383 model = self.treeviewEpisodes.get_model()
384 if model.get_iter_first() is None:
385 self.on_btnCancel_clicked(None)
387 def on_row_activated(self, treeview, path, view_column):
388 model = treeview.get_model()
389 iter = model.get_iter(path)
390 value = model.get_value(iter, self.COLUMN_TOGGLE)
391 model.set_value(iter, self.COLUMN_TOGGLE, not value)
393 self.calculate_total_size()
395 def get_selected_episodes( self, remove_episodes=False):
396 selected_episodes = []
398 for index, row in enumerate( self.model):
399 if self.model.get_value( row.iter, self.COLUMN_TOGGLE) == True:
400 selected_episodes.append( self.episodes[self.model.get_value( row.iter, self.COLUMN_INDEX)])
402 if remove_episodes:
403 for episode in selected_episodes:
404 index = self.episodes.index(episode)
405 iter = self.model.get_iter_first()
406 while iter is not None:
407 if self.model.get_value(iter, self.COLUMN_INDEX) == index:
408 self.model.remove(iter)
409 break
410 iter = self.model.iter_next(iter)
412 return selected_episodes
414 def on_btnOK_clicked( self, widget):
415 self.gPodderEpisodeSelector.destroy()
416 if self.callback is not None:
417 self.callback( self.get_selected_episodes())
419 def on_btnCancel_clicked( self, widget):
420 self.gPodderEpisodeSelector.destroy()
421 if self.callback is not None:
422 self.callback([])