class gPodderChannel(BuilderWidget):
    MAX_SIZE = 120

    def new(self):
        self.show_on_cover_load = True

        self.gPodderChannel.set_transient_for(self.parent_widget)
        self.title_label.set_text(self.channel.title)
        self.labelURL.set_text(self.channel.url)
        self.skip_feed_update_switch.set_active(self.channel.pause_subscription)
        self.enable_device_sync_switch.set_active(self.channel.sync_to_mp3_player)

        self.section_list = Gtk.ListStore(str)
        active_index = 0
        for index, section in enumerate(sorted(self.sections)):
            self.section_list.append([section])
            if section == self.channel.section:
                active_index = index
        self.combo_section.set_model(self.section_list)
        cell_renderer = Gtk.CellRendererText()
        self.combo_section.pack_start(cell_renderer, True)
        self.combo_section.add_attribute(cell_renderer, 'text', 0)
        self.combo_section.set_active(active_index)

        self.strategy_list = Gtk.ListStore(str, int)
        active_index = 0
        for index, (checked, strategy_id, strategy) in \
                enumerate(self.channel.get_download_strategies()):
            self.strategy_list.append([strategy, strategy_id])
            if checked:
                active_index = index
        self.combo_strategy.set_model(self.strategy_list)
        cell_renderer = Gtk.CellRendererText()
        self.combo_strategy.pack_start(cell_renderer, True)
        self.combo_strategy.add_attribute(cell_renderer, 'text', 0)
        self.combo_strategy.set_active(active_index)

        self.LabelDownloadTo.set_text(self.channel.save_dir)
        self.website_label.set_markup('<a href="{}">{}</a>'.format(
            self.channel.link, self.channel.link)
            if self.channel.link else '')
        self.website_label.connect('activate-link', lambda label, url: util.open_website(url))

        if self.channel.auth_username:
            self.FeedUsername.set_text(self.channel.auth_username)
        if self.channel.auth_password:
            self.FeedPassword.set_text(self.channel.auth_password)

        self.cover_downloader.register('cover-available', self.cover_download_finished)
        self.cover_downloader.request_cover(self.channel)

        if self.channel._update_error:
            err = '\n\n' + (_('ERROR: %s') % self.channel._update_error)
        else:
            err = ''
        self.channel_description.set_text(util.remove_html_tags(self.channel.description) + err)

        # Add Drag and Drop Support
        flags = Gtk.DestDefaults.ALL
        targets = [Gtk.TargetEntry.new('text/uri-list', 0, 2), Gtk.TargetEntry.new('text/plain', 0, 4)]
        actions = Gdk.DragAction.DEFAULT | Gdk.DragAction.COPY
        self.imgCover.drag_dest_set(flags, targets, actions)
        self.imgCover.connect('drag_data_received', self.drag_data_received)
        border = 6
        size = self.MAX_SIZE + border * 2
        self.imgCover.set_size_request(size, size)
        self.imgCoverEventBox.connect('button-press-event',
                self.on_cover_popup_menu)

        # Title save button state
        self.title_save_button_saves = True

        self._config.connect_gtk_window(self.gPodderChannel, 'channel_editor', True)

        gpodder.user_extensions.on_ui_object_available('channel-gtk', self)

        result = gpodder.user_extensions.on_channel_settings(self.channel)
        if result:
            for label, callback in result:
                sw = Gtk.ScrolledWindow()
                sw.add(callback(self.channel))
                sw.show_all()
                self.notebookChannelEditor.append_page(sw, Gtk.Label(label))

    def on_button_add_section_clicked(self, widget):
        text = self.show_text_edit_dialog(_('Add section'), _('New section:'),
            affirmative_text=_('_Add'))

        if text is not None:
            for index, (section,) in enumerate(self.section_list):
                if text == section:
                    self.combo_section.set_active(index)
                    return

            self.section_list.append([text])
            self.combo_section.set_active(len(self.section_list) - 1)

    def on_cover_popup_menu(self, widget, event):
        if not event.triggers_context_menu():
            return

        menu = Gtk.Menu()

        item = Gtk.MenuItem.new_with_mnemonic(_('_Open'))
        item.connect('activate', self.on_btnDownloadCover_clicked)
        menu.append(item)

        item = Gtk.MenuItem.new_with_mnemonic(_('_Refresh'))
        item.connect('activate', self.on_btnClearCover_clicked)
        menu.append(item)

        menu.attach_to_widget(widget)
        menu.show_all()
        menu.popup(None, None, None, None, event.button, event.time)

    def on_btnDownloadCover_clicked(self, widget):
        dlg = Gtk.FileChooserDialog(
            title=_('Select new podcast cover artwork'),
            parent=self.gPodderChannel,
            action=Gtk.FileChooserAction.OPEN)
        dlg.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL)
        dlg.add_button(_('_Open'), Gtk.ResponseType.OK)

        if dlg.run() == Gtk.ResponseType.OK:
            url = dlg.get_uri()
            self.clear_cover_cache(self.channel.url)
            self.cover_downloader.replace_cover(self.channel, custom_url=url)

        dlg.destroy()

    def on_btnClearCover_clicked(self, widget):
        self.clear_cover_cache(self.channel.url)
        self.cover_downloader.replace_cover(self.channel, custom_url=False)

    def cover_download_finished(self, channel, pixbuf):
        def set_cover(channel, pixbuf):
            if self.channel == channel:
                if pixbuf is not None:
                    self.imgCover.set_from_pixbuf(util.scale_pixbuf(pixbuf, self.MAX_SIZE))
                if self.show_on_cover_load:
                    self.main_window.show()
                    self.show_on_cover_load = False

        util.idle_add(set_cover, channel, pixbuf)

    def drag_data_received(self, widget, content, x, y, sel, ttype, time):
        files = sel.get_text().strip().split('\n')
        if len(files) != 1:
            self.show_message(
                _('You can only drop a single image or URL here.'),
                _('Drag and drop'))
            return

        file = files[0]

        if file.startswith('file://') or file.startswith('http://') or file.startswith('https://'):
            self.clear_cover_cache(self.channel.url)
            self.cover_downloader.replace_cover(self.channel, custom_url=file)
            return

        self.show_message(
            _('You can only drop local files and http:// URLs here.'),
            _('Drag and drop'))

    def on_gPodderChannel_destroy(self, widget, *args):
        self.cover_downloader.unregister('cover-available', self.cover_download_finished)

    # Title editing callbacks
    def on_title_edit_button_clicked(self, button):
        self.title_save_button_saves = True
        self.title_save_button.set_label(_("_Save"))
        self.title_stack.set_visible_child(self.title_edit_box)
        self.title_entry.set_text(self.title_label.get_text())
        self.title_entry.grab_focus()

    def on_title_entry_changed(self, entry):
        if len(entry.get_text()) > 0:
            self.title_save_button_saves = True
            self.title_save_button.set_label(_("_Save"))
        else:
            self.title_save_button_saves = False
            self.title_save_button.set_label(_("Cancel"))

    def on_title_entry_icon_press(self, entry, icon_pos, *args):
        self.title_entry.set_text("")

    def on_title_save_button_clicked(self, button):
        if self.title_save_button_saves:
            self.title_label.set_text(self.title_entry.get_text())
        self.title_stack.set_visible_child(self.title_box)

    def on_row_activated(self, listbox, row, *args):
        # Find the correct widget in the row to activate
        def _do(w, *args):
            if w.get_name().startswith("no_activation"):
                return
            elif isinstance(w, Gtk.Box):
                w.foreach(_do)
            elif isinstance(w, Gtk.ComboBox):
                w.popup()
            elif isinstance(w, Gtk.Entry):
                w.grab_focus()
            elif isinstance(w, Gtk.Switch):
                w.set_state(not w.get_state())
            elif isinstance(w, Gtk.Button):
                w.emit("clicked")
        row.foreach(_do)

    def on_btnCancel_clicked(self, widget, *args):
        self.main_window.destroy()

    def on_btnOK_clicked(self, widget, *args):
        self.channel.pause_subscription = self.skip_feed_update_switch.get_state()
        self.channel.sync_to_mp3_player = self.enable_device_sync_switch.get_state()
        self.channel.rename(self.title_label.get_text())
        self.channel.auth_username = self.FeedUsername.get_text().strip()
        self.channel.auth_password = self.FeedPassword.get_text()

        self.cover_downloader.unregister('cover-available', self.cover_download_finished)
        self.clear_cover_cache(self.channel.url)
        self.cover_downloader.request_cover(self.channel)

        new_section = self.section_list[self.combo_section.get_active()][0]
        if self.channel.section != new_section:
            self.channel.section = new_section
            section_changed = True
        else:
            section_changed = False

        new_strategy = self.strategy_list[self.combo_strategy.get_active()][1]
        self.channel.set_download_strategy(new_strategy)

        self.channel.save()

        self.main_window.destroy()

        self.update_podcast_list_model(selected=True,
                sections_changed=section_changed) 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
      - ok_button: (optional) Will replace the "OK" button label with this
                   string (e.g. can be '_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.gPodderEpisodeSelector.set_transient_for(self.parent_widget)
        if hasattr(self, 'title'):
            self.gPodderEpisodeSelector.set_title(self.title)

        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, 'instructions'):
            self.labelInstructions.set_text(self.instructions)
            self.labelInstructions.show_all()

        if hasattr(self, 'ok_button'):
            if self.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_image(None)
                self.btnOK.set_label(self.ok_button)

        # 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(_('_Cancel'))
            else:
                self.btnCancel.set_label(_('_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([]) class gPodderExportToLocalFolder(BuilderWidget):
    """ Export to Local Folder UI: file dialog + checkbox to save all to same folder """
    def new(self):
        self.gPodderExportToLocalFolder.set_transient_for(self.parent_widget)
        self.RES_CANCEL = -6
        self.RES_SAVE = -3
        self.gPodderExportToLocalFolder.add_buttons("_Cancel", self.RES_CANCEL,
                                                    "_Save", self.RES_SAVE)
        self._config.connect_gtk_window(self.gPodderExportToLocalFolder,
                                        'export_to_local_folder', True)

    def save_as(self, initial_directory, filename, remaining=0):
        """
        blocking method: prompt for save to local folder
        :param str initial_directory: folder to show to user or None
        :param str filename: default export filename
        :param int remaining: remaining episodes (to show/hide and customize checkbox label)
        :return (bool, str, str, bool): notCancelled, selected folder, selected path,
                                        save all remaining episodes with default params
        """
        if remaining:
            self.allsamefolder.set_label(
                N_('Export remaining %(count)d episode to this folder with its default name',
                   'Export remaining %(count)d episodes to this folder with their default name',
                   remaining) % {'count': remaining})
        else:
            self.allsamefolder.hide()
        if initial_directory is None:
            initial_directory = os.path.expanduser('~')
        self.gPodderExportToLocalFolder.set_current_folder(initial_directory)
        self.gPodderExportToLocalFolder.set_current_name(filename)
        res = self.gPodderExportToLocalFolder.run()
        self.gPodderExportToLocalFolder.hide()
        notCancelled = (res == self.RES_SAVE)
        allRemainingDefault = self.allsamefolder.get_active()
        if notCancelled:
            folder = self.gPodderExportToLocalFolder.get_current_folder()
            filename = self.gPodderExportToLocalFolder.get_filename()
        else:
            folder = initial_directory
            filename = None
        return (notCancelled, folder, filename, allRemainingDefault) class gPodderPodcastDirectory(BuilderWidget):
    def new(self):
        self.gPodderPodcastDirectory.set_transient_for(self.parent_widget)
        if hasattr(self, 'custom_title'):
            self.main_window.set_title(self.custom_title)

        if not hasattr(self, 'add_podcast_list'):
            self.add_podcast_list = None

        self.providers_model = DirectoryProvidersModel(directory.PROVIDERS)
        self.podcasts_model = DirectoryPodcastsModel(self.on_can_subscribe_changed)
        self.current_provider = None
        self.podcasts_progress_indicator = None

        self.setup_providers_treeview()
        self.setup_podcasts_treeview()
        self.setup_tag_cloud()

        selection = self.tv_providers.get_selection()
        selection.select_path((0,))
        self.on_tv_providers_row_activated(self.tv_providers, (0,), None)

        self.main_window.show()

    def download_opml_file(self, filename):
        self.providers_model.add_provider(directory.FixedOpmlFileProvider(filename))
        self.tv_providers.set_cursor(len(self.providers_model) - 1)

    def setup_podcasts_treeview(self):
        column = Gtk.TreeViewColumn('')
        cell = Gtk.CellRendererToggle()
        cell.set_fixed_size(48, -1)
        column.pack_start(cell, False)
        column.add_attribute(cell, 'active', DirectoryPodcastsModel.C_SELECTED)
        cell.connect('toggled', lambda cell, path: self.podcasts_model.toggle(path))
        self.tv_podcasts.append_column(column)

        column = Gtk.TreeViewColumn('')
        cell = Gtk.CellRendererText()
        cell.set_property('ellipsize', Pango.EllipsizeMode.END)
        column.pack_start(cell, True)
        column.add_attribute(cell, 'markup', DirectoryPodcastsModel.C_MARKUP)
        self.tv_podcasts.append_column(column)

        self.tv_podcasts.set_model(self.podcasts_model)
        self.podcasts_model.append((False, 'a', 'b', 'c'))

    def setup_providers_treeview(self):
        column = Gtk.TreeViewColumn('')
        cell = Gtk.CellRendererPixbuf()
        column.pack_start(cell, False)
        column.add_attribute(cell, 'pixbuf', DirectoryProvidersModel.C_ICON)
        cell = Gtk.CellRendererText()
        # cell.set_property('ellipsize', Pango.EllipsizeMode.END)
        column.pack_start(cell, True)
        column.add_attribute(cell, 'text', DirectoryProvidersModel.C_TEXT)
        column.add_attribute(cell, 'weight', DirectoryProvidersModel.C_WEIGHT)
        self.tv_providers.append_column(column)

        self.tv_providers.set_row_separator_func(self.providers_model.is_row_separator)

        self.tv_providers.set_model(self.providers_model)

    def setup_tag_cloud(self):
        self.tag_cloud = TagCloud()
        self.tag_cloud.set_size_request(-1, 130)
        self.tag_cloud.show_all()
        self.sw_tagcloud.add(self.tag_cloud)

        self.tag_cloud.connect('selected', self.on_tag_selected)

    def on_tag_selected(self, tag_cloud, tag):
        self.obtain_podcasts_with(lambda: self.current_provider.on_tag(tag))

    def on_tv_providers_row_activated(self, treeview, path, column):
        it = self.providers_model.get_iter(path)

        for row in self.providers_model:
            row[DirectoryProvidersModel.C_WEIGHT] = Pango.Weight.NORMAL

        if it:
            self.providers_model.set_value(it, DirectoryProvidersModel.C_WEIGHT, Pango.Weight.BOLD)
            provider = self.providers_model.get_value(it, DirectoryProvidersModel.C_PROVIDER)
            self.use_provider(provider)

    def use_provider(self, provider):
        self.podcasts_model.clear()
        self.current_provider = provider

        if provider.kind == directory.Provider.PROVIDER_SEARCH:
            self.lb_search.set_text(_('Search:'))
            self.bt_search.set_label(_('Search'))
        elif provider.kind == directory.Provider.PROVIDER_URL:
            self.lb_search.set_text(_('URL:'))
            self.bt_search.set_label(_('Download'))
        elif provider.kind == directory.Provider.PROVIDER_FILE:
            self.lb_search.set_text(_('Filename:'))
            self.bt_search.set_label(_('Open'))
        elif provider.kind == directory.Provider.PROVIDER_TAGCLOUD:
            self.tag_cloud.clear_tags()

            @util.run_in_background
            def load_tags():
                try:
                    tags = [(t.tag, t.weight) for t in provider.get_tags()]
                except Exception as e:
                    logger.warn('Got exception while loading tags: %s', e)
                    tags = []

                @util.idle_add
                def update_ui():
                    self.tag_cloud.set_tags(tags)
        elif provider.kind == directory.Provider.PROVIDER_STATIC:
            self.obtain_podcasts_with(provider.on_static)

        if provider.kind in (directory.Provider.PROVIDER_SEARCH,
                directory.Provider.PROVIDER_URL,
                directory.Provider.PROVIDER_FILE):
            self.en_query.set_text('')
            self.hb_text_entry.show()
            util.idle_add(self.en_query.grab_focus)
        else:
            self.hb_text_entry.hide()

        if provider.kind == directory.Provider.PROVIDER_TAGCLOUD:
            self.sw_tagcloud.show()
        else:
            self.sw_tagcloud.hide()

    def on_tv_providers_cursor_changed(self, treeview):
        path, column = treeview.get_cursor()
        if path is not None:
            self.on_tv_providers_row_activated(treeview, path, column)

    def obtain_podcasts_with(self, callback):
        if self.podcasts_progress_indicator is not None:
            self.podcasts_progress_indicator.on_finished()

        self.podcasts_progress_indicator = ProgressIndicator(_('Loading podcasts'),
                _('Please wait while the podcast list is downloaded'),
                parent=self.main_window)

        original_provider = self.current_provider

        self.en_query.set_sensitive(False)
        self.bt_search.set_sensitive(False)
        self.tag_cloud.set_sensitive(False)
        self.podcasts_model.clear()

        @util.run_in_background
        def download_data():
            try:
                podcasts = callback()
            except Exception as e:
                logger.warn('Got exception while loading podcasts: %s', e)
                podcasts = []

            @util.idle_add
            def update_ui():
                if self.podcasts_progress_indicator is not None:
                    self.podcasts_progress_indicator.on_finished()
                    self.podcasts_progress_indicator = None

                if original_provider == self.current_provider:
                    self.podcasts_model.load(podcasts or [])
                else:
                    logger.warn('Ignoring update from old thread')

                self.en_query.set_sensitive(True)
                self.bt_search.set_sensitive(True)
                self.tag_cloud.set_sensitive(True)
                if self.en_query.get_realized():
                    self.en_query.grab_focus()

    def on_bt_search_clicked(self, widget):
        if self.current_provider is None:
            return

        query = self.en_query.get_text()

        @self.obtain_podcasts_with
        def load_data():
            if self.current_provider.kind == directory.Provider.PROVIDER_SEARCH:
                return self.current_provider.on_search(query)
            elif self.current_provider.kind == directory.Provider.PROVIDER_URL:
                return self.current_provider.on_url(query)
            elif self.current_provider.kind == directory.Provider.PROVIDER_FILE:
                return self.current_provider.on_file(query)

    def on_can_subscribe_changed(self, can_subscribe):
        self.btnOK.set_sensitive(can_subscribe)

    def on_btnSelectAll_clicked(self, widget, *args):
        self.podcasts_model.set_selection_to(True)

    def on_btnSelectNone_clicked(self, widget, *args):
        self.podcasts_model.set_selection_to(False)

    def on_btnOK_clicked(self, widget, *args):
        urls = self.podcasts_model.get_selected_podcasts()

        self.main_window.destroy()

        # add channels that have been selected
        if self.add_podcast_list is not None:
            self.add_podcast_list(urls)

    def on_btnCancel_clicked(self, widget, *args):
        self.main_window.destroy() class gPodderWelcome(BuilderWidget):

    def new(self):
        self.gPodderWelcome.set_transient_for(self.parent_widget)

    def on_btnCancel_clicked(self, button):
        self.main_window.response(Gtk.ResponseType.CANCEL) class UserAppsReader(object):
    # XDG categories, plus some others found in random .desktop files
    # https://standards.freedesktop.org/menu-spec/latest/apa.html
    PLAYER_CATEGORIES = ('Audio', 'Video', 'AudioVideo', 'Player')

    def __init__(self, mimetypes):
        self.apps = []
        self.mimetypes = mimetypes
        self.__has_read = False
        self.__finished = threading.Event()
        self.__has_sep = False
        self.apps.append(UserApplication(
            _('Default application'), 'default',
            ';'.join((mime + '/*' for mime in self.mimetypes)),
            'document-open'))

    def add_separator(self):
        self.apps.append(UserApplication(
            '', '',
            ';'.join((mime + '/*' for mime in self.mimetypes)), ''))
        self.__has_sep = True

    def read(self):
        if self.__has_read:
            return

        self.__has_read = True
        if gpodder.ui.win32:
            import winreg
            for caption, types, hkey in WIN32_APP_REG_KEYS:
                try:
                    cmdline = win32_read_registry_key(hkey)
                    self.apps.append(UserApplication(
                        caption, cmdline,
                        ';'.join(typ + '/*' for typ in types), None))
                except Exception as e:
                    logger.warn('Parse HKEY error: %s (%s)', hkey, e)

        for dir in userappsdirs:
            if os.path.exists(dir):
                for file in glob.glob(os.path.join(dir, '*.desktop')):
                    self.parse_and_append(file)
        self.__finished.set()

    def parse_and_append(self, filename):
        try:
            parser = RawConfigParser()
            parser.read([filename])
            if not parser.has_section(sect):
                return

            app_categories = parser.get(sect, 'Categories')
            if not app_categories:
                return

            if not any(category in self.PLAYER_CATEGORIES
                       for category in app_categories.split(';')):
                return

            # Find out if we need it by comparing mime types
            app_mime = parser.get(sect, 'MimeType')
            for needed_type in self.mimetypes:
                if app_mime.find(needed_type + '/') != -1:
                    app_name = parser.get(sect, 'Name')
                    app_cmd = parser.get(sect, 'Exec')
                    app_icon = parser.get(sect, 'Icon')
                    if not self.__has_sep:
                        self.add_separator()
                    self.apps.append(UserApplication(app_name, app_cmd, app_mime, app_icon))
                    return
        except:
            return

    def get_model(self, mimetype):
        self.__finished.wait()

        model = PlayerListModel()
        for app in self.apps:
            if app.is_mime(mimetype):
                model.insert_app(app.get_icon(), app.name, app.cmd)
        return model class DownloadStatusModel(Gtk.ListStore):
    # Symbolic names for our columns, so we know what we're up to
    C_TASK, C_NAME, C_URL, C_PROGRESS, C_PROGRESS_TEXT, C_ICON_NAME = list(range(6))

    SEARCH_COLUMNS = (C_NAME, C_URL)

    def __init__(self):
        Gtk.ListStore.__init__(self, object, str, str, int, str, str)

        # Set up stock icon IDs for tasks
        self._status_ids = collections.defaultdict(lambda: None)
        self._status_ids[download.DownloadTask.DOWNLOADING] = 'go-down'
        self._status_ids[download.DownloadTask.DONE] = 'object-select-symbolic'
        self._status_ids[download.DownloadTask.FAILED] = 'dialog-error'
        self._status_ids[download.DownloadTask.CANCELLING] = 'media-playback-stop'
        self._status_ids[download.DownloadTask.CANCELLED] = 'media-playback-stop'
        self._status_ids[download.DownloadTask.PAUSING] = 'media-playback-pause'
        self._status_ids[download.DownloadTask.PAUSED] = 'media-playback-pause'

    def _format_message(self, episode, message, podcast):
        episode = html.escape(episode)
        podcast = html.escape(podcast)
        message = html.escape(message)
        return '%s\n<small>%s - %s</small>' % (episode, message, podcast)

    def request_update(self, iter, task=None):
        if task is None:
            # Ongoing update request from UI - get task from model
            task = self.get_value(iter, self.C_TASK)
        else:
            # Initial update request - update non-changing fields
            self.set(iter,
                    self.C_TASK, task,
                    self.C_URL, task.url)

        if task.status == task.FAILED:
            status_message = '%s: %s' % (
                    task.STATUS_MESSAGE[task.status],
                    task.error_message)
        elif task.status == task.DOWNLOADING:
            status_message = '%s (%.0f%%, %s/s)' % (
                    task.STATUS_MESSAGE[task.status],
                    task.progress * 100,
                    util.format_filesize(task.speed))
        else:
            status_message = task.STATUS_MESSAGE[task.status]

        if task.progress > 0 and task.progress < 1:
            current = util.format_filesize(task.progress * task.total_size, digits=1)
            total = util.format_filesize(task.total_size, digits=1)

            # Remove unit from current if same as in total
            # (does: "12 MiB / 24 MiB" => "12 / 24 MiB")
            current = current.split()
            if current[-1] == total.split()[-1]:
                current.pop()
            current = ' '.join(current)

            progress_message = ' / '.join((current, total))
        elif task.total_size > 0:
            progress_message = util.format_filesize(task.total_size,
                    digits=1)
        else:
            progress_message = ('unknown size')

        self.set(iter,
                self.C_NAME, self._format_message(task.episode.title,
                    status_message, task.episode.channel.title),
                self.C_PROGRESS, 100. * task.progress,
                self.C_PROGRESS_TEXT, progress_message,
                self.C_ICON_NAME, self._status_ids[task.status])

    def __add_new_task(self, task):
        iter = self.append()
        self.request_update(iter, task)

    def register_task(self, task, background=True):
        if background:
            util.idle_add(self.__add_new_task, task)
        else:
            self.__add_new_task(task)

    def queue_task(self, task):
        with task:
            if task.status in (task.NEW, task.FAILED, task.CANCELLED, task.PAUSED):
                task.status = task.QUEUED
                task.set_episode_download_task()

    def tell_all_tasks_to_quit(self):
        for row in self:
            task = row[DownloadStatusModel.C_TASK]
            if task is not None:
                with task:
                    # Pause currently queued downloads
                    if task.status == task.QUEUED:
                        task.status = task.PAUSED

                    # Request pause of currently running downloads
                    elif task.status == task.DOWNLOADING:
                        task.status = task.PAUSING

                    # Delete cancelled and failed downloads
                    elif task.status in (task.CANCELLED, task.FAILED):
                        task.removed_from_list()

    def are_downloads_in_progress(self):
        """
        Returns True if there are any downloads in the
        QUEUED or DOWNLOADING status, False otherwise.
        """
        for row in self:
            task = row[DownloadStatusModel.C_TASK]
            if task is not None and \
                    task.status in (task.DOWNLOADING,
                                    task.QUEUED):
                return True

        return False

    def has_work(self):
        return any(self._work_gen())

    def available_work_count(self):
        return len(list(self._work_gen()))

    def __get_next(self, dqr):
        try:
            task = next(self._work_gen())
            # this is the only thread accessing the list store, so it's safe
            # to assume a) the task is still queued and b) we can transition to downloading
            task.status = task.DOWNLOADING
        except StopIteration as e:
            task = None
        # hand the task off to the worker thread
        dqr.resolve(task)

    # get the next task to download. this proxies the request to the main thread,
    # as only the main thread is allowed to manipulate the list store.
    def get_next(self):
        dqr = DequeueRequest()
        util.idle_add(self.__get_next, dqr)
        return dqr.dequeue()

    def _work_gen(self):
        return (task for task in
                (row[DownloadStatusModel.C_TASK] for row in self)
                if task.status == task.QUEUED) def cake_size_from_widget(widget=None):
    if widget is None:
        # Use GTK+ style of a normal Button
        widget = Gtk.Label()
    style_context = widget.get_style_context()
    font_desc = style_context.get_font(Gtk.StateFlags.NORMAL)
    pango_context = widget.create_pango_context()
    layout = Pango.Layout(pango_context)
    layout.set_font_description(font_desc)
    layout.set_text("1", -1)
    # use text height as size
    return layout.get_pixel_size()[1] def draw_cake_pixbuf(percentage, text=None, emblem=None, size=None):
    return cairo_surface_to_pixbuf(draw_cake(percentage, text, emblem, size=size)) def draw_iconcell_scale(column, cell, model, iter, scale):
    """
    Draw cell's pixbuf to a surface with proper scaling for high resolution
    displays. To be used as gtk.TreeViewColumn.set_cell_data_func.

    :param column: gtk.TreeViewColumn (ignored)
    :param cell: gtk.CellRenderer
    :param model: gtk.TreeModel (ignored)
    :param iter: gtk.TreeIter (ignored)
    :param scale: factor of the target display (e.g. 1 or 2)
    """
    pixbuf = cell.props.pixbuf
    if not pixbuf:
        return

    width = pixbuf.get_width()
    height = pixbuf.get_height()
    scale_inv = 1 / scale

    surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
    surface.set_device_scale(scale, scale)

    cr = cairo.Context(surface)
    cr.scale(scale_inv, scale_inv)
    Gdk.cairo_set_source_pixbuf(cr, cell.props.pixbuf, 0, 0)
    cr.paint()

    cell.props.surface = surface def draw_text_box_centered(ctx, widget, w_width, w_height, text, font_desc=None, add_progress=None):
    style_context = widget.get_style_context()
    text_color = style_context.get_color(Gtk.StateFlags.PRELIGHT)

    if font_desc is None:
        font_desc = style_context.get_font(Gtk.StateFlags.NORMAL)
        font_desc.set_size(14 * Pango.SCALE)

    pango_context = widget.create_pango_context()
    layout = Pango.Layout(pango_context)
    layout.set_font_description(font_desc)
    layout.set_text(text, -1)
    width, height = layout.get_pixel_size()

    ctx.move_to(w_width / 2 - width / 2, w_height / 2 - height / 2)
    ctx.set_source_rgba(text_color.red, text_color.green, text_color.blue, 0.5)
    PangoCairo.show_layout(ctx, layout)

    # Draw an optional progress bar below the text (same width)
    if add_progress is not None:
        bar_height = 10
        ctx.set_source_rgba(*text_color)
        ctx.set_line_width(1.)
        rounded_rectangle(ctx,
                          w_width / 2 - width / 2 - .5,
                          w_height / 2 + height - .5, width + 1, bar_height + 1)
        ctx.stroke()
        rounded_rectangle(ctx,
                          w_width / 2 - width / 2,
                          w_height / 2 + height, int(width * add_progress) + .5, bar_height)
        ctx.fill() class gPodderAddPodcast(BuilderWidget):
    def new(self):
        self.gPodderAddPodcast.set_transient_for(self.parent_widget)
        if not hasattr(self, 'add_podcast_list'):
            self.add_podcast_list = None
        if hasattr(self, 'custom_label'):
            self.label_add.set_text(self.custom_label)
        if hasattr(self, 'custom_title'):
            self.gPodderAddPodcast.set_title(self.custom_title)
        if hasattr(self, 'preset_url'):
            self.entry_url.set_text(self.preset_url)
        self.entry_url.connect('activate', self.on_entry_url_activate)
        self.gPodderAddPodcast.show()

        if not hasattr(self, 'preset_url'):
            # Fill the entry if a valid URL is in the clipboard, but
            # only if there's no preset_url available (see bug 1132).
            # First try from CLIPBOARD (everyday copy-paste),
            # then from SELECTION (text selected and pasted via
            # middle mouse button).
            clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)

            def receive_clipboard_text(clipboard, text, second_try):
                # Heuristic: If there is a space in the clipboard
                # text, assume it's some arbitrary text, and no URL
                if text is not None and ' ' not in text:
                    url = util.normalize_feed_url(text)
                    if url is not None:
                        self.entry_url.set_text(url)
                        self.entry_url.set_position(-1)
                        return

                if not second_try:
                    clipboard = Gtk.Clipboard.get(Gdk.SELECTION_PRIMARY)
                    clipboard.request_text(receive_clipboard_text, True)
            clipboard.request_text(receive_clipboard_text, False)

    def on_btn_close_clicked(self, widget):
        self.gPodderAddPodcast.destroy()

    def on_btn_paste_clicked(self, widget):
        clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
        clipboard.request_text(self.receive_clipboard_text)

    def receive_clipboard_text(self, clipboard, text, data=None):
        if text is not None:
            self.entry_url.set_text(text)
        else:
            self.show_message(_('Nothing to paste.'), _('Clipboard is empty'))

    def on_entry_url_changed(self, widget):
        self.btn_add.set_sensitive(self.entry_url.get_text().strip() != '')

    def on_entry_url_activate(self, widget):
        self.on_btn_add_clicked(widget)

    def on_btn_add_clicked(self, widget):
        url = self.entry_url.get_text()
        self.on_btn_close_clicked(widget)
        if self.add_podcast_list is not None:
            title = None  # FIXME: Add title GUI element
            self.add_podcast_list([(title, url)]) class BuilderWidget(GtkBuilderWidget):
    def __init__(self, parent, **kwargs):
        self._window_iconified = False

        GtkBuilderWidget.__init__(self, gpodder.ui_folders, gpodder.textdomain, parent, **kwargs)

        # Enable support for tracking iconified state
        if hasattr(self, 'on_iconify') and hasattr(self, 'on_uniconify'):
            self.main_window.connect('window-state-event',
                    self._on_window_state_event_iconified)

    def _on_window_state_event_iconified(self, widget, event):
        if event.new_window_state & Gdk.WindowState.ICONIFIED:
            if not self._window_iconified:
                self._window_iconified = True
                self.on_iconify()
        else:
            if self._window_iconified:
                self._window_iconified = False
                self.on_uniconify()

        return False

    def is_iconified(self):
        return self._window_iconified

    def notification(self, message, title=None, important=False, widget=None):
        util.idle_add(self.show_message, message, title, important, widget)

    def get_dialog_parent(self):
        """Return a Gtk.Window that should be the parent of dialogs"""
        return self.main_window

    def show_message_details(self, title, message, details):
        dlg = Gtk.MessageDialog(self.main_window, Gtk.DialogFlags.MODAL, Gtk.MessageType.INFO, Gtk.ButtonsType.OK)
        dlg.set_title(title)
        dlg.set_property('text', title)
        dlg.format_secondary_text(message)

        # make message copy/pastable
        for lbl in dlg.get_message_area():
            if isinstance(lbl, Gtk.Label):
                lbl.set_halign(Gtk.Align.START)
                lbl.set_selectable(True)

        tv = Gtk.TextView()
        tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
        tv.set_border_width(10)
        tv.set_editable(False)
        tb = Gtk.TextBuffer()
        tb.insert_markup(tb.get_start_iter(), details, -1)
        tv.set_buffer(tb)
        tv.set_property('expand', True)
        sw = Gtk.ScrolledWindow()
        sw.set_size_request(400, 200)
        sw.set_property('shadow-type', Gtk.ShadowType.IN)
        sw.add(tv)
        sw.show_all()

        dlg.get_message_area().add(sw)
        dlg.get_widget_for_response(Gtk.ResponseType.OK).grab_focus()
        dlg.run()
        dlg.destroy()

    def show_message(self, message, title=None, important=False, widget=None):
        if important:
            show_message_dialog(self.main_window, message, title)
        else:
            gpodder.user_extensions.on_notification_show(title, message)

    def show_confirmation(self, message, title=None):
        dlg = Gtk.MessageDialog(self.main_window, Gtk.DialogFlags.MODAL, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO)
        if title:
            dlg.set_title(str(title))
            dlg.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s' % (title, message))
        else:
            dlg.set_markup('<span weight="bold" size="larger">%s</span>' % (message))
        response = dlg.run()
        dlg.destroy()
        return response == Gtk.ResponseType.YES

    def show_text_edit_dialog(self, title, prompt, text=None, empty=False,
            is_url=False, affirmative_text=_('_OK')):
        dialog = Gtk.Dialog(title, self.get_dialog_parent(),
            Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT)

        dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL)
        dialog.add_button(affirmative_text, Gtk.ResponseType.OK)

        dialog.set_default_size(300, -1)
        dialog.set_default_response(Gtk.ResponseType.OK)

        text_entry = Gtk.Entry()
        text_entry.set_activates_default(True)
        if text is not None:
            text_entry.set_text(text)
            text_entry.select_region(0, -1)

        if not empty:
            def on_text_changed(editable):
                can_confirm = (editable.get_text() != '')
                dialog.set_response_sensitive(Gtk.ResponseType.OK, can_confirm)
            text_entry.connect('changed', on_text_changed)
            if text is None:
                dialog.set_response_sensitive(Gtk.ResponseType.OK, False)

        hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        hbox.set_border_width(10)
        hbox.set_spacing(10)
        hbox.pack_start(Gtk.Label(prompt, True, True, 0), False, False, 0)
        hbox.pack_start(text_entry, True, True, 0)
        dialog.vbox.pack_start(hbox, True, True, 0)

        dialog.show_all()
        response = dialog.run()
        result = text_entry.get_text()
        dialog.destroy()

        if response == Gtk.ResponseType.OK:
            return result
        else:
            return None

    def show_login_dialog(self, title, message, root_url=None, username=None, password=None,
            username_prompt=None, register_callback=None, register_text=None, ask_server=False):
        def toggle_password_visibility(_, entry):
            entry.set_visibility(not entry.get_visibility())

        if username_prompt is None:
            username_prompt = _('Username')

        if register_text is None:
            register_text = _('New user')

        dialog = Gtk.MessageDialog(
            self.main_window,
            Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT,
            Gtk.MessageType.QUESTION,
            Gtk.ButtonsType.CANCEL)
        dialog.add_button(_('Login'), Gtk.ResponseType.OK)
        dialog.set_image(Gtk.Image.new_from_icon_name('dialog-password', Gtk.IconSize.DIALOG))
        dialog.set_title(_('Authentication required'))
        dialog.set_markup('<span weight="bold" size="larger">' + title + '</span>')
        dialog.format_secondary_markup(message)
        dialog.set_default_response(Gtk.ResponseType.OK)

        if register_callback is not None:
            dialog.add_button(register_text, Gtk.ResponseType.HELP)

        server_entry = Gtk.Entry()
        server_entry.set_tooltip_text(_('hostname or root URL (e.g. https://gpodder.net)'))
        username_entry = Gtk.Entry()
        password_entry = Gtk.Entry()

        server_entry.connect('activate', lambda w: username_entry.grab_focus())
        username_entry.connect('activate', lambda w: password_entry.grab_focus())
        password_entry.set_visibility(False)
        password_entry.set_activates_default(True)

        if root_url is not None:
            server_entry.set_text(root_url)
        if username is not None:
            username_entry.set_text(username)
        if password is not None:
            password_entry.set_text(password)

        table = Gtk.Table(3, 2)
        table.set_row_spacings(6)
        table.set_col_spacings(6)

        server_label = Gtk.Label()
        server_label.set_markup('<b>' + _('Server') + ':</b>')

        username_label = Gtk.Label()
        username_label.set_markup('<b>' + username_prompt + ':</b>')

        password_label = Gtk.Label()
        password_label.set_markup('<b>' + _('Password') + ':</b>')

        show_password_label = Gtk.Label()
        show_password = Gtk.CheckButton.new_with_label(_('Show Password'))
        show_password.connect('toggled', toggle_password_visibility, password_entry)

        label_entries = [(username_label, username_entry),
                         (password_label, password_entry),
                         (show_password_label, show_password)]

        if ask_server:
            label_entries.insert(0, (server_label, server_entry))

        for i, (label, entry) in enumerate(label_entries):
            label.set_alignment(0.0, 0.5)
            table.attach(label, 0, 1, i, i + 1, Gtk.AttachOptions.FILL, 0)
            table.attach(entry, 1, 2, i, i + 1)

        dialog.vbox.pack_end(table, True, True, 0)
        dialog.show_all()
        username_entry.grab_focus()
        response = dialog.run()

        while response == Gtk.ResponseType.HELP:
            register_callback()
            response = dialog.run()

        password_entry.set_visibility(True)
        root_url = server_entry.get_text()
        username = username_entry.get_text()
        password = password_entry.get_text()
        success = (response == Gtk.ResponseType.OK)

        dialog.destroy()

        if ask_server:
            return (success, (root_url, username, password))
        else:
            return (success, (username, password))

    def show_folder_select_dialog(self, initial_directory=None, title=_('Select destination')):
        if initial_directory is None:
            initial_directory = os.path.expanduser('~')

        dlg = Gtk.FileChooserDialog(title=title, parent=self.main_window, action=Gtk.FileChooserAction.SELECT_FOLDER)
        dlg.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL)
        dlg.add_button(_('_Save'), Gtk.ResponseType.OK)

        dlg.set_do_overwrite_confirmation(True)
        dlg.set_current_folder(initial_directory)

        result = False
        folder = initial_directory
        if dlg.run() == Gtk.ResponseType.OK:
            result = True
            folder = dlg.get_current_folder()

        dlg.destroy()
        return (result, folder) class TreeViewHelper(object):
    """Container for gPodder-specific TreeView attributes."""
    LAST_TOOLTIP = '_gpodder_last_tooltip'
    CAN_TOOLTIP = '_gpodder_can_tooltip'
    ROLE = '_gpodder_role'
    COLUMNS = '_gpodder_columns'

    # Enum for the role attribute
    ROLE_PODCASTS, ROLE_EPISODES, ROLE_DOWNLOADS = list(range(3))

    @classmethod
    def set(cls, treeview, role):
        setattr(treeview, cls.LAST_TOOLTIP, None)
        setattr(treeview, cls.CAN_TOOLTIP, True)
        setattr(treeview, cls.ROLE, role)

    @staticmethod
    def make_search_equal_func(gpodder_model):
        def func(model, column, key, iter):
            if model is None:
                return True
            key = key.lower()
            for column in gpodder_model.SEARCH_COLUMNS:
                if key in model.get_value(iter, column).lower():
                    return False
            return True
        return func

    @classmethod
    def register_column(cls, treeview, column):
        if not hasattr(treeview, cls.COLUMNS):
            setattr(treeview, cls.COLUMNS, [])

        columns = getattr(treeview, cls.COLUMNS)
        columns.append(column)

    @classmethod
    def get_columns(cls, treeview):
        return getattr(treeview, cls.COLUMNS, [])

    @staticmethod
    def make_popup_position_func(widget):
        """
        :return: suitable function to pass to Gtk.Menu.popup()
        It's used for instance when the popup trigger is the Menu key:
        it will position the menu on top of the selected row even if the mouse is elsewhere
        see http://lazka.github.io/pgi-docs/#Gtk-3.0/classes/Menu.html#Gtk.Menu.popup
        """
        def position_func(menu, *unused_args):
            _, x, y = widget.get_bin_window().get_origin()

            # If there's a selection, place the popup menu on top of
            # the first-selected row (otherwise in the top left corner)
            selection = widget.get_selection()
            model, paths = selection.get_selected_rows()
            if paths:
                path = paths[0]
                area = widget.get_cell_area(path, widget.get_column(0))
                x += area.x
                y += area.y

            return (x, y, True)
        return position_func class ProgressIndicator(object):
    # Delayed time until window is shown (for short operations)
    DELAY = 500

    # Time between GUI updates after window creation
    INTERVAL = 100

    def __init__(self, title, subtitle=None, cancellable=False, parent=None):
        self.title = title
        self.subtitle = subtitle
        self.cancellable = cancellable
        self.parent = parent
        self.dialog = None
        self.progressbar = None
        self.indicator = None
        self._initial_message = None
        self._initial_progress = None
        self._progress_set = False
        self.source_id = GObject.timeout_add(self.DELAY, self._create_progress)

    def _on_delete_event(self, window, event):
        if self.cancellable:
            self.dialog.response(Gtk.ResponseType.CANCEL)
        return True

    def _create_progress(self):
        self.dialog = Gtk.MessageDialog(self.parent,
                0, 0, Gtk.ButtonsType.CANCEL, self.subtitle or self.title)
        self.dialog.set_modal(True)
        self.dialog.connect('delete-event', self._on_delete_event)
        self.dialog.set_title(self.title)
        self.dialog.set_deletable(self.cancellable)

        # Avoid selectable text (requires PyGTK >= 2.22)
        if hasattr(self.dialog, 'get_message_area'):
            for label in self.dialog.get_message_area():
                if isinstance(label, Gtk.Label):
                    label.set_selectable(False)

        self.dialog.set_response_sensitive(Gtk.ResponseType.CANCEL,
                self.cancellable)

        self.progressbar = Gtk.ProgressBar()
        self.progressbar.set_show_text(True)
        self.progressbar.set_ellipsize(Pango.EllipsizeMode.END)

        # If the window is shown after the first update, set the progress
        # info so that when the window appears, data is there already
        if self._initial_progress is not None:
            self.progressbar.set_fraction(self._initial_progress)
        if self._initial_message is not None:
            self.progressbar.set_text(self._initial_message)

        self.dialog.vbox.add(self.progressbar)
        self.indicator = SpinningProgressIndicator()
        self.dialog.set_image(self.indicator)
        self.dialog.show_all()

        GObject.source_remove(self.source_id)
        self.source_id = GObject.timeout_add(self.INTERVAL, self._update_gui)
        return False

    def _update_gui(self):
        if self.indicator:
            self.indicator.step_animation()
        if not self._progress_set and self.progressbar:
            self.progressbar.pulse()
        return True

    def on_message(self, message):
        if self.progressbar:
            self.progressbar.set_text(message)
        else:
            self._initial_message = message

    def on_progress(self, progress):
        self._progress_set = True
        if self.progressbar:
            self.progressbar.set_fraction(progress)
        else:
            self._initial_progress = progress

    def on_finished(self):
        if self.dialog is not None:
            self.dialog.destroy()
        GObject.source_remove(self.source_id) class SearchTree:
    """
        handle showing/hiding the search box for podcast or episode treeviews,
        as well as searching for text entered in the search entry.
        Automatically attaches to entry signals on creation.
    """
    def __init__(self, search_box, search_entry, tree, model, config):
        self.search_box = search_box
        self.search_entry = search_entry
        self.tree = tree
        self.model = model
        self.config = config
        self._search_timeout = None
        self.search_entry.connect('icon-press', self.hide_search)
        self.search_entry.connect('changed', self.on_entry_changed)
        self.search_entry.connect('key-press-event', self.on_entry_key_press)

    def set_search_term(self, text):
        self.model.set_search_term(text)
        self._search_timeout = None
        return False

    def on_entry_changed(self, editable):
        if self.search_box.get_property('visible'):
            if self._search_timeout is not None:
                GLib.source_remove(self._search_timeout)
            self._search_timeout = GLib.timeout_add(
                    self.config.ui.gtk.live_search_delay,
                    self.set_search_term, editable.get_chars(0, -1))

    def on_entry_key_press(self, editable, event):
        if event.keyval == Gdk.KEY_Escape:
            self.hide_search()
            return True

    def hide_search(self, *args):
        if self._search_timeout is not None:
            GLib.source_remove(self._search_timeout)
            self._search_timeout = None
        if not self.config.ui.gtk.search_always_visible:
            self.search_box.hide()
        self.search_entry.set_text('')
        self.model.set_search_term(None)
        self.tree.grab_focus()

    def show_search(self, input_char=None, grab_focus=True):
        self.search_box.show()
        if input_char:
            self.search_entry.insert_text(input_char, -1)
        if grab_focus:
            self.search_entry.grab_focus()
        self.search_entry.set_position(-1) class EpisodeListModel(Gtk.ListStore):
    C_URL, C_TITLE, C_FILESIZE_TEXT, C_EPISODE, C_STATUS_ICON, \
        C_PUBLISHED_TEXT, C_DESCRIPTION, C_TOOLTIP, \
        C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED, \
        C_VIEW_SHOW_UNPLAYED, C_FILESIZE, C_PUBLISHED, \
        C_TIME, C_TIME_VISIBLE, C_TOTAL_TIME, \
        C_LOCKED, \
        C_TIME_AND_SIZE, C_TOTAL_TIME_AND_SIZE, C_FILESIZE_AND_TIME_TEXT, C_FILESIZE_AND_TIME = list(range(21))

    VIEW_ALL, VIEW_UNDELETED, VIEW_DOWNLOADED, VIEW_UNPLAYED = list(range(4))

    VIEWS = ['VIEW_ALL', 'VIEW_UNDELETED', 'VIEW_DOWNLOADED', 'VIEW_UNPLAYED']

    # In which steps the UI is updated for "loading" animations
    _UI_UPDATE_STEP = .03

    # Steps for the "downloading" icon progress
    PROGRESS_STEPS = 20

    def __init__(self, config, on_filter_changed=lambda has_episodes: None):
        Gtk.ListStore.__init__(self, str, str, str, object, str, str, str,
                               str, bool, bool, bool, GObject.TYPE_INT64,
                               GObject.TYPE_INT64, str, bool,
                               GObject.TYPE_INT64, bool, str, GObject.TYPE_INT64, str, GObject.TYPE_INT64)

        self._config = config

        # Callback for when the filter / list changes, gets one parameter
        # (has_episodes) that is True if the list has any episodes
        self._on_filter_changed = on_filter_changed

        # Filter to allow hiding some episodes
        self._filter = self.filter_new()
        self._sorter = Gtk.TreeModelSort(self._filter)
        self._view_mode = self.VIEW_ALL
        self._search_term = None
        self._search_term_eql = None
        self._filter.set_visible_func(self._filter_visible_func)

        # Are we currently showing "all episodes"/section or a single channel?
        self._section_view = False

        self.ICON_AUDIO_FILE = 'audio-x-generic'
        self.ICON_VIDEO_FILE = 'video-x-generic'
        self.ICON_IMAGE_FILE = 'image-x-generic'
        self.ICON_GENERIC_FILE = 'text-x-generic'
        self.ICON_DOWNLOADING = 'go-down'
        self.ICON_DELETED = 'edit-delete'
        self.ICON_ERROR = 'dialog-error'

        self.background_update = None
        self.background_update_tag = None

        if 'KDE_FULL_SESSION' in os.environ:
            # Workaround until KDE adds all the freedesktop icons
            # See https://bugs.kde.org/show_bug.cgi?id=233505 and
            #     http://gpodder.org/bug/553
            self.ICON_DELETED = 'archive-remove'

    def _format_filesize(self, episode):
        if episode.file_size > 0:
            return util.format_filesize(episode.file_size, digits=1)
        else:
            return None

    def _filter_visible_func(self, model, iter, misc):
        # If searching is active, set visibility based on search text
        if self._search_term is not None and self._search_term != '':
            episode = model.get_value(iter, self.C_EPISODE)
            if episode is None:
                return False

            try:
                return self._search_term_eql.match(episode)
            except Exception as e:
                return True

        if self._view_mode == self.VIEW_ALL:
            return True
        elif self._view_mode == self.VIEW_UNDELETED:
            return model.get_value(iter, self.C_VIEW_SHOW_UNDELETED)
        elif self._view_mode == self.VIEW_DOWNLOADED:
            return model.get_value(iter, self.C_VIEW_SHOW_DOWNLOADED)
        elif self._view_mode == self.VIEW_UNPLAYED:
            return model.get_value(iter, self.C_VIEW_SHOW_UNPLAYED)

        return True

    def get_filtered_model(self):
        """Returns a filtered version of this episode model

        The filtered version should be displayed in the UI,
        as this model can have some filters set that should
        be reflected in the UI.
        """
        return self._sorter

    def has_episodes(self):
        """Returns True if episodes are visible (filtered)

        If episodes are visible with the current filter
        applied, return True (otherwise return False).
        """
        return bool(len(self._filter))

    def set_view_mode(self, new_mode):
        """Sets a new view mode for this model

        After setting the view mode, the filtered model
        might be updated to reflect the new mode."""
        if self._view_mode != new_mode:
            self._view_mode = new_mode
            self._filter.refilter()
            self._on_filter_changed(self.has_episodes())

    def get_view_mode(self):
        """Returns the currently-set view mode"""
        return self._view_mode

    def set_search_term(self, new_term):
        if self._search_term != new_term:
            self._search_term = new_term
            self._search_term_eql = query.UserEQL(new_term)
            self._filter.refilter()
            self._on_filter_changed(self.has_episodes())

    def get_search_term(self):
        return self._search_term

    def _format_description(self, episode, include_description=False):
        title = episode.trimmed_title

        if episode.state != gpodder.STATE_DELETED and episode.is_new:
            yield '<b>'
            yield html.escape(title)
            yield '</b>'
        else:
            yield html.escape(title)

        if include_description:
            yield '\n'
            if self._section_view:
                yield _('from %s') % html.escape(episode.channel.title)
            else:
                description = episode.one_line_description()
                if description.startswith(title):
                    description = description[len(title):].strip()
                yield html.escape(description)

    def replace_from_channel(self, channel, include_description=False):
        """
        Add episode from the given channel to this model.
        Downloading should be a callback.
        include_description should be a boolean value (True if description
        is to be added to the episode row, or False if not)
        """

        # Remove old episodes in the list store
        self.clear()

        self._section_view = isinstance(channel, PodcastChannelProxy)

        # Avoid gPodder bug 1291
        if channel is None:
            episodes = []
        else:
            episodes = channel.get_all_episodes()

        # Always make a copy, so we can pass the episode list to BackgroundUpdate
        episodes = list(episodes)

        for _ in range(len(episodes)):
            self.append()

        self._update_from_episodes(episodes, include_description)

    def _update_from_episodes(self, episodes, include_description):
        if self.background_update_tag is not None:
            GObject.source_remove(self.background_update_tag)

        self.background_update = BackgroundUpdate(self, episodes, include_description)
        self.background_update_tag = GObject.idle_add(self._update_background)

    def _update_background(self):
        if self.background_update is not None:
            if self.background_update.update():
                return True

            self.background_update = None
            self.background_update_tag = None
            self._on_filter_changed(self.has_episodes())

        return False

    def update_all(self, include_description=False):
        if self.background_update is None:
            episodes = [row[self.C_EPISODE] for row in self]
        else:
            # Update all episodes that have already been initialized...
            episodes = [row[self.C_EPISODE] for index, row in enumerate(self) if index < self.background_update.index]
            # ...and also include episodes that still need to be initialized
            episodes.extend(self.background_update.episodes)

        self._update_from_episodes(episodes, include_description)

    def update_by_urls(self, urls, include_description=False):
        for row in self:
            if row[self.C_URL] in urls:
                self.update_by_iter(row.iter, include_description)

    def update_by_filter_iter(self, iter, include_description=False):
        # Convenience function for use by "outside" methods that use iters
        # from the filtered episode list model (i.e. all UI things normally)
        iter = self._sorter.convert_iter_to_child_iter(iter)
        self.update_by_iter(self._filter.convert_iter_to_child_iter(iter),
                include_description)

    def get_update_fields(self, episode, include_description):
        show_bullet = False
        show_padlock = False
        show_missing = False
        status_icon = None
        tooltip = []
        view_show_undeleted = True
        view_show_downloaded = False
        view_show_unplayed = False
        icon_theme = Gtk.IconTheme.get_default()

        task = episode.download_task

        if task is not None and task.status in (task.PAUSING, task.PAUSED):
            tooltip.append('%s %d%%' % (_('Paused'),
                int(task.progress * 100)))

            status_icon = 'media-playback-pause'

            view_show_downloaded = True
            view_show_unplayed = True
        elif episode.downloading:
            tooltip.append('%s %d%%' % (_('Downloading'),
                int(task.progress * 100)))

            index = int(self.PROGRESS_STEPS * task.progress)
            status_icon = 'gpodder-progress-%d' % index

            view_show_downloaded = True
            view_show_unplayed = True
        else:
            if episode.state == gpodder.STATE_DELETED:
                tooltip.append(_('Deleted'))
                status_icon = self.ICON_DELETED
                view_show_undeleted = False
            elif episode.state == gpodder.STATE_DOWNLOADED:
                tooltip = []
                view_show_downloaded = True
                view_show_unplayed = episode.is_new
                show_bullet = episode.is_new
                show_padlock = episode.archive
                show_missing = not episode.file_exists()
                filename = episode.local_filename(create=False, check_only=True)

                file_type = episode.file_type()
                if file_type == 'audio':
                    tooltip.append(_('Downloaded episode'))
                    status_icon = self.ICON_AUDIO_FILE
                elif file_type == 'video':
                    tooltip.append(_('Downloaded video episode'))
                    status_icon = self.ICON_VIDEO_FILE
                elif file_type == 'image':
                    tooltip.append(_('Downloaded image'))
                    status_icon = self.ICON_IMAGE_FILE
                else:
                    tooltip.append(_('Downloaded file'))
                    status_icon = self.ICON_GENERIC_FILE

                # Try to find a themed icon for this file
                # doesn't work on win32 (opus files are showed as text)
                if filename is not None and have_gio and not gpodder.ui.win32:
                    file = Gio.File.new_for_path(filename)
                    if file.query_exists():
                        file_info = file.query_info('*', Gio.FileQueryInfoFlags.NONE, None)
                        icon = file_info.get_icon()
                        for icon_name in icon.get_names():
                            if icon_theme.has_icon(icon_name):
                                status_icon = icon_name
                                break

                if show_missing:
                    tooltip.append(_('missing file'))
                else:
                    if show_bullet:
                        if file_type == 'image':
                            tooltip.append(_('never displayed'))
                        elif file_type in ('audio', 'video'):
                            tooltip.append(_('never played'))
                        else:
                            tooltip.append(_('never opened'))
                    else:
                        if file_type == 'image':
                            tooltip.append(_('displayed'))
                        elif file_type in ('audio', 'video'):
                            tooltip.append(_('played'))
                        else:
                            tooltip.append(_('opened'))
                    if show_padlock:
                        tooltip.append(_('deletion prevented'))

                if episode.total_time > 0 and episode.current_position:
                    tooltip.append('%d%%' % (100. * float(episode.current_position) /
                                             float(episode.total_time),))
            elif episode._download_error is not None:
                tooltip.append(_('ERROR: %s') % episode._download_error)
                status_icon = self.ICON_ERROR
                if episode.state == gpodder.STATE_NORMAL and episode.is_new:
                    view_show_downloaded = self._config.ui.gtk.episode_list.always_show_new
                    view_show_unplayed = True
            elif episode.state == gpodder.STATE_NORMAL and episode.is_new:
                tooltip.append(_('New episode'))
                view_show_downloaded = self._config.ui.gtk.episode_list.always_show_new
                view_show_unplayed = True

        if episode.total_time:
            total_time = util.format_time(episode.total_time)
            if total_time:
                tooltip.append(total_time)

        tooltip = ', '.join(tooltip)

        description = ''.join(self._format_description(episode, include_description))
        return (
                (self.C_STATUS_ICON, status_icon),
                (self.C_VIEW_SHOW_UNDELETED, view_show_undeleted),
                (self.C_VIEW_SHOW_DOWNLOADED, view_show_downloaded),
                (self.C_VIEW_SHOW_UNPLAYED, view_show_unplayed),
                (self.C_DESCRIPTION, description),
                (self.C_TOOLTIP, tooltip),
                (self.C_TIME, episode.get_play_info_string()),
                (self.C_TIME_VISIBLE, bool(episode.total_time)),
                (self.C_TOTAL_TIME, episode.total_time),
                (self.C_LOCKED, episode.archive),
                (self.C_FILESIZE_TEXT, self._format_filesize(episode)),
                (self.C_FILESIZE, episode.file_size),

                (self.C_TIME_AND_SIZE, "%s\n<small>%s</small>"
                    % (episode.get_play_info_string(), self._format_filesize(episode) if episode.file_size > 0 else "")),
                (self.C_TOTAL_TIME_AND_SIZE, episode.total_time),
                (self.C_FILESIZE_AND_TIME_TEXT, "%s\n<small>%s</small>"
                    % (self._format_filesize(episode) if episode.file_size > 0 else "", episode.get_play_info_string())),
                (self.C_FILESIZE_AND_TIME, episode.file_size),
        )

    def update_by_iter(self, iter, include_description=False):
        episode = self.get_value(iter, self.C_EPISODE)
        if episode is not None:
            self.set(iter, *(x for pair in self.get_update_fields(episode, include_description) for x in pair)) class PodcastChannelProxy:
    """ a bag of podcasts: 'All Episodes' or each section """
    def __init__(self, db, config, channels, section, model):
        self.ALL_EPISODES_PROXY = not bool(section)
        self._db = db
        self._config = config
        self.channels = channels
        if self.ALL_EPISODES_PROXY:
            self.title = _('All episodes')
            self.description = _('from all podcasts')
            self.url = ''
            self.cover_file = coverart.CoverDownloader.ALL_EPISODES_ID
        else:
            self.title = section
            self.description = ''
            self.url = '-'
            self.cover_file = None
        # self.parse_error = ''
        self.section = section
        self.id = None
        self.cover_url = None
        self.auth_username = None
        self.auth_password = None
        self.pause_subscription = False
        self.sync_to_mp3_player = False
        self.cover_thumb = None
        self.auto_archive_episodes = False
        self.model = model

        self._update_error = None

    def get_statistics(self):
        if self.ALL_EPISODES_PROXY:
            # Get the total statistics for all channels from the database
            return self._db.get_podcast_statistics()
        else:
            # Calculate the stats over all podcasts of this section
            if len(self.channels) == 0:
                total = deleted = new = downloaded = unplayed = 0
            else:
                total, deleted, new, downloaded, unplayed = list(map(sum,
                        list(zip(*[c.get_statistics() for c in self.channels]))))
            return total, deleted, new, downloaded, unplayed

    def get_all_episodes(self):
        """Returns a generator that yields every episode"""
        if self.model._search_term is not None:
            def matches(channel):
                columns = (getattr(channel, c) for c in PodcastListModel.SEARCH_ATTRS)
                return any((key in c.lower() for c in columns if c is not None))
            key = self.model._search_term
        else:
            def matches(e):
                return True
        return Model.sort_episodes_by_pubdate((e for c in self.channels if matches(c)
            for e in c.get_all_episodes()), True)

    def save(self):
        pass class PodcastListModel(Gtk.ListStore):
    C_URL, C_TITLE, C_DESCRIPTION, C_PILL, C_CHANNEL, \
        C_COVER, C_ERROR, C_PILL_VISIBLE, \
        C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED, \
        C_VIEW_SHOW_UNPLAYED, C_HAS_EPISODES, C_SEPARATOR, \
        C_DOWNLOADS, C_COVER_VISIBLE, C_SECTION = list(range(16))

    SEARCH_COLUMNS = (C_TITLE, C_DESCRIPTION, C_SECTION)
    SEARCH_ATTRS = ('title', 'description', 'group_by')

    @classmethod
    def row_separator_func(cls, model, iter):
        return model.get_value(iter, cls.C_SEPARATOR)

    def __init__(self, cover_downloader):
        Gtk.ListStore.__init__(self, str, str, str, GdkPixbuf.Pixbuf,
                object, GdkPixbuf.Pixbuf, str, bool, bool, bool, bool,
                bool, bool, int, bool, str)

        # Filter to allow hiding some episodes
        self._filter = self.filter_new()
        self._view_mode = -1
        self._search_term = None
        self._filter.set_visible_func(self._filter_visible_func)

        self._cover_cache = {}
        self._max_image_side = 40
        self._scale = 1
        self._cover_downloader = cover_downloader

        self.ICON_DISABLED = 'media-playback-pause'
        self.ICON_ERROR = 'dialog-warning'

    def _filter_visible_func(self, model, iter, misc):
        channel = model.get_value(iter, self.C_CHANNEL)

        # If searching is active, set visibility based on search text
        if self._search_term is not None and self._search_term != '':
            key = self._search_term.lower()
            if isinstance(channel, PodcastChannelProxy):
                if channel.ALL_EPISODES_PROXY:
                    return False
                return any(key in getattr(ch, c).lower() for c in PodcastListModel.SEARCH_ATTRS for ch in channel.channels)
            columns = (model.get_value(iter, c) for c in self.SEARCH_COLUMNS)
            return any((key in c.lower() for c in columns if c is not None))

        # Show section if any of its channels have an update error
        if isinstance(channel, PodcastChannelProxy) and not channel.ALL_EPISODES_PROXY:
            if any(c._update_error is not None for c in channel.channels):
                return True

        if model.get_value(iter, self.C_SEPARATOR):
            return True
        elif getattr(channel, '_update_error', None) is not None:
            return True
        elif self._view_mode == EpisodeListModel.VIEW_ALL:
            return model.get_value(iter, self.C_HAS_EPISODES)
        elif self._view_mode == EpisodeListModel.VIEW_UNDELETED:
            return model.get_value(iter, self.C_VIEW_SHOW_UNDELETED)
        elif self._view_mode == EpisodeListModel.VIEW_DOWNLOADED:
            return model.get_value(iter, self.C_VIEW_SHOW_DOWNLOADED)
        elif self._view_mode == EpisodeListModel.VIEW_UNPLAYED:
            return model.get_value(iter, self.C_VIEW_SHOW_UNPLAYED)

        return True

    def get_filtered_model(self):
        """Returns a filtered version of this episode model

        The filtered version should be displayed in the UI,
        as this model can have some filters set that should
        be reflected in the UI.
        """
        return self._filter

    def set_view_mode(self, new_mode):
        """Sets a new view mode for this model

        After setting the view mode, the filtered model
        might be updated to reflect the new mode."""
        if self._view_mode != new_mode:
            self._view_mode = new_mode
            self._filter.refilter()

    def get_view_mode(self):
        """Returns the currently-set view mode"""
        return self._view_mode

    def set_search_term(self, new_term):
        if self._search_term != new_term:
            self._search_term = new_term
            self._filter.refilter()

    def get_search_term(self):
        return self._search_term

    def set_max_image_size(self, size, scale):
        self._max_image_side = size * scale
        self._scale = scale
        self._cover_cache = {}

    def _resize_pixbuf_keep_ratio(self, url, pixbuf):
        """
        Resizes a GTK Pixbuf but keeps its aspect ratio.
        Returns None if the pixbuf does not need to be
        resized or the newly resized pixbuf if it does.
        """
        if url in self._cover_cache:
            return self._cover_cache[url]

        max_side = self._max_image_side
        w_cur = pixbuf.get_width()
        h_cur = pixbuf.get_height()

        if w_cur <= max_side and h_cur <= max_side:
            return None

        f = max_side / (w_cur if w_cur >= h_cur else h_cur)
        w_new = int(w_cur * f)
        h_new = int(h_cur * f)

        logger.debug("Scaling cover image: url=%s from %ix%i to %ix%i",
                     url, w_cur, h_cur, w_new, h_new)
        pixbuf = pixbuf.scale_simple(w_new, h_new,
                                     GdkPixbuf.InterpType.BILINEAR)

        self._cover_cache[url] = pixbuf
        return pixbuf

    def _resize_pixbuf(self, url, pixbuf):
        if pixbuf is None:
            return None

        return self._resize_pixbuf_keep_ratio(url, pixbuf) or pixbuf

    def _overlay_pixbuf(self, pixbuf, icon):
        try:
            icon_theme = Gtk.IconTheme.get_default()
            emblem = icon_theme.load_icon(icon, self._max_image_side / 2, 0)
            (width, height) = (emblem.get_width(), emblem.get_height())
            xpos = pixbuf.get_width() - width
            ypos = pixbuf.get_height() - height
            if ypos < 0:
                # need to resize overlay for none standard icon size
                emblem = icon_theme.load_icon(icon, pixbuf.get_height() - 1, 0)
                (width, height) = (emblem.get_width(), emblem.get_height())
                xpos = pixbuf.get_width() - width
                ypos = pixbuf.get_height() - height
            emblem.composite(pixbuf, xpos, ypos, width, height, xpos, ypos, 1, 1, GdkPixbuf.InterpType.BILINEAR, 255)
        except:
            pass

        return pixbuf

    def _get_cached_thumb(self, channel):
        if channel.cover_thumb is None:
            return None

        try:
            loader = GdkPixbuf.PixbufLoader()
            loader.write(channel.cover_thumb)
            loader.close()
            pixbuf = loader.get_pixbuf()
            if self._max_image_side not in (pixbuf.get_width(), pixbuf.get_height()):
                logger.debug("cached thumb wrong size: %r != %i", (pixbuf.get_width(), pixbuf.get_height()), self._max_image_side)
                return None
            return pixbuf
        except Exception as e:
            logger.warn('Could not load cached cover art for %s', channel.url, exc_info=True)
            channel.cover_thumb = None
            channel.save()
            return None

    def _save_cached_thumb(self, channel, pixbuf):
        bufs = []

        def save_callback(buf, length, user_data):
            user_data.append(buf)
            return True
        pixbuf.save_to_callbackv(save_callback, bufs, 'png', [None], [])
        channel.cover_thumb = bytes(b''.join(bufs))
        channel.save()

    def _get_cover_image(self, channel, add_overlay=False, pixbuf_overlay=None):
        """ get channel's cover image. Callable from gtk thread.
            :param channel: channel model
            :param bool add_overlay: True to add a pause/error overlay
            :param GdkPixbuf.Pixbux pixbuf_overlay: existing pixbuf if already loaded, as an optimization
            :return GdkPixbuf.Pixbux: channel's cover image as pixbuf
        """
        if self._cover_downloader is None:
            return pixbuf_overlay

        if pixbuf_overlay is None:  # optimization: we can pass existing pixbuf
            pixbuf_overlay = self._get_cached_thumb(channel)

        if pixbuf_overlay is None:
            # load cover if it's not in cache
            pixbuf = self._cover_downloader.get_cover(channel, avoid_downloading=True)
            pixbuf_overlay = self._resize_pixbuf(channel.url, pixbuf)
            self._save_cached_thumb(channel, pixbuf_overlay)

        if add_overlay:
            if getattr(channel, '_update_error', None) is not None:
                pixbuf_overlay = self._overlay_pixbuf(pixbuf_overlay, self.ICON_ERROR)
            elif channel.pause_subscription:
                pixbuf_overlay = self._overlay_pixbuf(pixbuf_overlay, self.ICON_DISABLED)
                pixbuf_overlay.saturate_and_pixelate(pixbuf_overlay, 0.0, False)

        return pixbuf_overlay

    def _get_pill_image(self, channel, count_downloaded, count_unplayed):
        if count_unplayed > 0 or count_downloaded > 0:
            return draw.draw_pill_pixbuf('{:n}'.format(count_unplayed),
                                         '{:n}'.format(count_downloaded),
                                         widget=self.widget,
                                         scale=self._scale)
        else:
            return None

    def _format_description(self, channel, total, deleted,
            new, downloaded, unplayed):
        title_markup = html.escape(channel.title)
        if channel._update_error is not None:
            description_markup = html.escape(_('ERROR: %s') % channel._update_error)
        elif not channel.pause_subscription:
            description_markup = html.escape(
                util.get_first_line(util.remove_html_tags(channel.description)) or ' ')
        else:
            description_markup = html.escape(_('Subscription paused'))
        d = []
        if new:
            d.append('<span weight="bold">')
        d.append(title_markup)
        if new:
            d.append('</span>')

        if channel._update_error is not None:
            return ''.join(d + ['\n', '<span weight="bold">', description_markup, '</span>'])
        elif description_markup.strip():
            return ''.join(d + ['\n', '<small>', description_markup, '</small>'])
        else:
            return ''.join(d)

    def _format_error(self, channel):
        # if channel.parse_error:
        #     return str(channel.parse_error)
        # else:
        #     return None
        return None

    def set_channels(self, db, config, channels):
        # Clear the model and update the list of podcasts
        self.clear()

        def channel_to_row(channel, add_overlay=False):
            # C_URL, C_TITLE, C_DESCRIPTION, C_PILL, C_CHANNEL
            return (channel.url, '', '', None, channel,
                    # C_COVER, C_ERROR, C_PILL_VISIBLE,
                    self._get_cover_image(channel, add_overlay), '', True,
                    # C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED,
                    True, True,
                    # C_VIEW_SHOW_UNPLAYED, C_HAS_EPISODES, C_SEPARATOR
                    True, True, False,
                    # C_DOWNLOADS, C_COVER_VISIBLE, C_SECTION
                    0, True, '')

        def section_to_row(section):
            # C_URL, C_TITLE, C_DESCRIPTION, C_PILL, C_CHANNEL
            return (section.url, '', '', None, section,
                    # C_COVER, C_ERROR, C_PILL_VISIBLE,
                    None, '', True,
                    # C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED,
                    True, True,
                    # C_VIEW_SHOW_UNPLAYED, C_HAS_EPISODES, C_SEPARATOR
                    True, True, False,
                    # C_DOWNLOADS, C_COVER_VISIBLE, C_SECTION
                    0, False, section.title)

        if config.podcast_list_view_all and channels:
            all_episodes = PodcastChannelProxy(db, config, channels, '', self)
            iter = self.append(channel_to_row(all_episodes))
            self.update_by_iter(iter)

            # Separator item
            if not config.podcast_list_sections:
                self.append(('', '', '', None, SeparatorMarker, None, '',
                    True, True, True, True, True, True, 0, False, ''))

        def groupby_func(channel):
            return channel.group_by

        def key_func(channel):
            return (channel.group_by, model.Model.podcast_sort_key(channel))

        if config.podcast_list_sections:
            groups = groupby(sorted(channels, key=key_func), groupby_func)
        else:
            groups = [(None, sorted(channels, key=model.Model.podcast_sort_key))]

        for section, section_channels in groups:
            if config.podcast_list_sections and section is not None:
                section_channels = list(section_channels)
                section_obj = PodcastChannelProxy(db, config, section_channels, section, self)
                iter = self.append(section_to_row(section_obj))
                self.update_by_iter(iter)
            for channel in section_channels:
                iter = self.append(channel_to_row(channel, True))
                self.update_by_iter(iter)

    def get_filter_path_from_url(self, url):
        # Return the path of the filtered model for a given URL
        child_path = self.get_path_from_url(url)
        if child_path is None:
            return None
        else:
            return self._filter.convert_child_path_to_path(child_path)

    def get_path_from_url(self, url):
        # Return the tree model path for a given URL
        if url is None:
            return None

        for row in self:
            if row[self.C_URL] == url:
                return row.path
        return None

    def update_first_row(self):
        # Update the first row in the model (for "all episodes" updates)
        self.update_by_iter(self.get_iter_first())

    def update_by_urls(self, urls):
        # Given a list of URLs, update each matching row
        for row in self:
            if row[self.C_URL] in urls:
                self.update_by_iter(row.iter)

    def iter_is_first_row(self, iter):
        iter = self._filter.convert_iter_to_child_iter(iter)
        path = self.get_path(iter)
        return (path == Gtk.TreePath.new_first())

    def update_by_filter_iter(self, iter):
        self.update_by_iter(self._filter.convert_iter_to_child_iter(iter))

    def update_all(self):
        for row in self:
            self.update_by_iter(row.iter)

    def update_sections(self):
        for row in self:
            if isinstance(row[self.C_CHANNEL], PodcastChannelProxy) and not row[self.C_CHANNEL].ALL_EPISODES_PROXY:
                self.update_by_iter(row.iter)

    def update_by_iter(self, iter):
        if iter is None:
            return

        # Given a GtkTreeIter, update volatile information
        channel = self.get_value(iter, self.C_CHANNEL)

        if channel is SeparatorMarker:
            return

        total, deleted, new, downloaded, unplayed = channel.get_statistics()

        if isinstance(channel, PodcastChannelProxy) and not channel.ALL_EPISODES_PROXY:
            section = channel.title

            # We could customized the section header here with the list
            # of channels and their stats (i.e. add some "new" indicator)
            description = '<b>%s</b>' % (
                    html.escape(section))
            pill_image = None
            cover_image = None
        else:
            description = self._format_description(channel, total, deleted, new,
                    downloaded, unplayed)

            pill_image = self._get_pill_image(channel, downloaded, unplayed)
            cover_image = self._get_cover_image(channel, True)

        self.set(iter,
                self.C_TITLE, channel.title,
                self.C_DESCRIPTION, description,
                self.C_COVER, cover_image,
                self.C_SECTION, channel.section,
                self.C_ERROR, self._format_error(channel),
                self.C_PILL, pill_image,
                self.C_PILL_VISIBLE, pill_image is not None,
                self.C_VIEW_SHOW_UNDELETED, total - deleted > 0,
                self.C_VIEW_SHOW_DOWNLOADED, downloaded + new > 0,
                self.C_VIEW_SHOW_UNPLAYED, unplayed + new > 0,
                self.C_HAS_EPISODES, total > 0,
                self.C_DOWNLOADS, downloaded)

    def clear_cover_cache(self, podcast_url):
        if podcast_url in self._cover_cache:
            logger.info('Clearing cover from cache: %s', podcast_url)
            del self._cover_cache[podcast_url]

    def add_cover_by_channel(self, channel, pixbuf):
        if pixbuf is None:
            return
        # Remove older images from cache
        self.clear_cover_cache(channel.url)

        # Resize and add the new cover image
        pixbuf = self._resize_pixbuf(channel.url, pixbuf)
        self._save_cached_thumb(channel, pixbuf)

        pixbuf = self._get_cover_image(channel, add_overlay=True, pixbuf_overlay=pixbuf)

        for row in self:
            if row[self.C_URL] == channel.url:
                row[self.C_COVER] = pixbuf
                break class CoverDownloader(ObservableService):
    """
    This class manages downloading cover art and notification
    of other parts of the system. Downloading cover art can
    happen either synchronously via get_cover() or in
    asynchronous mode via request_cover(). When in async mode,
    the cover downloader will send the cover via the
    'cover-available' message (via the ObservableService).
    """

    def __init__(self):
        self.downloader = coverart.CoverDownloader()
        signal_names = ['cover-available', 'cover-removed']
        ObservableService.__init__(self, signal_names)

    def request_cover(self, channel, custom_url=None, avoid_downloading=False):
        """
        Sends an asynchronous request to download a
        cover for the specific channel.

        After the cover has been downloaded, the
        "cover-available" signal will be sent with
        the channel url and new cover as pixbuf.

        If you specify a custom_url, the cover will
        be downloaded from the specified URL and not
        taken from the channel metadata.

        The optional parameter "avoid_downloading",
        when true, will make sure we return only
        already-downloaded covers and return None
        when we have no cover on the local disk.
        """
        logger.debug('cover download request for %s', channel.url)
        util.run_in_background(lambda: self.__get_cover(channel,
            custom_url, True, avoid_downloading))

    def get_cover(self, channel, custom_url=None, avoid_downloading=False):
        """
        Sends a synchronous request to download a
        cover for the specified channel.

        The cover will be returned to the caller.

        The custom_url has the same semantics as
        in request_cover().

        The optional parameter "avoid_downloading",
        when true, will make sure we return only
        already-downloaded covers and return None
        when we have no cover on the local disk.
        """
        (url, pixbuf) = self.__get_cover(channel, custom_url, False, avoid_downloading)
        return pixbuf

    def replace_cover(self, channel, custom_url=None):
        """
        This is a convenience function that deletes
        the current cover file and requests a new
        cover from the URL specified.
        """
        self.request_cover(channel, custom_url)

    def __get_cover(self, channel, url, async_mode=False, avoid_downloading=False):
        def get_filename():
            return self.downloader.get_cover(channel.cover_file,
                    url or channel.cover_url, channel.url, channel.title,
                    channel.auth_username, channel.auth_password,
                    not avoid_downloading)

        if url is not None:
            filename = get_filename()
            if filename.startswith(channel.cover_file):
                logger.info('Replacing cover: %s', filename)
                util.delete_file(filename)

        filename = get_filename()
        pixbuf = None

        try:
            pixbuf = GdkPixbuf.Pixbuf.new_from_file(filename)
        except Exception as e:
            logger.warn('Cannot load cover art', exc_info=True)
        if pixbuf is None and filename.startswith(channel.cover_file):
            logger.info('Deleting broken cover: %s', filename)
            util.delete_file(filename)
            filename = get_filename()
            try:
                pixbuf = GdkPixbuf.Pixbuf.new_from_file(filename)
            except Exception as e:
                logger.warn('Corrupt cover art on server, deleting', exc_info=True)
                util.delete_file(filename)

        if async_mode:
            self.notify('cover-available', channel, pixbuf)
        else:
            return (channel.url, pixbuf) class SimpleMessageArea(Gtk.HBox):
    """A simple, yellow message area. Inspired by gedit.

    Original C source code:
    http://svn.gnome.org/viewvc/gedit/trunk/gedit/gedit-message-area.c
    """
    def __init__(self, message, buttons=()):
        Gtk.HBox.__init__(self, spacing=6)
        self.set_border_width(6)
        self.__in_style_updated = False
        self.connect('style-updated', self.__style_updated)
        self.connect('draw', self.__on_draw)

        self.__label = Gtk.Label()
        self.__label.set_alignment(0.0, 0.5)
        self.__label.set_line_wrap(False)
        self.__label.set_ellipsize(Pango.EllipsizeMode.END)
        self.__label.set_markup('<b>%s</b>' % html.escape(message))
        self.pack_start(self.__label, True, True, 0)

        hbox = Gtk.HBox()
        for button in buttons:
            hbox.pack_start(button, True, False, 0)
        self.pack_start(hbox, False, False, 0)

    def set_markup(self, markup, line_wrap=True, min_width=3, max_width=100):
        # The longest line should determine the size of the label
        width_chars = max(len(line) for line in markup.splitlines())

        # Enforce upper and lower limits for the width
        width_chars = max(min_width, min(max_width, width_chars))

        self.__label.set_width_chars(width_chars)
        self.__label.set_markup(markup)
        self.__label.set_line_wrap(line_wrap)

    def __style_updated(self, widget):
        if self.__in_style_updated:
            return

        w = Gtk.Window(Gtk.WindowType.POPUP)
        w.set_name('gtk-tooltip')
        w.ensure_style()
        style = w.get_style()

        self.__in_style_set = True
        self.set_style(style)
        self.__label.set_style(style)
        self.__in_style_set = False

        w.destroy()

        self.queue_draw()

    def __on_draw(self, widget, cr):
        style = widget.get_style()
        x, rect = Gdk.cairo_get_clip_rectangle(cr)
        Gtk.paint_flat_box(style, cr, Gtk.StateType.NORMAL,
                Gtk.ShadowType.OUT, widget, "tooltip",
                rect.x, rect.y, rect.width, rect.height)
        return False
import collections
import html
import logging
import os
import re
import shutil
import sys
import tempfile
import threading
import time
import urllib.parse
import dbus.service
import requests.exceptions
import urllib3.exceptions
import gpodder
import gi  # isort:skip
from gpodder import (common, download, extensions, feedcore, my, opml, player,
                     util, youtube)
from gpodder.dbusproxy import DBusPodcastsProxy
from gpodder.model import Model, PodcastEpisode
from gpodder.syncui import gPodderSyncUI
from . import shownotes
from .desktop.channel import gPodderChannel
from .desktop.episodeselector import gPodderEpisodeSelector
from .desktop.exportlocal import gPodderExportToLocalFolder
from .desktop.podcastdirectory import gPodderPodcastDirectory
from .desktop.welcome import gPodderWelcome
from .desktopfile import UserAppsReader
from .download import DownloadStatusModel
from .draw import (cake_size_from_widget, draw_cake_pixbuf,
                   draw_iconcell_scale, draw_text_box_centered)
from .interface.addpodcast import gPodderAddPodcast
from .interface.common import BuilderWidget, TreeViewHelper
from .interface.progress import ProgressIndicator
from .interface.searchtree import SearchTree
from .model import EpisodeListModel, PodcastChannelProxy, PodcastListModel
from .services import CoverDownloader
from .widgets import SimpleMessageArea
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, GObject, Gtk, Pango  # isort:skip

        self.treeChannels.set_model(self.podcast_list_model.get_filtered_model())
        self.podcast_list_model.widget = self.treeChannels

        # When no podcast is selected, clear the episode list model
        selection = self.treeChannels.get_selection()

        # Set up type-ahead find for the podcast list
        def on_key_press(treeview, event):
            if event.keyval == Gdk.KEY_Right:
                self.treeAvailable.grab_focus()
            elif event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down):
                # If section markers exist in the treeview, we want to
                # "jump over" them when moving the cursor up and down
                if event.keyval == Gdk.KEY_Up:
                    step = -1
                else:
                    step = 1

                selection = self.treeChannels.get_selection()
                model, it = selection.get_selected()
                if it is None:
                    it = model.get_iter_first()
                    if it is None:
                        return False
                    step = 1

                path = model.get_path(it)
                path = (path[0] + step,)

                if path[0] < 0:
                    # Valid paths must have a value >= 0
                    return True

                try:
                    it = model.get_iter(path)
                except ValueError:
                    # Already at the end of the list
                    return True

                self.treeChannels.set_cursor(path)
            elif event.keyval == Gdk.KEY_Escape:
                self._search_podcasts.hide_search()
            elif event.get_state() & Gdk.ModifierType.CONTROL_MASK:
                # Don't handle type-ahead when control is pressed (so shortcuts
                # with the Ctrl key still work, e.g. Ctrl+A, ...)
                return True
            elif event.keyval == Gdk.KEY_Delete:
                return False
            else:
                unicode_char_id = Gdk.keyval_to_unicode(event.keyval)
                # < 32 to intercept Delete and Tab events
                if unicode_char_id < 32:
                    return False
                input_char = chr(unicode_char_id)
                self._search_podcasts.show_search(input_char)
            return True
        self.treeChannels.connect('key-press-event', on_key_press)

        self.treeChannels.connect('popup-menu', self.treeview_channels_show_context_menu)

        # Enable separators to the podcast list to separate special podcasts
        # from others (this is used for the "all episodes" view)
        self.treeChannels.set_row_separator_func(PodcastListModel.row_separator_func)

        TreeViewHelper.set(self.treeChannels, TreeViewHelper.ROLE_PODCASTS)

        self._search_podcasts = SearchTree(self.hbox_search_podcasts,
                                           self.entry_search_podcasts,
                                           self.treeChannels,
                                           self.podcast_list_model,
                                           self.config)
        if self.config.ui.gtk.search_always_visible:
            self._search_podcasts.show_search(grab_focus=False)

    def on_find_episode_activate(self, *args):
        if self._search_episodes:
            self._search_episodes.show_search()

    def set_episode_list_column(self, index, new_value):
        mask = (1 << index)
        if new_value:
            self.config.episode_list_columns |= mask
        else:
            self.config.episode_list_columns &= ~mask

    def update_episode_list_columns_visibility(self):
        columns = TreeViewHelper.get_columns(self.treeAvailable)
        for index, column in enumerate(columns):
            visible = bool(self.config.episode_list_columns & (1 << index))
            column.set_visible(visible)
            self.view_column_actions[index].set_state(GLib.Variant.new_boolean(visible))
        self.treeAvailable.columns_autosize()

    def on_episode_list_header_reordered(self, treeview):
        self.config.ui.gtk.state.main_window.episode_column_order = \
            [column.get_sort_column_id() for column in treeview.get_columns()]

    def on_episode_list_header_sorted(self, column):
        self.config.ui.gtk.state.main_window.episode_column_sort_id = column.get_sort_column_id()
        self.config.ui.gtk.state.main_window.episode_column_sort_order = \
            (column.get_sort_order() is Gtk.SortType.ASCENDING)

    def on_episode_list_header_clicked(self, button, event):
        if event.button == 1:
            # Require control click to sort episodes, when enabled
            if self.config.ui.gtk.episode_list.ctrl_click_to_sort and (event.state & Gdk.ModifierType.CONTROL_MASK) == 0:
                return True
        elif event.button == 3:
            if self.episode_columns_menu is not None:
                self.episode_columns_menu.popup(None, None, None, None, event.button, event.time)

        return False

    def init_episode_list_treeview(self):
        self.episode_list_model.set_view_mode(self.config.episode_list_view_mode)

        # Initialize progress icons
        cake_size = cake_size_from_widget(self.treeAvailable)
        for i in range(EpisodeListModel.PROGRESS_STEPS + 1):

            pixbuf = draw_cake_pixbuf(i /