# -*- coding: utf-8 -*- # # gPodder - A media aggregator and podcast client # Copyright (c) 2005-2018 The gPodder Team # # gPodder is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 3 of the License, or # (at your option) any later version. # # gPodder is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # from gi.repository import Gtk, Pango import gpodder from gpodder import util from gpodder.gtkui.interface.common import BuilderWidget, TreeViewHelper _ = gpodder.gettext N_ = gpodder.ngettext class gPodderEpisodeSelector(BuilderWidget): """Episode selection dialog Optional keyword arguments that modify the behaviour of this dialog: - callback: Function that takes 1 parameter which is a list of the selected episodes (or empty list when none selected) - remove_callback: Function that takes 1 parameter which is a list of episodes that should be "removed" (see below) (default is None, which means remove not possible) - remove_action: Label for the "remove" action (default is "Remove") - remove_finished: Callback after all remove callbacks have finished (default is None, also depends on remove_callback) It will get a list of episode URLs that have been removed, so the main UI can update those - episodes: List of episodes that are presented for selection - selected: (optional) List of boolean variables that define the default checked state for the given episodes - selected_default: (optional) The default boolean value for the checked state if no other value is set (default is False) - columns: List of (name, sort_name, sort_type, caption) pairs for the columns, the name is the attribute name of the episode to be read from each episode object. The sort name is the attribute name of the episode to be used to sort this column. If the sort_name is None it will use the attribute name for sorting. The sort type is the type of the sort column. The caption attribute is the text that appear as column caption (default is [('title_markup', None, None, 'Episode'),]) - title: (optional) The title of the window + heading - instructions: (optional) A one-line text describing what the user should select / what the selection is for - stock_ok_button: (optional) Will replace the "OK" button with another GTK+ stock item to be used for the affirmative button of the dialog (e.g. can be Gtk.STOCK_DELETE when the episodes to be selected will be deleted after closing the dialog) - selection_buttons: (optional) A dictionary with labels as keys and callbacks as values; for each key a button will be generated, and when the button is clicked, the callback will be called for each episode and the return value of the callback (True or False) will be the new selected state of the episode - size_attribute: (optional) The name of an attribute of the supplied episode objects that can be used to calculate the size of an episode; set this to None if no total size calculation should be done (in cases where total size is useless) (default is 'file_size') - tooltip_attribute: (optional) The name of an attribute of the supplied episode objects that holds the text for the tooltips when hovering over an episode (default is 'description') """ COLUMN_INDEX = 0 COLUMN_TOOLTIP = 1 COLUMN_TOGGLE = 2 COLUMN_ADDITIONAL = 3 def new(self): self._config.connect_gtk_window(self.gPodderEpisodeSelector, 'episode_selector', True) if not hasattr(self, 'callback'): self.callback = None if not hasattr(self, 'remove_callback'): self.remove_callback = None if not hasattr(self, 'remove_action'): self.remove_action = _('Remove') if not hasattr(self, 'remove_finished'): self.remove_finished = None if not hasattr(self, 'episodes'): self.episodes = [] if not hasattr(self, 'size_attribute'): self.size_attribute = 'file_size' if not hasattr(self, 'tooltip_attribute'): self.tooltip_attribute = 'description' if not hasattr(self, 'selection_buttons'): self.selection_buttons = {} if not hasattr(self, 'selected_default'): self.selected_default = False if not hasattr(self, 'selected'): self.selected = [self.selected_default] * len(self.episodes) if len(self.selected) < len(self.episodes): self.selected += [self.selected_default] * (len(self.episodes) - len(self.selected)) if not hasattr(self, 'columns'): self.columns = (('title_markup', None, None, _('Episode')),) if hasattr(self, 'title'): self.gPodderEpisodeSelector.set_title(self.title) if hasattr(self, 'instructions'): self.labelInstructions.set_text(self.instructions) self.labelInstructions.show_all() if hasattr(self, 'stock_ok_button'): if self.stock_ok_button == 'gpodder-download': self.btnOK.set_image(Gtk.Image.new_from_icon_name('go-down', Gtk.IconSize.BUTTON)) self.btnOK.set_label(_('Download')) else: self.btnOK.set_label(self.stock_ok_button) self.btnOK.set_use_stock(True) # check/uncheck column toggle_cell = Gtk.CellRendererToggle() toggle_cell.connect('toggled', self.toggle_cell_handler) toggle_column = Gtk.TreeViewColumn('', toggle_cell, active=self.COLUMN_TOGGLE) toggle_column.set_clickable(True) self.treeviewEpisodes.append_column(toggle_column) next_column = self.COLUMN_ADDITIONAL for name, sort_name, sort_type, caption in self.columns: renderer = Gtk.CellRendererText() if next_column < self.COLUMN_ADDITIONAL + 1: renderer.set_property('ellipsize', Pango.EllipsizeMode.END) column = Gtk.TreeViewColumn(caption, renderer, markup=next_column) column.set_clickable(False) column.set_resizable(True) # Only set "expand" on the first column if next_column < self.COLUMN_ADDITIONAL + 1: column.set_expand(True) if sort_name is not None: column.set_sort_column_id(next_column + 1) else: column.set_sort_column_id(next_column) self.treeviewEpisodes.append_column(column) next_column += 1 if sort_name is not None: # add the sort column column = Gtk.TreeViewColumn() column.set_clickable(False) column.set_visible(False) self.treeviewEpisodes.append_column(column) next_column += 1 column_types = [int, str, bool] # add string column type plus sort column type if it exists for name, sort_name, sort_type, caption in self.columns: column_types.append(str) if sort_name is not None: column_types.append(sort_type) self.model = Gtk.ListStore(*column_types) tooltip = None for index, episode in enumerate(self.episodes): if self.tooltip_attribute is not None: try: tooltip = getattr(episode, self.tooltip_attribute) except: tooltip = None row = [index, tooltip, self.selected[index]] for name, sort_name, sort_type, caption in self.columns: if not hasattr(episode, name): row.append(None) else: row.append(getattr(episode, name)) if sort_name is not None: if not hasattr(episode, sort_name): row.append(None) else: row.append(getattr(episode, sort_name)) self.model.append(row) if self.remove_callback is not None: self.btnRemoveAction.show() self.btnRemoveAction.set_label(self.remove_action) # connect to tooltip signals if self.tooltip_attribute is not None: try: self.treeviewEpisodes.set_property('has-tooltip', True) self.treeviewEpisodes.connect('query-tooltip', self.treeview_episodes_query_tooltip) except: pass self.last_tooltip_episode = None self.episode_list_can_tooltip = True self.treeviewEpisodes.connect('button-press-event', self.treeview_episodes_button_pressed) self.treeviewEpisodes.connect('popup-menu', self.treeview_episodes_button_pressed) self.treeviewEpisodes.set_rules_hint(True) self.treeviewEpisodes.set_model(self.model) self.treeviewEpisodes.columns_autosize() # Focus the toggle column for Tab-focusing (bug 503) path, column = self.treeviewEpisodes.get_cursor() if path is not None: self.treeviewEpisodes.set_cursor(path, toggle_column) self.calculate_total_size() def treeview_episodes_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip): # With get_bin_window, we get the window that contains the rows without # the header. The Y coordinate of this window will be the height of the # treeview header. This is the amount we have to subtract from the # event's Y coordinate to get the coordinate to pass to get_path_at_pos (x_bin, y_bin) = treeview.get_bin_window().get_position() y -= x_bin y -= y_bin (path, column, rx, ry) = treeview.get_path_at_pos(x, y) or (None,) * 4 if not self.episode_list_can_tooltip or column != treeview.get_columns()[1]: self.last_tooltip_episode = None return False if path is not None: model = treeview.get_model() iter = model.get_iter(path) index = model.get_value(iter, self.COLUMN_INDEX) description = model.get_value(iter, self.COLUMN_TOOLTIP) if self.last_tooltip_episode is not None and self.last_tooltip_episode != index: self.last_tooltip_episode = None return False self.last_tooltip_episode = index description = util.remove_html_tags(description) # Bug 1825: make sure description is a unicode string, # so it may be cut correctly on UTF-8 char boundaries description = util.convert_bytes(description) if description is not None: if len(description) > 400: description = description[:398] + '[...]' tooltip.set_text(description) return True else: return False self.last_tooltip_episode = None return False def treeview_episodes_button_pressed(self, treeview, event=None): if event is None or event.triggers_context_menu(): menu = Gtk.Menu() if len(self.selection_buttons): for label in self.selection_buttons: item = Gtk.MenuItem(label) item.connect('activate', self.custom_selection_button_clicked, label) menu.append(item) menu.append(Gtk.SeparatorMenuItem()) item = Gtk.MenuItem(_('Select all')) item.connect('activate', self.on_btnCheckAll_clicked) menu.append(item) item = Gtk.MenuItem(_('Select none')) item.connect('activate', self.on_btnCheckNone_clicked) menu.append(item) menu.show_all() # Disable tooltips while we are showing the menu, so # the tooltip will not appear over the menu self.episode_list_can_tooltip = False menu.connect('deactivate', lambda menushell: self.episode_list_allow_tooltips()) if event is None: func = TreeViewHelper.make_popup_position_func(treeview) menu.popup(None, None, func, None, 3, Gtk.get_current_event_time()) else: menu.popup(None, None, None, None, event.button, event.time) return True def episode_list_allow_tooltips(self): self.episode_list_can_tooltip = True def calculate_total_size(self): if self.size_attribute is not None: (total_size, count) = (0, 0) for episode in self.get_selected_episodes(): try: total_size += int(getattr(episode, self.size_attribute)) count += 1 except: pass text = [] if count == 0: text.append(_('Nothing selected')) text.append(N_('%(count)d episode', '%(count)d episodes', count) % {'count': count}) if total_size > 0: text.append(_('size: %s') % util.format_filesize(total_size)) self.labelTotalSize.set_text(', '.join(text)) self.btnOK.set_sensitive(count > 0) self.btnRemoveAction.set_sensitive(count > 0) if count > 0: self.btnCancel.set_label(Gtk.STOCK_CANCEL) else: self.btnCancel.set_label(Gtk.STOCK_CLOSE) else: self.btnOK.set_sensitive(False) self.btnRemoveAction.set_sensitive(False) for index, row in enumerate(self.model): if self.model.get_value(row.iter, self.COLUMN_TOGGLE) is True: self.btnOK.set_sensitive(True) self.btnRemoveAction.set_sensitive(True) break self.labelTotalSize.set_text('') def toggle_cell_handler(self, cell, path): model = self.treeviewEpisodes.get_model() model[path][self.COLUMN_TOGGLE] = not model[path][self.COLUMN_TOGGLE] self.calculate_total_size() def custom_selection_button_clicked(self, button, label): callback = self.selection_buttons[label] for index, row in enumerate(self.model): new_value = callback(self.episodes[index]) self.model.set_value(row.iter, self.COLUMN_TOGGLE, new_value) self.calculate_total_size() def on_btnCheckAll_clicked(self, widget): for row in self.model: self.model.set_value(row.iter, self.COLUMN_TOGGLE, True) self.calculate_total_size() def on_btnCheckNone_clicked(self, widget): for row in self.model: self.model.set_value(row.iter, self.COLUMN_TOGGLE, False) self.calculate_total_size() def on_remove_action_activate(self, widget): episodes = self.get_selected_episodes(remove_episodes=True) urls = [] for episode in episodes: urls.append(episode.url) self.remove_callback(episode) if self.remove_finished is not None: self.remove_finished(urls) self.calculate_total_size() # Close the window when there are no episodes left model = self.treeviewEpisodes.get_model() if model.get_iter_first() is None: self.on_btnCancel_clicked(None) def on_row_activated(self, treeview, path, view_column): model = treeview.get_model() iter = model.get_iter(path) value = model.get_value(iter, self.COLUMN_TOGGLE) model.set_value(iter, self.COLUMN_TOGGLE, not value) self.calculate_total_size() def get_selected_episodes(self, remove_episodes=False): selected_episodes = [] for index, row in enumerate(self.model): if self.model.get_value(row.iter, self.COLUMN_TOGGLE) is True: selected_episodes.append(self.episodes[self.model.get_value( row.iter, self.COLUMN_INDEX)]) if remove_episodes: for episode in selected_episodes: index = self.episodes.index(episode) iter = self.model.get_iter_first() while iter is not None: if self.model.get_value(iter, self.COLUMN_INDEX) == index: self.model.remove(iter) break iter = self.model.iter_next(iter) return selected_episodes def on_btnOK_clicked(self, widget): self.gPodderEpisodeSelector.destroy() if self.callback is not None: self.callback(self.get_selected_episodes()) def on_btnCancel_clicked(self, widget): self.gPodderEpisodeSelector.destroy() if self.callback is not None: self.callback([])