diff options
Diffstat (limited to 'deluge/ui/console/modes/torrentlist')
-rw-r--r-- | deluge/ui/console/modes/torrentlist/__init__.py | 17 | ||||
-rw-r--r-- | deluge/ui/console/modes/torrentlist/add_torrents_popup.py | 110 | ||||
-rw-r--r-- | deluge/ui/console/modes/torrentlist/filtersidebar.py | 131 | ||||
-rw-r--r-- | deluge/ui/console/modes/torrentlist/queue_mode.py | 154 | ||||
-rw-r--r-- | deluge/ui/console/modes/torrentlist/search_mode.py | 206 | ||||
-rw-r--r-- | deluge/ui/console/modes/torrentlist/torrentactions.py | 272 | ||||
-rw-r--r-- | deluge/ui/console/modes/torrentlist/torrentlist.py | 347 | ||||
-rw-r--r-- | deluge/ui/console/modes/torrentlist/torrentview.py | 514 | ||||
-rw-r--r-- | deluge/ui/console/modes/torrentlist/torrentviewcolumns.py | 159 |
9 files changed, 1910 insertions, 0 deletions
diff --git a/deluge/ui/console/modes/torrentlist/__init__.py b/deluge/ui/console/modes/torrentlist/__init__.py new file mode 100644 index 0000000..48c60ce --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/__init__.py @@ -0,0 +1,17 @@ +class ACTION: + PAUSE = 'pause' + RESUME = 'resume' + REANNOUNCE = 'update_tracker' + EDIT_TRACKERS = 3 + RECHECK = 'force_recheck' + REMOVE = 'remove_torrent' + REMOVE_DATA = 6 + REMOVE_NODATA = 7 + DETAILS = 'torrent_details' + MOVE_STORAGE = 'move_download_folder' + QUEUE = 'queue' + QUEUE_TOP = 'queue_top' + QUEUE_UP = 'queue_up' + QUEUE_DOWN = 'queue_down' + QUEUE_BOTTOM = 'queue_bottom' + TORRENT_OPTIONS = 'torrent_options' diff --git a/deluge/ui/console/modes/torrentlist/add_torrents_popup.py b/deluge/ui/console/modes/torrentlist/add_torrents_popup.py new file mode 100644 index 0000000..3ff9ab7 --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/add_torrents_popup.py @@ -0,0 +1,110 @@ +# +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +import logging + +import deluge.common +from deluge.ui.client import client +from deluge.ui.console.widgets.popup import InputPopup, SelectablePopup + +log = logging.getLogger(__name__) + + +def report_add_status(torrentlist, succ_cnt, fail_cnt, fail_msgs): + if fail_cnt == 0: + torrentlist.report_message( + 'Torrents Added', '{!success!}Successfully added %d torrent(s)' % succ_cnt + ) + else: + msg = ( + '{!error!}Failed to add the following %d torrent(s):\n {!input!}' % fail_cnt + ) + '\n '.join(fail_msgs) + if succ_cnt != 0: + msg += '\n \n{!success!}Successfully added %d torrent(s)' % succ_cnt + torrentlist.report_message('Torrent Add Report', msg) + + +def show_torrent_add_popup(torrentlist): + def do_add_from_url(data=None, **kwargs): + torrentlist.pop_popup() + if not data or kwargs.get('close', False): + return + + def fail_cb(msg, url): + log.debug('failed to add torrent: %s: %s', url, msg) + error_msg = f'{{!input!}} * {url}: {{!error!}}{msg}' + report_add_status(torrentlist, 0, 1, [error_msg]) + + def success_cb(tid, url): + if tid: + log.debug('added torrent: %s (%s)', url, tid) + report_add_status(torrentlist, 1, 0, []) + else: + fail_cb('Already in session (probably)', url) + + url = data['url']['value'] + if not url: + return + + t_options = { + 'download_location': data['path']['value'], + 'add_paused': data['add_paused']['value'], + } + + if deluge.common.is_magnet(url): + client.core.add_torrent_magnet(url, t_options).addCallback( + success_cb, url + ).addErrback(fail_cb, url) + elif deluge.common.is_url(url): + client.core.add_torrent_url(url, t_options).addCallback( + success_cb, url + ).addErrback(fail_cb, url) + else: + torrentlist.report_message( + 'Error', '{!error!}Invalid URL or magnet link: %s' % url + ) + return + + log.debug( + 'Adding Torrent(s): %s (dl path: %s) (paused: %d)', + url, + data['path']['value'], + data['add_paused']['value'], + ) + + def show_add_url_popup(): + add_paused = 1 if 'add_paused' in torrentlist.coreconfig else 0 + popup = InputPopup( + torrentlist, 'Add Torrent (Esc to cancel)', close_cb=do_add_from_url + ) + popup.add_text_input('url', 'Enter torrent URL or Magnet link:') + popup.add_text_input( + 'path', + 'Enter save path:', + torrentlist.coreconfig.get('download_location', ''), + complete=True, + ) + popup.add_select_input( + 'add_paused', 'Add Paused:', ['Yes', 'No'], [True, False], add_paused + ) + torrentlist.push_popup(popup) + + def option_chosen(selected, *args, **kwargs): + if not selected or selected == 'cancel': + torrentlist.pop_popup() + return + if selected == 'file': + torrentlist.consoleui.set_mode('AddTorrents') + elif selected == 'url': + show_add_url_popup() + + popup = SelectablePopup(torrentlist, 'Add torrent', option_chosen) + popup.add_line('file', '- From _File(s)', use_underline=True) + popup.add_line('url', '- From _URL or Magnet', use_underline=True) + popup.add_line('cancel', '- _Cancel', use_underline=True) + torrentlist.push_popup(popup, clear=True) diff --git a/deluge/ui/console/modes/torrentlist/filtersidebar.py b/deluge/ui/console/modes/torrentlist/filtersidebar.py new file mode 100644 index 0000000..982e245 --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/filtersidebar.py @@ -0,0 +1,131 @@ +# +# Copyright (C) 2016 bendikro <bro.devel+deluge@gmail.com> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +import curses +import logging + +from deluge.component import Component +from deluge.decorators import overrides +from deluge.ui.client import client +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.widgets import BaseInputPane +from deluge.ui.console.widgets.sidebar import Sidebar + +log = logging.getLogger(__name__) + + +class FilterSidebar(Sidebar, Component): + """The sidebar in the main torrentview + + Shows the different states of the torrents and allows to filter the + torrents based on state. + + """ + + def __init__(self, torrentlist, config): + self.config = config + height = curses.LINES - 2 + width = self.config['torrentview']['sidebar_width'] + Sidebar.__init__( + self, + torrentlist, + width, + height, + title=' Filter ', + border_off_north=1, + allow_resize=True, + ) + Component.__init__(self, 'FilterSidebar') + self.checked_index = 0 + kwargs = { + 'checked_char': '*', + 'unchecked_char': '-', + 'checkbox_format': ' %s ', + 'col': 0, + } + self.add_checked_input('All', 'All', checked=True, **kwargs) + self.add_checked_input('Active', 'Active', **kwargs) + self.add_checked_input( + 'Downloading', 'Downloading', color='green,black', **kwargs + ) + self.add_checked_input('Seeding', 'Seeding', color='cyan,black', **kwargs) + self.add_checked_input('Paused', 'Paused', **kwargs) + self.add_checked_input('Error', 'Error', color='red,black', **kwargs) + self.add_checked_input('Checking', 'Checking', color='blue,black', **kwargs) + self.add_checked_input('Queued', 'Queued', **kwargs) + self.add_checked_input( + 'Allocating', 'Allocating', color='yellow,black', **kwargs + ) + self.add_checked_input('Moving', 'Moving', color='green,black', **kwargs) + + @overrides(Component) + def update(self): + if not self.hidden() and client.connected(): + d = client.core.get_filter_tree(True, []).addCallback( + self._cb_update_filter_tree + ) + + def on_filter_tree_updated(changed): + if changed: + self.refresh() + + d.addCallback(on_filter_tree_updated) + + def _cb_update_filter_tree(self, filter_items): + """Callback function on client.core.get_filter_tree""" + states = filter_items['state'] + largest_count = 0 + largest_state_width = 0 + for state in states: + largest_state_width = max(len(state[0]), largest_state_width) + largest_count = max(int(state[1]), largest_count) + + border_and_spacing = 6 # Account for border + whitespace + filter_state_width = largest_state_width + filter_count_width = self.width - filter_state_width - border_and_spacing + + changed = False + for state in states: + field = self.get_input(state[0]) + if field: + txt = ( + '%%-%ds%%%ds' + % (filter_state_width, filter_count_width) + % (state[0], state[1]) + ) + if field.set_message(txt): + changed = True + return changed + + @overrides(BaseInputPane) + def immediate_action_cb(self, state_changed=True): + if state_changed: + self.parent.torrentview.set_torrent_filter( + self.inputs[self.active_input].name + ) + + @overrides(Sidebar) + def handle_read(self, c): + if c == util.KEY_SPACE: + if self.checked_index != self.active_input: + self.inputs[self.checked_index].set_value(False) + Sidebar.handle_read(self, c) + self.checked_index = self.active_input + return util.ReadState.READ + else: + return Sidebar.handle_read(self, c) + + @overrides(Sidebar) + def on_resize(self, width): + sidebar_width = self.config['torrentview']['sidebar_width'] + if sidebar_width != width: + self.config['torrentview']['sidebar_width'] = width + self.config.save() + self.resize_window(self.height, width) + self.parent.toggle_sidebar() + self.refresh() diff --git a/deluge/ui/console/modes/torrentlist/queue_mode.py b/deluge/ui/console/modes/torrentlist/queue_mode.py new file mode 100644 index 0000000..33af013 --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/queue_mode.py @@ -0,0 +1,154 @@ +# +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +from deluge.ui.client import client +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.widgets.popup import MessagePopup, SelectablePopup + +from . import ACTION + +try: + import curses +except ImportError: + pass + +key_to_action = { + curses.KEY_HOME: ACTION.QUEUE_TOP, + curses.KEY_UP: ACTION.QUEUE_UP, + curses.KEY_DOWN: ACTION.QUEUE_DOWN, + curses.KEY_END: ACTION.QUEUE_BOTTOM, +} +QUEUE_MODE_HELP_STR = """ +Change queue position of selected torrents + +{!info!}'+'{!normal!} - {|indent_pos:|}Move up +{!info!}'-'{!normal!} - {|indent_pos:|}Move down + +{!info!}'Home'{!normal!} - {|indent_pos:|}Move to top +{!info!}'End'{!normal!} - {|indent_pos:|}Move to bottom + +""" + + +class QueueMode: + def __init__(self, torrentslist, torrent_ids): + self.torrentslist = torrentslist + self.torrentview = torrentslist.torrentview + self.torrent_ids = torrent_ids + + def set_statusbar_args(self, statusbar_args): + statusbar_args[ + 'bottombar' + ] = '{!black,white!}Queue mode: change queue position of selected torrents.' + statusbar_args['bottombar_help'] = ' Press [h] for help' + + def update_cursor(self): + pass + + def update_colors(self, tidx, colors): + pass + + def handle_read(self, c): + if c in [util.KEY_ESC, util.KEY_BELL]: # If Escape key or CTRL-g, we abort + self.torrentslist.set_minor_mode(None) + elif c == ord('h'): + popup = MessagePopup( + self.torrentslist, + 'Help', + QUEUE_MODE_HELP_STR, + width_req=0.65, + border_off_west=1, + ) + self.torrentslist.push_popup(popup, clear=True) + elif c in [ + curses.KEY_UP, + curses.KEY_DOWN, + curses.KEY_HOME, + curses.KEY_END, + curses.KEY_NPAGE, + curses.KEY_PPAGE, + ]: + action = key_to_action[c] + self.do_queue(action) + + def move_selection(self, cb_arg, qact): + if self.torrentslist.config['torrentview']['move_selection'] is False: + return + queue_length = 0 + selected_num = 0 + for tid in self.torrentview.curstate: + tq = self.torrentview.curstate[tid]['queue'] + if tq != -1: + queue_length += 1 + if tq in self.torrentview.marked: + selected_num += 1 + if qact == ACTION.QUEUE_TOP: + if self.torrentview.marked: + self.torrentview.cursel = 1 + sorted(self.torrentview.marked).index( + self.torrentview.cursel + ) + else: + self.torrentview.cursel = 1 + self.torrentview.marked = list(range(1, selected_num + 1)) + elif qact == ACTION.QUEUE_UP: + self.torrentview.cursel = max(1, self.torrentview.cursel - 1) + self.torrentview.marked = [marked - 1 for marked in self.torrentview.marked] + self.torrentview.marked = [ + marked for marked in self.torrentview.marked if marked > 0 + ] + elif qact == ACTION.QUEUE_DOWN: + self.torrentview.cursel = min(queue_length, self.torrentview.cursel + 1) + self.torrentview.marked = [marked + 1 for marked in self.torrentview.marked] + self.torrentview.marked = [ + marked for marked in self.torrentview.marked if marked <= queue_length + ] + elif qact == ACTION.QUEUE_BOTTOM: + if self.torrentview.marked: + self.torrentview.cursel = ( + queue_length + - selected_num + + 1 + + sorted(self.torrentview.marked).index(self.torrentview.cursel) + ) + else: + self.torrentview.cursel = queue_length + self.torrentview.marked = list( + range(queue_length - selected_num + 1, queue_length + 1) + ) + + def do_queue(self, qact, *args, **kwargs): + if qact == ACTION.QUEUE_TOP: + client.core.queue_top(self.torrent_ids).addCallback( + self.move_selection, qact + ) + elif qact == ACTION.QUEUE_BOTTOM: + client.core.queue_bottom(self.torrent_ids).addCallback( + self.move_selection, qact + ) + elif qact == ACTION.QUEUE_UP: + client.core.queue_up(self.torrent_ids).addCallback( + self.move_selection, qact + ) + elif qact == ACTION.QUEUE_DOWN: + client.core.queue_down(self.torrent_ids).addCallback( + self.move_selection, qact + ) + + def popup(self, **kwargs): + popup = SelectablePopup( + self.torrentslist, + 'Queue Action', + self.do_queue, + cb_args=kwargs, + border_off_west=1, + ) + popup.add_line(ACTION.QUEUE_TOP, '_Top') + popup.add_line(ACTION.QUEUE_UP, '_Up') + popup.add_line(ACTION.QUEUE_DOWN, '_Down') + popup.add_line(ACTION.QUEUE_BOTTOM, '_Bottom') + self.torrentslist.push_popup(popup) diff --git a/deluge/ui/console/modes/torrentlist/search_mode.py b/deluge/ui/console/modes/torrentlist/search_mode.py new file mode 100644 index 0000000..6f79628 --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/search_mode.py @@ -0,0 +1,206 @@ +# +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +import logging + +from deluge.decorators import overrides +from deluge.ui.console.modes.basemode import InputKeyHandler, move_cursor +from deluge.ui.console.modes.torrentlist.torrentactions import torrent_actions_popup +from deluge.ui.console.utils import curses_util as util + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) +QUEUE_MODE_HELP_STR = """ +Change queue position of selected torrents + +{!info!}'+'{!normal!} - {|indent_pos:|}Move up +{!info!}'-'{!normal!} - {|indent_pos:|}Move down + +{!info!}'Home'{!normal!} - {|indent_pos:|}Move to top +{!info!}'End'{!normal!} - {|indent_pos:|}Move to bottom + +""" +SEARCH_EMPTY = 0 +SEARCH_FAILING = 1 +SEARCH_SUCCESS = 2 +SEARCH_START_REACHED = 3 +SEARCH_END_REACHED = 4 +SEARCH_FORMAT = { + SEARCH_EMPTY: '{!black,white!}Search torrents: %s{!black,white!}', + SEARCH_SUCCESS: '{!black,white!}Search torrents: {!black,green!}%s{!black,white!}', + SEARCH_FAILING: '{!black,white!}Search torrents: {!black,red!}%s{!black,white!}', + SEARCH_START_REACHED: '{!black,white!}Search torrents: {!black,yellow!}%s{!black,white!} (start reached)', + SEARCH_END_REACHED: '{!black,white!}Search torrents: {!black,yellow!}%s{!black,white!} (end reached)', +} + + +class SearchMode(InputKeyHandler): + def __init__(self, torrentlist): + super().__init__() + self.torrentlist = torrentlist + self.torrentview = torrentlist.torrentview + self.search_state = SEARCH_EMPTY + self.search_string = '' + + def update_cursor(self): + util.safe_curs_set(util.Curser.VERY_VISIBLE) + move_cursor( + self.torrentlist.stdscr, + self.torrentlist.rows - 1, + len(self.search_string) + 17, + ) + + def set_statusbar_args(self, statusbar_args): + statusbar_args['bottombar'] = ( + SEARCH_FORMAT[self.search_state] % self.search_string + ) + statusbar_args['bottombar_help'] = False + + def update_colors(self, tidx, colors): + if len(self.search_string) > 1: + lcase_name = self.torrentview.torrent_names[tidx].lower() + sstring_lower = self.search_string.lower() + if lcase_name.find(sstring_lower) != -1: + if tidx == self.torrentview.cursel: + pass + elif tidx in self.torrentview.marked: + colors['bg'] = 'magenta' + else: + colors['bg'] = 'green' + if colors['fg'] == 'green': + colors['fg'] = 'black' + colors['attr'] = 'bold' + + def do_search(self, direction='first'): + """ + Performs a search on visible torrent and sets cursor to the match + + Args: + direction (str): The direction to search. Must be one of 'first', 'last', 'next' or 'previous' + + """ + search_space = list(enumerate(self.torrentview.torrent_names)) + + if direction == 'last': + search_space = reversed(search_space) + elif direction == 'next': + search_space = search_space[self.torrentview.cursel + 1 :] + elif direction == 'previous': + search_space = reversed(search_space[: self.torrentview.cursel]) + + search_string = self.search_string.lower() + for i, n in search_space: + n = n.lower() + if n.find(search_string) != -1: + self.torrentview.cursel = i + if ( + self.torrentview.curoff + + self.torrentview.torrent_rows + - self.torrentview.torrentlist_offset + ) < self.torrentview.cursel: + self.torrentview.curoff = ( + self.torrentview.cursel - self.torrentview.torrent_rows + 1 + ) + elif (self.torrentview.curoff + 1) > self.torrentview.cursel: + self.torrentview.curoff = max(0, self.torrentview.cursel) + self.search_state = SEARCH_SUCCESS + return + if direction in ['first', 'last']: + self.search_state = SEARCH_FAILING + elif direction == 'next': + self.search_state = SEARCH_END_REACHED + elif direction == 'previous': + self.search_state = SEARCH_START_REACHED + + @overrides(InputKeyHandler) + def handle_read(self, c): + cname = self.torrentview.torrent_names[self.torrentview.cursel] + refresh = True + + if c in [ + util.KEY_ESC, + util.KEY_BELL, + ]: # If Escape key or CTRL-g, we abort search + self.torrentlist.set_minor_mode(None) + self.search_state = SEARCH_EMPTY + elif c in [curses.KEY_BACKSPACE, util.KEY_BACKSPACE2]: + if self.search_string: + self.search_string = self.search_string[:-1] + if cname.lower().find(self.search_string.lower()) != -1: + self.search_state = SEARCH_SUCCESS + else: + self.torrentlist.set_minor_mode(None) + self.search_state = SEARCH_EMPTY + elif c == curses.KEY_DC: + self.search_string = '' + self.search_state = SEARCH_SUCCESS + elif c == curses.KEY_UP: + self.do_search('previous') + elif c == curses.KEY_DOWN: + self.do_search('next') + elif c == curses.KEY_LEFT: + self.torrentlist.set_minor_mode(None) + self.search_state = SEARCH_EMPTY + elif c == ord('/'): + self.torrentlist.set_minor_mode(None) + self.search_state = SEARCH_EMPTY + elif c == curses.KEY_RIGHT: + tid = self.torrentview.current_torrent_id() + self.torrentlist.show_torrent_details(tid) + refresh = False + elif c == curses.KEY_HOME: + self.do_search('first') + elif c == curses.KEY_END: + self.do_search('last') + elif c in [10, curses.KEY_ENTER]: + self.last_mark = -1 + tid = self.torrentview.current_torrent_id() + torrent_actions_popup(self.torrentlist, [tid], details=True) + refresh = False + elif c == util.KEY_ESC: + self.search_string = '' + self.search_state = SEARCH_EMPTY + elif c > 31 and c < 256: + old_search_string = self.search_string + stroke = chr(c) + uchar = stroke + while not uchar: + try: + uchar = stroke.decode(self.torrentlist.encoding) + except UnicodeDecodeError: + c = self.torrentlist.stdscr.getch() + stroke += chr(c) + + if uchar: + self.search_string += uchar + + still_matching = ( + cname.lower().find(self.search_string.lower()) + == cname.lower().find(old_search_string.lower()) + and cname.lower().find(self.search_string.lower()) != -1 + ) + + if self.search_string and not still_matching: + self.do_search() + elif self.search_string: + self.search_state = SEARCH_SUCCESS + else: + refresh = False + + if not self.search_string: + self.search_state = SEARCH_EMPTY + refresh = True + + if refresh: + self.torrentlist.refresh([]) + + return util.ReadState.READ diff --git a/deluge/ui/console/modes/torrentlist/torrentactions.py b/deluge/ui/console/modes/torrentlist/torrentactions.py new file mode 100644 index 0000000..a153e11 --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/torrentactions.py @@ -0,0 +1,272 @@ +# +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +import logging +import os + +from twisted.internet import defer + +import deluge.component as component +from deluge.ui.client import client +from deluge.ui.common import TORRENT_DATA_FIELD +from deluge.ui.console.modes.torrentlist.queue_mode import QueueMode +from deluge.ui.console.utils import colors +from deluge.ui.console.utils.common import TORRENT_OPTIONS +from deluge.ui.console.widgets.popup import InputPopup, MessagePopup, SelectablePopup + +from . import ACTION + +log = logging.getLogger(__name__) + + +def action_error(error, mode): + mode.report_message('Error Occurred', error.getErrorMessage()) + mode.refresh() + + +def action_remove(mode=None, torrent_ids=None, **kwargs): + def do_remove(*args, **kwargs): + data = args[0] if args else None + if data is None or kwargs.get('close', False): + mode.pop_popup() + return True + + mode.torrentview.clear_marked() + remove_data = data['remove_files']['value'] + + def on_removed_finished(errors): + if errors: + error_msgs = '' + for t_id, e_msg in errors: + error_msgs += f'Error removing torrent {t_id} : {e_msg}\n' + mode.report_message( + 'Error(s) occured when trying to delete torrent(s).', error_msgs + ) + mode.refresh() + + d = client.core.remove_torrents(torrent_ids, remove_data) + d.addCallback(on_removed_finished) + mode.pop_popup() + + def got_status(status): + return (status['name'], status['state']) + + callbacks = [] + for tid in torrent_ids: + d = client.core.get_torrent_status(tid, ['name', 'state']) + callbacks.append(d.addCallback(got_status)) + + def remove_dialog(status): + status = [t_status[1] for t_status in status] + + if len(torrent_ids) == 1: + rem_msg = '{!info!}Remove the following torrent?{!input!}' + else: + rem_msg = '{!info!}Remove the following %d torrents?{!input!}' % len( + torrent_ids + ) + + show_max = 6 + for i, (name, state) in enumerate(status): + color = colors.state_color[state] + rem_msg += f'\n {color}* {{!input!}}{name}' + if i == show_max - 1: + if i < len(status) - 1: + rem_msg += '\n {!red!}And %i more' % (len(status) - show_max) + break + + popup = InputPopup( + mode, + '(Esc to cancel, Enter to remove)', + close_cb=do_remove, + border_off_west=1, + border_off_north=1, + ) + popup.add_text(rem_msg) + popup.add_spaces(1) + popup.add_select_input( + 'remove_files', + '{!info!}Torrent files:', + ['Keep', 'Remove'], + [False, True], + False, + ) + mode.push_popup(popup) + + defer.DeferredList(callbacks).addCallback(remove_dialog) + + +def action_torrent_info(mode=None, torrent_ids=None, **kwargs): + popup = MessagePopup(mode, 'Torrent options', 'Querying core, please wait...') + mode.push_popup(popup) + torrents = torrent_ids + options = {} + + def _do_set_torrent_options(torrent_ids, result): + options = {} + for opt, val in result.items(): + if val['value'] not in ['multiple', None]: + options[opt] = val['value'] + client.core.set_torrent_options(torrent_ids, options) + + def on_torrent_status(status): + for key in status: + if key not in options: + options[key] = status[key] + elif options[key] != status[key]: + options[key] = 'multiple' + + def create_popup(status): + mode.pop_popup() + + def cb(result, **kwargs): + if result is None: + return + _do_set_torrent_options(torrent_ids, result) + if kwargs.get('close', False): + mode.pop_popup() + return True + + option_popup = InputPopup( + mode, + ' Set Torrent Options ', + close_cb=cb, + border_off_west=1, + border_off_north=1, + base_popup=kwargs.get('base_popup', None), + ) + for field in TORRENT_OPTIONS: + caption = '{!info!}' + TORRENT_DATA_FIELD[field]['name'] + value = options[field] + if isinstance(value, ''.__class__): + option_popup.add_text_input(field, caption, value) + elif isinstance(value, bool): + choices = (['Yes', 'No'], [True, False], [True, False].index(value)) + option_popup.add_select_input( + field, caption, choices[0], choices[1], choices[2] + ) + elif isinstance(value, float): + option_popup.add_float_spin_input( + field, caption, value=value, min_val=-1 + ) + elif isinstance(value, int): + option_popup.add_int_spin_input(field, caption, value=value, min_val=-1) + + mode.push_popup(option_popup) + + callbacks = [] + for tid in torrents: + deferred = component.get('SessionProxy').get_torrent_status( + tid, list(TORRENT_OPTIONS) + ) + callbacks.append(deferred.addCallback(on_torrent_status)) + + callbacks = defer.DeferredList(callbacks) + callbacks.addCallback(create_popup) + + +def torrent_action(action, *args, **kwargs): + retval = False + torrent_ids = kwargs.get('torrent_ids', None) + mode = kwargs.get('mode', None) + + if torrent_ids is None: + return + + if action == ACTION.PAUSE: + log.debug('Pausing torrents: %s', torrent_ids) + client.core.pause_torrents(torrent_ids).addErrback(action_error, mode) + retval = True + elif action == ACTION.RESUME: + log.debug('Resuming torrents: %s', torrent_ids) + client.core.resume_torrents(torrent_ids).addErrback(action_error, mode) + retval = True + elif action == ACTION.QUEUE: + queue_mode = QueueMode(mode, torrent_ids) + queue_mode.popup(**kwargs) + elif action == ACTION.REMOVE: + action_remove(**kwargs) + retval = True + elif action == ACTION.MOVE_STORAGE: + + def do_move(res, **kwargs): + if res is None or kwargs.get('close', False): + mode.pop_popup() + return True + + if os.path.exists(res['path']['value']) and not os.path.isdir( + res['path']['value'] + ): + mode.report_message( + 'Cannot Move Download Folder', + '{!error!}%s exists and is not a directory' % res['path']['value'], + ) + else: + log.debug('Moving %s to: %s', torrent_ids, res['path']['value']) + client.core.move_storage(torrent_ids, res['path']['value']).addErrback( + action_error, mode + ) + + popup = InputPopup( + mode, 'Move Download Folder', close_cb=do_move, border_off_east=1 + ) + popup.add_text_input('path', 'Enter path to move to:', complete=True) + mode.push_popup(popup) + elif action == ACTION.RECHECK: + log.debug('Rechecking torrents: %s', torrent_ids) + client.core.force_recheck(torrent_ids).addErrback(action_error, mode) + retval = True + elif action == ACTION.REANNOUNCE: + log.debug('Reannouncing torrents: %s', torrent_ids) + client.core.force_reannounce(torrent_ids).addErrback(action_error, mode) + retval = True + elif action == ACTION.DETAILS: + log.debug('Torrent details') + tid = mode.torrentview.current_torrent_id() + if tid: + mode.show_torrent_details(tid) + else: + log.error('No current torrent in _torrentaction, this is a bug') + elif action == ACTION.TORRENT_OPTIONS: + action_torrent_info(**kwargs) + + return retval + + +# Creates the popup. mode is the calling mode, tids is a list of torrents to take action upon +def torrent_actions_popup(mode, torrent_ids, details=False, action=None, close_cb=None): + if action is not None: + torrent_action(action, mode=mode, torrent_ids=torrent_ids) + return + + popup = SelectablePopup( + mode, + 'Torrent Actions', + torrent_action, + cb_args={'mode': mode, 'torrent_ids': torrent_ids}, + close_cb=close_cb, + border_off_north=1, + border_off_west=1, + border_off_east=1, + ) + popup.add_line(ACTION.PAUSE, '_Pause') + popup.add_line(ACTION.RESUME, '_Resume') + if details: + popup.add_divider() + popup.add_line(ACTION.QUEUE, 'Queue') + popup.add_divider() + popup.add_line(ACTION.REANNOUNCE, '_Update Tracker') + popup.add_divider() + popup.add_line(ACTION.REMOVE, 'Remo_ve Torrent') + popup.add_line(ACTION.RECHECK, '_Force Recheck') + popup.add_line(ACTION.MOVE_STORAGE, '_Move Download Folder') + popup.add_divider() + if details: + popup.add_line(ACTION.DETAILS, 'Torrent _Details') + popup.add_line(ACTION.TORRENT_OPTIONS, 'Torrent _Options') + mode.push_popup(popup) diff --git a/deluge/ui/console/modes/torrentlist/torrentlist.py b/deluge/ui/console/modes/torrentlist/torrentlist.py new file mode 100644 index 0000000..d3c32ec --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/torrentlist.py @@ -0,0 +1,347 @@ +# +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +import logging +from collections import deque + +import deluge.component as component +from deluge.component import Component +from deluge.decorators import overrides +from deluge.ui.client import client +from deluge.ui.console.modes.basemode import BaseMode, mkwin +from deluge.ui.console.modes.torrentlist import torrentview, torrentviewcolumns +from deluge.ui.console.modes.torrentlist.add_torrents_popup import ( + show_torrent_add_popup, +) +from deluge.ui.console.modes.torrentlist.filtersidebar import FilterSidebar +from deluge.ui.console.modes.torrentlist.queue_mode import QueueMode +from deluge.ui.console.modes.torrentlist.search_mode import SearchMode +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.widgets.popup import MessagePopup, PopupsHandler + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) + + +# Big help string that gets displayed when the user hits 'h' +HELP_STR = """ +This screen shows an overview of the current torrents Deluge is managing. \ +The currently selected torrent is indicated with a white background. \ +You can change the selected torrent using the up/down arrows or the \ +PgUp/PgDown keys. Home and End keys go to the first and last torrent \ +respectively. + +Operations can be performed on multiple torrents by marking them and \ +then hitting Enter. See below for the keys used to mark torrents. + +You can scroll a popup window that doesn't fit its content (like \ +this one) using the up/down arrows, PgUp/PgDown and Home/End keys. + +All popup windows can be closed/canceled by hitting the Esc key \ +or the 'q' key (does not work for dialogs like the add torrent dialog) + +The actions you can perform and the keys to perform them are as follows: + +{!info!}'h'{!normal!} - {|indent_pos:|}Show this help +{!info!}'p'{!normal!} - {|indent_pos:|}Open preferences +{!info!}'l'{!normal!} - {|indent_pos:|}Enter Command Line mode +{!info!}'e'{!normal!} - {|indent_pos:|}Show the event log view ({!info!}'q'{!normal!} to go back to overview) + +{!info!}'a'{!normal!} - {|indent_pos:|}Add a torrent +{!info!}Delete{!normal!} - {|indent_pos:|}Delete a torrent + +{!info!}'/'{!normal!} - {|indent_pos:|}Search torrent names. \ +Searching starts immediately - matching torrents are highlighted in \ +green, you can cycle through them with Up/Down arrows and Home/End keys \ +You can view torrent details with right arrow, open action popup with \ +Enter key and exit search mode with '/' key, left arrow or \ +backspace with empty search field + +{!info!}'f'{!normal!} - {|indent_pos:|}Show only torrents in a certain state + (Will open a popup where you can select the state you want to see) +{!info!}'q'{!normal!} - {|indent_pos:|}Enter queue mode + +{!info!}'S'{!normal!} - {|indent_pos:|}Show or hide the sidebar + +{!info!}Enter{!normal!} - {|indent_pos:|}Show torrent actions popup. Here you can do things like \ +pause/resume, remove, recheck and so on. These actions \ +apply to all currently marked torrents. The currently \ +selected torrent is automatically marked when you press enter. + +{!info!}'o'{!normal!} - {|indent_pos:|}Show and set torrent options - this will either apply \ +to all selected torrents(but not the highlighted one) or currently \ +selected torrent if nothing is selected + +{!info!}'Q'{!normal!} - {|indent_pos:|}quit deluge-console +{!info!}'C'{!normal!} - {|indent_pos:|}show connection manager + +{!info!}'m'{!normal!} - {|indent_pos:|}Mark a torrent +{!info!}'M'{!normal!} - {|indent_pos:|}Mark all torrents between currently selected torrent and last marked torrent +{!info!}'c'{!normal!} - {|indent_pos:|}Clear selection + +{!info!}'v'{!normal!} - {|indent_pos:|}Show a dialog which allows you to choose columns to display +{!info!}'<' / '>'{!normal!} - {|indent_pos:|}Change column by which to sort torrents + +{!info!}Right Arrow{!normal!} - {|indent_pos:|}Torrent Detail Mode. This includes more detailed information \ +about the currently selected torrent, as well as a view of the \ +files in the torrent and the ability to set file priorities. + +{!info!}'q'/Esc{!normal!} - {|indent_pos:|}Close a popup (Note that 'q' does not work for dialogs \ +where you input something +""" + + +class TorrentList(BaseMode, PopupsHandler): + def __init__(self, stdscr, encoding=None): + BaseMode.__init__( + self, stdscr, encoding=encoding, do_refresh=False, depend=['SessionProxy'] + ) + PopupsHandler.__init__(self) + self.messages = deque() + self.last_mark = -1 + self.go_top = False + self.minor_mode = None + + self.consoleui = component.get('ConsoleUI') + self.coreconfig = self.consoleui.coreconfig + self.config = self.consoleui.config + self.sidebar = FilterSidebar(self, self.config) + self.torrentview_panel = mkwin( + curses.COLOR_GREEN, + curses.LINES - 1, + curses.COLS - self.sidebar.width, + 0, + self.sidebar.width, + ) + self.torrentview = torrentview.TorrentView(self, self.config) + + util.safe_curs_set(util.Curser.INVISIBLE) + self.stdscr.notimeout(0) + + def torrentview_columns(self): + return self.torrentview_panel.getmaxyx()[1] + + def on_config_changed(self): + self.config.save() + self.torrentview.on_config_changed() + + def toggle_sidebar(self): + if self.config['torrentview']['show_sidebar']: + self.sidebar.show() + self.sidebar.resize_window(curses.LINES - 2, self.sidebar.width) + self.torrentview_panel.resize( + curses.LINES - 1, curses.COLS - self.sidebar.width + ) + self.torrentview_panel.mvwin(0, self.sidebar.width) + else: + self.sidebar.hide() + self.torrentview_panel.resize(curses.LINES - 1, curses.COLS) + self.torrentview_panel.mvwin(0, 0) + self.torrentview.update_columns() + # After updating the columns widths, clear row cache to recreate them + self.torrentview.cached_rows.clear() + self.refresh() + + @overrides(Component) + def start(self): + self.torrentview.on_config_changed() + self.toggle_sidebar() + + if self.config['first_run']: + self.push_popup( + MessagePopup(self, 'Welcome to Deluge', HELP_STR, width_req=0.65) + ) + self.config['first_run'] = False + self.config.save() + + if client.connected(): + self.torrentview.update(refresh=False) + + @overrides(Component) + def update(self): + if self.mode_paused(): + return + + if client.connected(): + self.torrentview.update(refresh=True) + + @overrides(BaseMode) + def resume(self): + super().resume() + + @overrides(BaseMode) + def on_resize(self, rows, cols): + BaseMode.on_resize(self, rows, cols) + + if self.popup: + self.popup.handle_resize() + + if not self.consoleui.is_active_mode(self): + return + + self.toggle_sidebar() + + def show_torrent_details(self, tid): + mode = self.consoleui.set_mode('TorrentDetail') + mode.update(tid) + + def set_minor_mode(self, mode): + self.minor_mode = mode + self.refresh() + + def _show_visible_columns_popup(self): + self.push_popup(torrentviewcolumns.TorrentViewColumns(self)) + + @overrides(BaseMode) + def refresh(self, lines=None): + # Something has requested we scroll to the top of the list + if self.go_top: + self.torrentview.cursel = 0 + self.torrentview.curoff = 0 + self.go_top = False + + if not lines: + if not self.consoleui.is_active_mode(self): + return + self.stdscr.erase() + + self.add_string(1, self.torrentview.column_string, scr=self.torrentview_panel) + + # Update the status bars + statusbar_args = {'scr': self.stdscr, 'bottombar_help': True} + if self.torrentview.curr_filter is not None: + statusbar_args[ + 'topbar' + ] = '{} {{!filterstatus!}}Current filter: {}'.format( + self.statusbars.topbar, + self.torrentview.curr_filter, + ) + + if self.minor_mode: + self.minor_mode.set_statusbar_args(statusbar_args) + + self.draw_statusbars(**statusbar_args) + + self.torrentview.update_torrents(lines) + + if self.minor_mode: + self.minor_mode.update_cursor() + else: + util.safe_curs_set(util.Curser.INVISIBLE) + + if not self.consoleui.is_active_mode(self): + return + + self.stdscr.noutrefresh() + self.torrentview_panel.noutrefresh() + + if not self.sidebar.hidden(): + self.sidebar.refresh() + + if self.popup: + self.popup.refresh() + + curses.doupdate() + + @overrides(BaseMode) + def read_input(self): + # Read the character + affected_lines = None + c = self.stdscr.getch() + + # Either ESC or ALT+<some key> + if c == util.KEY_ESC: + n = self.stdscr.getch() + if n == -1: # Means it was the escape key + pass + else: # ALT+<some key> + c = [c, n] + + if self.popup: + ret = self.popup.handle_read(c) + if self.popup and self.popup.closed(): + self.pop_popup() + self.refresh() + return ret + if util.is_printable_chr(c): + if chr(c) == 'Q': + component.get('ConsoleUI').quit() + elif chr(c) == 'C': + self.consoleui.set_mode('ConnectionManager') + return + elif chr(c) == 'q': + self.torrentview.update_marked(self.torrentview.cursel) + self.set_minor_mode( + QueueMode(self, self.torrentview._selected_torrent_ids()) + ) + return + elif chr(c) == '/': + self.set_minor_mode(SearchMode(self)) + return + + if self.sidebar.has_focus() and c not in [curses.KEY_RIGHT]: + self.sidebar.handle_read(c) + self.refresh() + return + + if self.torrentview.numtorrents < 0: + return + elif self.minor_mode: + self.minor_mode.handle_read(c) + return + + affected_lines = None + # Hand off to torrentview + if self.torrentview.handle_read(c) == util.ReadState.CHANGED: + affected_lines = self.torrentview.get_input_result() + + if c == curses.KEY_LEFT: + if not self.sidebar.has_focus(): + self.sidebar.set_focused(True) + self.refresh() + return + elif c == curses.KEY_RIGHT: + if self.sidebar.has_focus(): + self.sidebar.set_focused(False) + self.refresh() + return + # We enter a new mode for the selected torrent here + tid = self.torrentview.current_torrent_id() + if tid: + self.show_torrent_details(tid) + return + + elif util.is_printable_chr(c): + if chr(c) == 'a': + show_torrent_add_popup(self) + elif chr(c) == 'v': + self._show_visible_columns_popup() + elif chr(c) == 'h': + self.push_popup(MessagePopup(self, 'Help', HELP_STR, width_req=0.65)) + elif chr(c) == 'p': + mode = self.consoleui.set_mode('Preferences') + mode.load_config() + return + elif chr(c) == 'e': + self.consoleui.set_mode('EventView') + return + elif chr(c) == 'S': + self.config['torrentview']['show_sidebar'] = ( + self.config['torrentview']['show_sidebar'] is False + ) + self.config.save() + self.toggle_sidebar() + elif chr(c) == 'l': + self.consoleui.set_mode('CmdLine', refresh=True) + return + + self.refresh(affected_lines) diff --git a/deluge/ui/console/modes/torrentlist/torrentview.py b/deluge/ui/console/modes/torrentlist/torrentview.py new file mode 100644 index 0000000..1ce5097 --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/torrentview.py @@ -0,0 +1,514 @@ +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +import logging + +import deluge.component as component +from deluge.decorators import overrides +from deluge.ui.console.modes.basemode import InputKeyHandler +from deluge.ui.console.modes.torrentlist import torrentviewcolumns +from deluge.ui.console.modes.torrentlist.torrentactions import torrent_actions_popup +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.utils import format_utils +from deluge.ui.console.utils.column import ( + get_column_value, + get_required_fields, + torrent_data_fields, +) + +from . import ACTION + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) + + +state_fg_colors = { + 'Downloading': 'green', + 'Seeding': 'cyan', + 'Error': 'red', + 'Queued': 'yellow', + 'Checking': 'blue', + 'Moving': 'green', +} + + +reverse_sort_fields = [ + 'size', + 'download_speed', + 'upload_speed', + 'num_seeds', + 'num_peers', + 'distributed_copies', + 'time_added', + 'total_uploaded', + 'all_time_download', + 'total_remaining', + 'progress', + 'ratio', + 'seeding_time', + 'active_time', +] + + +default_column_values = { + 'queue': {'width': 4, 'visible': True}, + 'name': {'width': -1, 'visible': True}, + 'size': {'width': 8, 'visible': True}, + 'progress': {'width': 7, 'visible': True}, + 'download_speed': {'width': 7, 'visible': True}, + 'upload_speed': {'width': 7, 'visible': True}, + 'state': {'width': 13}, + 'eta': {'width': 8, 'visible': True}, + 'time_added': {'width': 15}, + 'tracker': {'width': 15}, + 'download_location': {'width': 15}, + 'downloaded': {'width': 13}, + 'uploaded': {'width': 7}, + 'remaining': {'width': 13}, + 'completed_time': {'width': 15}, + 'last_seen_complete': {'width': 15}, + 'max_upload_speed': {'width': 7}, +} + + +default_columns = {} +for col_i, col_name in enumerate(torrentviewcolumns.column_pref_names): + default_columns[col_name] = {'width': 10, 'order': col_i, 'visible': False} + if col_name in default_column_values: + default_columns[col_name].update(default_column_values[col_name]) + + +class TorrentView(InputKeyHandler): + def __init__(self, torrentlist, config): + super().__init__() + self.torrentlist = torrentlist + self.config = config + self.filter_dict = {} + self.curr_filter = None + self.cached_rows = {} + self.sorted_ids = None + self.torrent_names = None + self.numtorrents = -1 + self.column_string = '' + self.curoff = 0 + self.marked = [] + self.cursel = 0 + + @property + def rows(self): + return self.torrentlist.rows + + @property + def torrent_rows(self): + return self.torrentlist.rows - 3 # Account for header lines + columns line + + @property + def torrentlist_offset(self): + return 2 + + def update_state(self, state, refresh=False): + self.curstate = state # cache in case we change sort order + self.cached_rows.clear() + self.numtorrents = len(state) + self.sorted_ids = self._sort_torrents(state) + self.torrent_names = [] + for torrent_id in self.sorted_ids: + ts = self.curstate[torrent_id] + self.torrent_names.append(ts['name']) + + if refresh: + self.torrentlist.refresh() + + def set_torrent_filter(self, state): + self.curr_filter = state + filter_dict = {'state': [state]} + if state == 'All': + self.curr_filter = None + filter_dict = {} + self.filter_dict = filter_dict + self.torrentlist.go_top = True + self.torrentlist.update() + return True + + def _scroll_up(self, by): + cursel = self.cursel + prevoff = self.curoff + self.cursel = max(self.cursel - by, 0) + if self.cursel < self.curoff: + self.curoff = self.cursel + affected = [] + if prevoff == self.curoff: + affected.append(cursel) + if cursel != self.cursel: + affected.insert(0, self.cursel) + return affected + + def _scroll_down(self, by): + cursel = self.cursel + prevoff = self.curoff + self.cursel = min(self.cursel + by, self.numtorrents - 1) + if (self.curoff + self.torrent_rows) <= self.cursel: + self.curoff = self.cursel - self.torrent_rows + 1 + affected = [] + if prevoff == self.curoff: + affected.append(cursel) + if cursel != self.cursel: + affected.append(self.cursel) + return affected + + def current_torrent_id(self): + if not self.sorted_ids: + return None + return self.sorted_ids[self.cursel] + + def _selected_torrent_ids(self): + if not self.sorted_ids: + return None + ret = [] + for i in self.marked: + ret.append(self.sorted_ids[i]) + return ret + + def clear_marked(self): + self.marked = [] + self.last_mark = -1 + + def mark_unmark(self, idx): + if idx in self.marked: + self.marked.remove(idx) + self.last_mark = -1 + else: + self.marked.append(idx) + self.last_mark = idx + + def add_marked(self, indices, last_marked): + for i in indices: + if i not in self.marked: + self.marked.append(i) + self.last_mark = last_marked + + def update_marked(self, index, last_mark=True, clear=False): + if index not in self.marked: + if clear: + self.marked = [] + self.marked.append(index) + if last_mark: + self.last_mark = index + return True + return False + + def _sort_torrents(self, state): + """Sorts by primary and secondary sort fields.""" + + if not state: + return {} + + s_primary = self.config['torrentview']['sort_primary'] + s_secondary = self.config['torrentview']['sort_secondary'] + + result = state + + # Sort first by secondary sort field and then primary sort field + # so it all works out + + def sort_by_field(state, to_sort, field): + field = torrent_data_fields[field]['status'][0] + reverse = field in reverse_sort_fields + + # Get first element so we can check if it has given field + # and if it's a string + first_element = state[list(state)[0]] + if field in first_element: + + def sort_key(s): + try: + # Sort case-insensitively but preserve A>a order. + return state.get(s)[field].lower() + except AttributeError: + # Not a string. + return state.get(s)[field] + + to_sort = sorted(to_sort, key=sort_key, reverse=reverse) + + if field == 'eta': + to_sort = sorted(to_sort, key=lambda s: state.get(s)['eta'] == 0) + + return to_sort + + # Just in case primary and secondary fields are empty and/or + # both are too ambiguous, also sort by queue position first + if 'queue' not in [s_secondary, s_primary]: + result = sort_by_field(state, result, 'queue') + if s_secondary != s_primary: + result = sort_by_field(state, result, s_secondary) + result = sort_by_field(state, result, s_primary) + + if self.config['torrentview']['separate_complete']: + result = sorted( + result, key=lambda s: state.get(s).get('progress', 0) == 100.0 + ) + + return result + + def _get_colors(self, row, tidx): + # default style + colors = {'fg': 'white', 'bg': 'black', 'attr': None} + + if tidx in self.marked: + colors.update({'bg': 'blue', 'attr': 'bold'}) + + if tidx == self.cursel: + col_selected = {'bg': 'white', 'fg': 'black', 'attr': 'bold'} + if tidx in self.marked: + col_selected['fg'] = 'blue' + colors.update(col_selected) + + colors['fg'] = state_fg_colors.get(row[1], colors['fg']) + + if self.torrentlist.minor_mode: + self.torrentlist.minor_mode.update_colors(tidx, colors) + return colors + + def update_torrents(self, lines): + # add all the torrents + if self.numtorrents == 0: + cols = self.torrentlist.torrentview_columns() + msg = 'No torrents match filter'.center(cols) + self.torrentlist.add_string( + 3, '{!info!}%s' % msg, scr=self.torrentlist.torrentview_panel + ) + elif self.numtorrents == 0: + self.torrentlist.add_string(1, 'Waiting for torrents from core...') + return + + def draw_row(index): + if index not in self.cached_rows: + ts = self.curstate[self.sorted_ids[index]] + self.cached_rows[index] = ( + format_utils.format_row( + [get_column_value(name, ts) for name in self.cols_to_show], + self.column_widths, + ), + ts['state'], + ) + return self.cached_rows[index] + + tidx = self.curoff + currow = 0 + todraw = [] + # Affected lines are given when changing selected torrent + if lines: + for line in lines: + if line < tidx: + continue + if line >= (tidx + self.torrent_rows) or line >= self.numtorrents: + break + todraw.append((line, line - self.curoff, draw_row(line))) + else: + for i in range(tidx, tidx + self.torrent_rows): + if i >= self.numtorrents: + break + todraw.append((i, i - self.curoff, draw_row(i))) + + for tidx, currow, row in todraw: + if (currow + self.torrentlist_offset - 1) > self.torrent_rows: + continue + colors = self._get_colors(row, tidx) + if colors['attr']: + colorstr = '{!%(fg)s,%(bg)s,%(attr)s!}' % colors + else: + colorstr = '{!%(fg)s,%(bg)s!}' % colors + + self.torrentlist.add_string( + currow + self.torrentlist_offset, + f'{colorstr}{row[0]}', + trim=False, + scr=self.torrentlist.torrentview_panel, + ) + + def update(self, refresh=False): + d = component.get('SessionProxy').get_torrents_status( + self.filter_dict, self.status_fields + ) + d.addCallback(self.update_state, refresh=refresh) + + def on_config_changed(self): + s_primary = self.config['torrentview']['sort_primary'] + s_secondary = self.config['torrentview']['sort_secondary'] + changed = None + for col in default_columns: + if col not in self.config['torrentview']['columns']: + changed = self.config['torrentview']['columns'][col] = default_columns[ + col + ] + if changed: + self.config.save() + + self.cols_to_show = [ + col + for col in sorted( + self.config['torrentview']['columns'], + key=lambda k: self.config['torrentview']['columns'][k]['order'], + ) + if self.config['torrentview']['columns'][col]['visible'] + ] + self.status_fields = get_required_fields(self.cols_to_show) + + # we always need these, even if we're not displaying them + for rf in ['state', 'name', 'queue', 'progress']: + if rf not in self.status_fields: + self.status_fields.append(rf) + + # same with sort keys + if s_primary and s_primary not in self.status_fields: + self.status_fields.append(s_primary) + if s_secondary and s_secondary not in self.status_fields: + self.status_fields.append(s_secondary) + + self.update_columns() + + def update_columns(self): + self.column_widths = [ + self.config['torrentview']['columns'][col]['width'] + for col in self.cols_to_show + ] + requested_width = sum(width for width in self.column_widths if width >= 0) + + cols = self.torrentlist.torrentview_columns() + if requested_width > cols: # can't satisfy requests, just spread out evenly + cw = int(cols / len(self.cols_to_show)) + for i in range(0, len(self.column_widths)): + self.column_widths[i] = cw + else: + rem = cols - requested_width + var_cols = len([width for width in self.column_widths if width < 0]) + if var_cols > 0: + vw = int(rem / var_cols) + for i in range(0, len(self.column_widths)): + if self.column_widths[i] < 0: + self.column_widths[i] = vw + + self.column_string = '{!header!}' + + primary_sort_col_name = self.config['torrentview']['sort_primary'] + + for i, column in enumerate(self.cols_to_show): + ccol = torrent_data_fields[column]['name'] + width = self.column_widths[i] + + # Trim the column if it's too long to fit + if len(ccol) > width: + ccol = ccol[: width - 1] + + # Padding + ccol += ' ' * (width - len(ccol)) + + # Highlight the primary sort column + if column == primary_sort_col_name: + if i != len(self.cols_to_show) - 1: + ccol = '{!black,green,bold!}%s{!header!}' % ccol + else: + ccol = ('{!black,green,bold!}%s' % ccol)[:-1] + + self.column_string += ccol + + @overrides(InputKeyHandler) + def handle_read(self, c): + affected_lines = None + if c == curses.KEY_UP: + if self.cursel != 0: + affected_lines = self._scroll_up(1) + elif c == curses.KEY_PPAGE: + affected_lines = self._scroll_up(int(self.torrent_rows / 2)) + elif c == curses.KEY_DOWN: + if self.cursel < self.numtorrents: + affected_lines = self._scroll_down(1) + elif c == curses.KEY_NPAGE: + affected_lines = self._scroll_down(int(self.torrent_rows / 2)) + elif c == curses.KEY_HOME: + affected_lines = self._scroll_up(self.cursel) + elif c == curses.KEY_END: + affected_lines = self._scroll_down(self.numtorrents - self.cursel) + elif c == curses.KEY_DC: # DEL + added = self.update_marked(self.cursel) + + def on_close(**kwargs): + if added: + self.marked.pop() + + torrent_actions_popup( + self.torrentlist, + self._selected_torrent_ids(), + action=ACTION.REMOVE, + close_cb=on_close, + ) + elif c in [curses.KEY_ENTER, util.KEY_ENTER2] and self.numtorrents: + added = self.update_marked(self.cursel) + + def on_close(data, **kwargs): + if added: + self.marked.remove(self.cursel) + + torrent_actions_popup( + self.torrentlist, + self._selected_torrent_ids(), + details=True, + close_cb=on_close, + ) + self.torrentlist.refresh() + elif c == ord('j'): + affected_lines = self._scroll_down(1) + elif c == ord('k'): + affected_lines = self._scroll_up(1) + elif c == ord('m'): + self.mark_unmark(self.cursel) + affected_lines = [self.cursel] + elif c == ord('M'): + if self.last_mark >= 0: + if self.cursel > self.last_mark: + mrange = list(range(self.last_mark, self.cursel + 1)) + else: + mrange = list(range(self.cursel, self.last_mark)) + self.add_marked(mrange, self.cursel) + affected_lines = mrange + else: + self.mark_unmark(self.cursel) + affected_lines = [self.cursel] + elif c == ord('c'): + self.clear_marked() + elif c == ord('o'): + if not self.marked: + added = self.update_marked(self.cursel, clear=True) + else: + self.last_mark = -1 + torrent_actions_popup( + self.torrentlist, + self._selected_torrent_ids(), + action=ACTION.TORRENT_OPTIONS, + ) + elif c in [ord('>'), ord('<')]: + try: + i = self.cols_to_show.index(self.config['torrentview']['sort_primary']) + except ValueError: + i = 0 if chr(c) == '<' else len(self.cols_to_show) + else: + i += 1 if chr(c) == '>' else -1 + + i = max(0, min(len(self.cols_to_show) - 1, i)) + self.config['torrentview']['sort_primary'] = self.cols_to_show[i] + self.config.save() + self.on_config_changed() + self.update_columns() + self.torrentlist.refresh([]) + else: + return util.ReadState.IGNORED + + self.set_input_result(affected_lines) + return util.ReadState.CHANGED if affected_lines else util.ReadState.READ diff --git a/deluge/ui/console/modes/torrentlist/torrentviewcolumns.py b/deluge/ui/console/modes/torrentlist/torrentviewcolumns.py new file mode 100644 index 0000000..586a569 --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/torrentviewcolumns.py @@ -0,0 +1,159 @@ +# +# Copyright (C) 2016 bendikro <bro.devel+deluge@gmail.com> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +from deluge.decorators import overrides +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.utils.column import torrent_data_fields +from deluge.ui.console.widgets.fields import CheckedPlusInput, IntSpinInput +from deluge.ui.console.widgets.popup import InputPopup, MessagePopup + +COLUMN_VIEW_HELP_STR = """ +Control column visibilty with the following actions: + +{!info!}'+'{!normal!} - {|indent_pos:|}Increase column width +{!info!}'-'{!normal!} - {|indent_pos:|}Decrease column width + +{!info!}'CTRL+up'{!normal!} - {|indent_pos:|} Move column left +{!info!}'CTRL+down'{!normal!} - {|indent_pos:|} Move column right +""" + +column_pref_names = [ + 'queue', + 'name', + 'size', + 'downloaded', + 'uploaded', + 'remaining', + 'state', + 'progress', + 'seeds', + 'peers', + 'seeds_peers_ratio', + 'download_speed', + 'upload_speed', + 'max_download_speed', + 'max_upload_speed', + 'eta', + 'ratio', + 'avail', + 'time_added', + 'completed_time', + 'last_seen_complete', + 'tracker', + 'download_location', + 'active_time', + 'seeding_time', + 'finished_time', + 'time_since_transfer', + 'shared', + 'owner', +] + + +class ColumnAndWidth(CheckedPlusInput): + def __init__(self, parent, name, message, child, on_width_func, **kwargs): + CheckedPlusInput.__init__(self, parent, name, message, child, **kwargs) + self.on_width_func = on_width_func + + @overrides(CheckedPlusInput) + def handle_read(self, c): + if c in [ord('+'), ord('-')]: + val = self.child.get_value() + change = 1 if chr(c) == '+' else -1 + self.child.set_value(val + change, validate=True) + self.on_width_func(self.name, self.child.get_value()) + return util.ReadState.CHANGED + return CheckedPlusInput.handle_read(self, c) + + +class TorrentViewColumns(InputPopup): + def __init__(self, torrentlist): + self.torrentlist = torrentlist + self.torrentview = torrentlist.torrentview + + title = 'Visible columns (Esc to exit)' + InputPopup.__init__( + self, + torrentlist, + title, + close_cb=self._do_set_column_visibility, + immediate_action=True, + height_req=len(column_pref_names) - 5, + width_req=max(len(col) for col in column_pref_names + [title]) + 14, + border_off_west=1, + allow_rearrange=True, + ) + + msg_fmt = '%-25s' + self.add_header((msg_fmt % _('Columns')) + ' ' + _('Width'), space_below=True) + + for colpref_name in column_pref_names: + col = self.torrentview.config['torrentview']['columns'][colpref_name] + width_spin = IntSpinInput( + self, + colpref_name + '_ width', + '', + self.move, + col['width'], + min_val=-1, + max_val=99, + fmt='%2d', + ) + + def on_width_func(name, width): + self.torrentview.config['torrentview']['columns'][name]['width'] = width + + self._add_input( + ColumnAndWidth( + self, + colpref_name, + torrent_data_fields[colpref_name]['name'], + width_spin, + on_width_func, + checked=col['visible'], + checked_char='*', + msg_fmt=msg_fmt, + show_usage_hints=False, + child_always_visible=True, + ) + ) + + def _do_set_column_visibility( + self, data=None, state_changed=True, close=True, **kwargs + ): + if close: + self.torrentlist.pop_popup() + return + elif not state_changed: + return + + for key, value in data.items(): + self.torrentview.config['torrentview']['columns'][key]['visible'] = value[ + 'value' + ] + self.torrentview.config['torrentview']['columns'][key]['order'] = value[ + 'order' + ] + + self.torrentview.config.save() + self.torrentview.on_config_changed() + self.torrentlist.refresh([]) + + @overrides(InputPopup) + def handle_read(self, c): + if c == ord('h'): + popup = MessagePopup( + self.torrentlist, + 'Help', + COLUMN_VIEW_HELP_STR, + width_req=70, + border_off_west=1, + ) + self.torrentlist.push_popup(popup) + return util.ReadState.READ + return InputPopup.handle_read(self, c) |