Use dict-based format strings for numbers (bug 1165)
[gpodder.git] / src / gpodder / gtkui / frmntl / episodeselector.py
blobd084a2c7c98d4113cf2d867c447c8284bc759d9d
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 hildon
22 import pango
23 import re
25 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
33 from gpodder.gtkui.interface.common import Orientation
35 from gpodder.gtkui.frmntl import style
37 class gPodderEpisodeSelector(BuilderWidget):
38 """Episode selection dialog
40 Optional keyword arguments that modify the behaviour of this dialog:
42 - callback: Function that takes 1 parameter which is a list of
43 the selected episodes (or empty list when none selected)
44 - remove_callback: Function that takes 1 parameter which is a list
45 of episodes that should be "removed" (see below)
46 (default is None, which means remove not possible)
47 - remove_action: Label for the "remove" action (default is "Remove")
48 - remove_finished: Callback after all remove callbacks have finished
49 (default is None, also depends on remove_callback)
50 It will get a list of episode URLs that have been
51 removed, so the main UI can update those
52 - episodes: List of episodes that are presented for selection
53 - selected: (optional) List of boolean variables that define the
54 default checked state for the given episodes
55 - selected_default: (optional) The default boolean value for the
56 checked state if no other value is set
57 (default is False)
58 - columns: List of (name, sort_name, sort_type, caption) pairs for the
59 columns, the name is the attribute name of the episode to be
60 read from each episode object. The sort name is the
61 attribute name of the episode to be used to sort this column.
62 If the sort_name is None it will use the attribute name for
63 sorting. The sort type is the type of the sort column.
64 The caption attribute is the text that appear as column caption
65 (default is [('title_markup', None, None, 'Episode'),])
66 - title: (optional) The title of the window + heading
67 - instructions: (optional) A one-line text describing what the
68 user should select / what the selection is for
69 - stock_ok_button: (optional) Will replace the "OK" button with
70 another GTK+ stock item to be used for the
71 affirmative button of the dialog (e.g. can
72 be gtk.STOCK_DELETE when the episodes to be
73 selected will be deleted after closing the
74 dialog)
75 - selection_buttons: (optional) A dictionary with labels as
76 keys and callbacks as values; for each
77 key a button will be generated, and when
78 the button is clicked, the callback will
79 be called for each episode and the return
80 value of the callback (True or False) will
81 be the new selected state of the episode
82 - size_attribute: (optional) The name of an attribute of the
83 supplied episode objects that can be used to
84 calculate the size of an episode; set this to
85 None if no total size calculation should be
86 done (in cases where total size is useless)
87 (default is 'length')
88 - tooltip_attribute: (optional) The name of an attribute of
89 the supplied episode objects that holds
90 the text for the tooltips when hovering
91 over an episode (default is 'description')
93 """
94 finger_friendly_widgets = ['btnRemoveAction', 'btnOK']
96 COLUMN_INDEX = 0
97 COLUMN_TOOLTIP = 1
98 COLUMN_TOGGLE = 2
99 COLUMN_ADDITIONAL = 3
101 def new( self):
102 self._config.connect_gtk_window(self.gPodderEpisodeSelector, 'episode_selector', True)
103 if not hasattr( self, 'callback'):
104 self.callback = None
106 if not hasattr(self, 'remove_callback'):
107 self.remove_callback = None
109 if not hasattr(self, 'remove_action'):
110 self.remove_action = _('Remove')
112 if not hasattr(self, 'remove_finished'):
113 self.remove_finished = None
115 if not hasattr( self, 'episodes'):
116 self.episodes = []
118 if not hasattr( self, 'size_attribute'):
119 self.size_attribute = 'length'
121 if not hasattr(self, 'tooltip_attribute'):
122 self.tooltip_attribute = 'description'
124 if not hasattr( self, 'selection_buttons'):
125 self.selection_buttons = {}
127 if not hasattr( self, 'selected_default'):
128 self.selected_default = False
130 if not hasattr( self, 'selected'):
131 self.selected = [self.selected_default]*len(self.episodes)
133 if len(self.selected) < len(self.episodes):
134 self.selected += [self.selected_default]*(len(self.episodes)-len(self.selected))
136 if not hasattr( self, 'columns'):
137 self.columns = (('title_markup', None, None, _('Episode')),)
139 if hasattr( self, 'title'):
140 self.gPodderEpisodeSelector.set_title( self.title)
142 if hasattr(self, 'instructions'):
143 #self.show_message(self.instructions)
144 pass
146 if self.remove_callback is not None:
147 self.btnRemoveAction.show()
148 self.btnRemoveAction.set_label(self.remove_action)
150 if hasattr(self, 'stock_ok_button'):
151 if self.stock_ok_button == 'gpodder-download':
152 self.btnOK.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_BUTTON))
153 self.btnOK.set_label(_('Download'))
154 else:
155 self.btnOK.set_label(self.stock_ok_button)
156 self.btnOK.set_use_stock(True)
158 # Work around Maemo bug #4718
159 self.btnOK.set_name('HildonButton-finger')
160 self.btnRemoveAction.set_name('HildonButton-finger')
162 # Make sure the window comes up quick
163 self.main_window.show()
164 self.main_window.present()
165 while gtk.events_pending():
166 gtk.main_iteration(False)
168 # Determine the styling for the list items
169 head_font = style.get_font_desc('SystemFont')
170 head_color = style.get_color('ButtonTextColor')
171 head = (head_font.to_string(), head_color.to_string())
172 head = '<span font_desc="%s" foreground="%s">%%s</span>' % head
173 sub_font = style.get_font_desc('SmallSystemFont')
174 sub_color = style.get_color('SecondaryTextColor')
175 sub = (sub_font.to_string(), sub_color.to_string())
176 sub = '<span font_desc="%s" foreground="%s">%%s</span>' % sub
177 self._markup_template = '\n'.join((head, sub))
179 # Context menu stuff for the treeview (shownotes)
180 self.touched_episode = None
181 if hasattr(self, 'show_episode_shownotes'):
182 self.context_menu = gtk.Menu()
183 # "Emulate" hildon_gtk_menu_new
184 self.context_menu.set_name('hildon-context-sensitive-menu')
185 self.context_menu.append(self.action_shownotes.create_menu_item())
186 self.context_menu.show_all()
187 self.treeviewEpisodes.connect('button-press-event', self.on_treeview_button_press)
188 self.treeviewEpisodes.tap_and_hold_setup(self.context_menu)
190 # This regex gets the two lines of the normal Maemo markup,
191 # as used on Maemo 4 (see maemo_markup() in gpodder.model)
192 markup_re = re.compile(r'<b>(.*)</b>\n<small>(.*)</small>')
194 next_column = self.COLUMN_ADDITIONAL
195 for name, sort_name, sort_type, caption in self.columns:
196 renderer = gtk.CellRendererText()
197 if next_column < self.COLUMN_ADDITIONAL + 2:
198 renderer.set_property('ellipsize', pango.ELLIPSIZE_END)
199 column = gtk.TreeViewColumn(caption, renderer, markup=next_column)
200 column.set_resizable( True)
201 # Only set "expand" on the first column
202 if next_column < self.COLUMN_ADDITIONAL + 1:
203 column.set_expand(True)
204 if sort_name is not None:
205 column.set_sort_column_id(next_column+1)
206 else:
207 column.set_sort_column_id(next_column)
208 self.treeviewEpisodes.append_column( column)
209 next_column += 1
211 if sort_name is not None:
212 # add the sort column
213 column = gtk.TreeViewColumn()
214 column.set_visible(False)
215 self.treeviewEpisodes.append_column( column)
216 next_column += 1
218 column_types = [ int, str, bool ]
219 # add string column type plus sort column type if it exists
220 for name, sort_name, sort_type, caption in self.columns:
221 column_types.append(str)
222 if sort_name is not None:
223 column_types.append(sort_type)
224 self.model = gtk.ListStore( *column_types)
226 tooltip = None
227 for index, episode in enumerate( self.episodes):
228 if self.tooltip_attribute is not None:
229 try:
230 tooltip = getattr(episode, self.tooltip_attribute)
231 except:
232 log('Episode object %s does not have tooltip attribute: "%s"', episode, self.tooltip_attribute, sender=self)
233 tooltip = None
234 row = [ index, tooltip, self.selected[index] ]
235 for name, sort_name, sort_type, caption in self.columns:
236 if name.startswith('maemo_') and name.endswith('markup'):
237 # This will fetch the Maemo 4 markup from the object
238 # and then filter the two lines (using markup_re.match)
239 # and use the markup template to create Maemo 5 markup
240 markup = getattr(episode, name)
241 args = markup_re.match(markup).groups()
242 row.append(self._markup_template % args)
243 elif not hasattr(episode, name):
244 log('Warning: Missing attribute "%s"', name, sender=self)
245 row.append(None)
246 else:
247 row.append(getattr( episode, name))
249 if sort_name is not None:
250 if not hasattr(episode, sort_name):
251 log('Warning: Missing attribute "%s"', sort_name, sender=self)
252 row.append(None)
253 else:
254 row.append(getattr( episode, sort_name))
255 self.model.append( row)
257 self.treeviewEpisodes.set_rules_hint( True)
258 self.treeviewEpisodes.set_model( self.model)
259 self.treeviewEpisodes.columns_autosize()
260 self.calculate_total_size()
262 selection = self.treeviewEpisodes.get_selection()
263 selection.connect('changed', self.on_selection_changed)
264 selection.set_mode(gtk.SELECTION_MULTIPLE)
265 selection.unselect_all()
267 appmenu = hildon.AppMenu()
268 for action in (self.action_select_all, \
269 self.action_select_none):
270 button = gtk.Button()
271 action.connect_proxy(button)
272 appmenu.append(button)
274 if self.selection_buttons:
275 for label in self.selection_buttons:
276 button = gtk.Button(label)
277 button.connect('clicked', self.custom_selection_button_clicked, label)
278 appmenu.append(button)
280 appmenu.show_all()
281 self.main_window.set_app_menu(appmenu)
283 def on_window_orientation_changed(self, orientation):
284 self.labelTotalSize.set_property('visible', \
285 orientation == Orientation.LANDSCAPE)
287 def on_selection_changed(self, selection):
288 self.calculate_total_size()
290 def on_treeview_button_press(self, widget, event):
291 result = self.treeviewEpisodes.get_path_at_pos(int(event.x), int(event.y))
292 if result is not None:
293 path, column, x, y = result
294 model = self.treeviewEpisodes.get_model()
295 index = model.get_value(model.get_iter(path), self.COLUMN_INDEX)
297 self.action_shownotes.set_property('visible', True)
298 self.touched_episode = self.episodes[index]
299 else:
300 self.action_shownotes.set_property('visible', False)
301 self.touched_episode = None
303 def on_treeview_button_release(self, widget, event):
304 selection = widget.get_selection()
305 self.on_selection_changed(widget.get_selection())
307 def on_select_all_button_clicked(self, widget):
308 selection = self.treeviewEpisodes.get_selection()
309 selection.select_all()
310 self.calculate_total_size()
312 def on_select_none_button_clicked(self, widget):
313 selection = self.treeviewEpisodes.get_selection()
314 selection.unselect_all()
315 self.calculate_total_size()
317 def on_close_button_clicked(self, widget):
318 self.on_btnCancel_clicked(widget)
320 def on_shownotes_button_clicked(self, widget):
321 if self.touched_episode is not None:
322 self.show_episode_shownotes(self.touched_episode)
324 def calculate_total_size( self):
325 if self.size_attribute is not None:
326 (total_size, count) = (0, 0)
327 for episode in self.get_selected_episodes():
328 try:
329 total_size += int(getattr( episode, self.size_attribute))
330 count += 1
331 except:
332 log( 'Cannot get size for %s', episode.title, sender = self)
334 text = []
335 if count == 0:
336 text.append(_('Nothing selected'))
337 else:
338 text.append(N_('%(count)d episode', '%(count)d episodes', count) % {'count':count})
339 if total_size > 0:
340 text.append(_('size: %s') % util.format_filesize(total_size))
341 self.labelTotalSize.set_text(', '.join(text))
342 self.btnOK.set_sensitive(count>0)
343 self.btnRemoveAction.set_sensitive(count>0)
344 else:
345 selection = self.treeviewEpisodes.get_selection()
346 selected_rows = selection.count_selected_rows()
347 self.btnOK.set_sensitive(selected_rows > 0)
348 self.btnRemoveAction.set_sensitive(selected_rows > 0)
349 self.labelTotalSize.set_text('')
351 def custom_selection_button_clicked(self, button, label):
352 callback = self.selection_buttons[label]
354 selection = self.treeviewEpisodes.get_selection()
355 selection.unselect_all()
356 for index, row in enumerate(self.model):
357 if callback(self.episodes[index]):
358 selection.select_path(row.path)
360 self.calculate_total_size()
362 def on_remove_action_activate(self, widget):
363 # Show progress icon and make sure the UI is updated already
364 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
365 while gtk.events_pending():
366 gtk.main_iteration(False)
368 episodes = self.get_selected_episodes(remove_episodes=True)
370 urls = []
371 for episode in episodes:
372 urls.append(episode.url)
373 self.remove_callback(episode)
375 if self.remove_finished is not None:
376 self.remove_finished(urls)
377 self.calculate_total_size()
379 # Hide the progress indicator after the update has finished
380 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
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 get_selected_episodes( self, remove_episodes=False):
388 selection = self.treeviewEpisodes.get_selection()
389 model, paths = selection.get_selected_rows()
391 selected_episodes = [self.episodes[model.get_value(\
392 model.get_iter(path), self.COLUMN_INDEX)] \
393 for path in paths]
395 if remove_episodes:
396 for episode in selected_episodes:
397 index = self.episodes.index(episode)
398 iter = self.model.get_iter_first()
399 while iter is not None:
400 if self.model.get_value(iter, self.COLUMN_INDEX) == index:
401 self.model.remove(iter)
402 break
403 iter = self.model.iter_next(iter)
405 return selected_episodes
407 def on_btnOK_clicked( self, widget):
408 selected = self.get_selected_episodes()
409 self.gPodderEpisodeSelector.destroy()
411 # Process UI events to make the window disappear quickly,
412 # because the callback below can take quite some time if
413 # for example downloads are being started from it.
414 while gtk.events_pending():
415 gtk.main_iteration(False)
417 if self.callback is not None:
418 self.callback(selected)
420 def on_btnCancel_clicked(self, widget):
421 self.gPodderEpisodeSelector.destroy()
422 if self.callback is not None:
423 self.callback([])