gpodder/src/gpodder/gtkui/desktop/episodeselector.py

422 lines
18 KiB
Python

# -*- 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 <http://www.gnu.org/licenses/>.
#
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([])