422 lines
18 KiB
Python
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([])
|