From 2e2851dc13d73352530dd4495c7e05603b2e520d Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 10 Apr 2024 23:38:38 +0200 Subject: Adding upstream version 2.1.2~dev0+20240219. Signed-off-by: Daniel Baumann --- deluge/ui/console/__init__.py | 24 + deluge/ui/console/cmdline/__init__.py | 0 deluge/ui/console/cmdline/command.py | 211 ++++ deluge/ui/console/cmdline/commands/__init__.py | 3 + deluge/ui/console/cmdline/commands/add.py | 117 ++ deluge/ui/console/cmdline/commands/cache.py | 28 + deluge/ui/console/cmdline/commands/config.py | 136 +++ deluge/ui/console/cmdline/commands/connect.py | 78 ++ deluge/ui/console/cmdline/commands/debug.py | 37 + deluge/ui/console/cmdline/commands/gui.py | 27 + deluge/ui/console/cmdline/commands/halt.py | 32 + deluge/ui/console/cmdline/commands/help.py | 71 ++ deluge/ui/console/cmdline/commands/info.py | 488 ++++++++ deluge/ui/console/cmdline/commands/manage.py | 114 ++ deluge/ui/console/cmdline/commands/move.py | 94 ++ deluge/ui/console/cmdline/commands/pause.py | 45 + deluge/ui/console/cmdline/commands/plugin.py | 140 +++ deluge/ui/console/cmdline/commands/quit.py | 22 + deluge/ui/console/cmdline/commands/recheck.py | 44 + deluge/ui/console/cmdline/commands/resume.py | 45 + deluge/ui/console/cmdline/commands/rm.py | 82 ++ deluge/ui/console/cmdline/commands/status.py | 116 ++ .../ui/console/cmdline/commands/update_tracker.py | 44 + deluge/ui/console/console.py | 163 +++ deluge/ui/console/eventlog.py | 125 ++ deluge/ui/console/main.py | 470 ++++++++ deluge/ui/console/modes/__init__.py | 0 deluge/ui/console/modes/add_util.py | 92 ++ deluge/ui/console/modes/addtorrents.py | 536 +++++++++ deluge/ui/console/modes/basemode.py | 360 ++++++ deluge/ui/console/modes/cmdline.py | 845 ++++++++++++++ deluge/ui/console/modes/connectionmanager.py | 211 ++++ deluge/ui/console/modes/eventview.py | 112 ++ deluge/ui/console/modes/preferences/__init__.py | 3 + .../console/modes/preferences/preference_panes.py | 757 ++++++++++++ deluge/ui/console/modes/preferences/preferences.py | 376 ++++++ deluge/ui/console/modes/torrentdetail.py | 1021 +++++++++++++++++ deluge/ui/console/modes/torrentlist/__init__.py | 17 + .../modes/torrentlist/add_torrents_popup.py | 110 ++ .../ui/console/modes/torrentlist/filtersidebar.py | 131 +++ deluge/ui/console/modes/torrentlist/queue_mode.py | 154 +++ deluge/ui/console/modes/torrentlist/search_mode.py | 206 ++++ .../ui/console/modes/torrentlist/torrentactions.py | 272 +++++ deluge/ui/console/modes/torrentlist/torrentlist.py | 347 ++++++ deluge/ui/console/modes/torrentlist/torrentview.py | 514 +++++++++ .../modes/torrentlist/torrentviewcolumns.py | 159 +++ deluge/ui/console/parser.py | 140 +++ deluge/ui/console/utils/__init__.py | 0 deluge/ui/console/utils/colors.py | 323 ++++++ deluge/ui/console/utils/column.py | 74 ++ deluge/ui/console/utils/common.py | 20 + deluge/ui/console/utils/config.py | 118 ++ deluge/ui/console/utils/curses_util.py | 62 + deluge/ui/console/utils/format_utils.py | 350 ++++++ deluge/ui/console/widgets/__init__.py | 5 + deluge/ui/console/widgets/fields.py | 1202 ++++++++++++++++++++ deluge/ui/console/widgets/inputpane.py | 394 +++++++ deluge/ui/console/widgets/popup.py | 398 +++++++ deluge/ui/console/widgets/sidebar.py | 79 ++ deluge/ui/console/widgets/statusbars.py | 124 ++ deluge/ui/console/widgets/window.py | 182 +++ 61 files changed, 12450 insertions(+) create mode 100644 deluge/ui/console/__init__.py create mode 100644 deluge/ui/console/cmdline/__init__.py create mode 100644 deluge/ui/console/cmdline/command.py create mode 100644 deluge/ui/console/cmdline/commands/__init__.py create mode 100644 deluge/ui/console/cmdline/commands/add.py create mode 100644 deluge/ui/console/cmdline/commands/cache.py create mode 100644 deluge/ui/console/cmdline/commands/config.py create mode 100644 deluge/ui/console/cmdline/commands/connect.py create mode 100644 deluge/ui/console/cmdline/commands/debug.py create mode 100644 deluge/ui/console/cmdline/commands/gui.py create mode 100644 deluge/ui/console/cmdline/commands/halt.py create mode 100644 deluge/ui/console/cmdline/commands/help.py create mode 100644 deluge/ui/console/cmdline/commands/info.py create mode 100644 deluge/ui/console/cmdline/commands/manage.py create mode 100644 deluge/ui/console/cmdline/commands/move.py create mode 100644 deluge/ui/console/cmdline/commands/pause.py create mode 100644 deluge/ui/console/cmdline/commands/plugin.py create mode 100644 deluge/ui/console/cmdline/commands/quit.py create mode 100644 deluge/ui/console/cmdline/commands/recheck.py create mode 100644 deluge/ui/console/cmdline/commands/resume.py create mode 100644 deluge/ui/console/cmdline/commands/rm.py create mode 100644 deluge/ui/console/cmdline/commands/status.py create mode 100644 deluge/ui/console/cmdline/commands/update_tracker.py create mode 100644 deluge/ui/console/console.py create mode 100644 deluge/ui/console/eventlog.py create mode 100644 deluge/ui/console/main.py create mode 100644 deluge/ui/console/modes/__init__.py create mode 100644 deluge/ui/console/modes/add_util.py create mode 100644 deluge/ui/console/modes/addtorrents.py create mode 100644 deluge/ui/console/modes/basemode.py create mode 100644 deluge/ui/console/modes/cmdline.py create mode 100644 deluge/ui/console/modes/connectionmanager.py create mode 100644 deluge/ui/console/modes/eventview.py create mode 100644 deluge/ui/console/modes/preferences/__init__.py create mode 100644 deluge/ui/console/modes/preferences/preference_panes.py create mode 100644 deluge/ui/console/modes/preferences/preferences.py create mode 100644 deluge/ui/console/modes/torrentdetail.py create mode 100644 deluge/ui/console/modes/torrentlist/__init__.py create mode 100644 deluge/ui/console/modes/torrentlist/add_torrents_popup.py create mode 100644 deluge/ui/console/modes/torrentlist/filtersidebar.py create mode 100644 deluge/ui/console/modes/torrentlist/queue_mode.py create mode 100644 deluge/ui/console/modes/torrentlist/search_mode.py create mode 100644 deluge/ui/console/modes/torrentlist/torrentactions.py create mode 100644 deluge/ui/console/modes/torrentlist/torrentlist.py create mode 100644 deluge/ui/console/modes/torrentlist/torrentview.py create mode 100644 deluge/ui/console/modes/torrentlist/torrentviewcolumns.py create mode 100644 deluge/ui/console/parser.py create mode 100644 deluge/ui/console/utils/__init__.py create mode 100644 deluge/ui/console/utils/colors.py create mode 100644 deluge/ui/console/utils/column.py create mode 100644 deluge/ui/console/utils/common.py create mode 100644 deluge/ui/console/utils/config.py create mode 100644 deluge/ui/console/utils/curses_util.py create mode 100644 deluge/ui/console/utils/format_utils.py create mode 100644 deluge/ui/console/widgets/__init__.py create mode 100644 deluge/ui/console/widgets/fields.py create mode 100644 deluge/ui/console/widgets/inputpane.py create mode 100644 deluge/ui/console/widgets/popup.py create mode 100644 deluge/ui/console/widgets/sidebar.py create mode 100644 deluge/ui/console/widgets/statusbars.py create mode 100644 deluge/ui/console/widgets/window.py (limited to 'deluge/ui/console') diff --git a/deluge/ui/console/__init__.py b/deluge/ui/console/__init__.py new file mode 100644 index 0000000..a09d936 --- /dev/null +++ b/deluge/ui/console/__init__.py @@ -0,0 +1,24 @@ +# +# Copyright (C) 2008-2009 Ido Abramovich +# +# 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.console.console import Console + +UI_PATH = __path__[0] + + +def start(): + Console().start() + + +def test_start(): + """Entry point for tests + + A workaround for unit tests which require a deferred object to be + returned to run properly due to mocking the Twisted reactor. + """ + return Console().start() diff --git a/deluge/ui/console/cmdline/__init__.py b/deluge/ui/console/cmdline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deluge/ui/console/cmdline/command.py b/deluge/ui/console/cmdline/command.py new file mode 100644 index 0000000..63dc926 --- /dev/null +++ b/deluge/ui/console/cmdline/command.py @@ -0,0 +1,211 @@ +# +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# Copyright (C) 2011 Nick Lanham +# +# 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 shlex + +from twisted.internet import defer + +from deluge.ui.client import client +from deluge.ui.console.parser import OptionParser, OptionParserError +from deluge.ui.console.utils.colors import strip_colors + +log = logging.getLogger(__name__) + + +class Commander: + def __init__(self, cmds, interactive=False): + self._commands = cmds + self.interactive = interactive + + def write(self, line): + print(strip_colors(line)) + + def do_command(self, cmd_line): + """Run a console command. + + Args: + cmd_line (str): Console command. + + Returns: + Deferred: A deferred that fires when the command has been executed. + + """ + options = self.parse_command(cmd_line) + if options: + return self.exec_command(options) + return defer.succeed(None) + + def exit(self, status=0, msg=None): + self._exit = True + if msg: + print(msg) + + def parse_command(self, cmd_line): + """Parse a console command and process with argparse. + + Args: + cmd_line (str): Console command. + + Returns: + argparse.Namespace: The parsed command. + + """ + if not cmd_line: + return + cmd, _, line = cmd_line.partition(' ') + try: + parser = self._commands[cmd].create_parser() + except KeyError: + self.write('{!error!}Unknown command: %s' % cmd) + return + + try: + args = [cmd] + self._commands[cmd].split(line) + except ValueError as ex: + self.write('{!error!}Error parsing command: %s' % ex) + return + + # Do a little hack here to print 'command --help' properly + parser._print_help = parser.print_help + + def print_help(f=None): + if self.interactive: + self.write(parser.format_help()) + else: + parser._print_help(f) + + parser.print_help = print_help + + # Only these commands can be run when not connected to a daemon + not_connected_cmds = ['help', 'connect', 'quit'] + aliases = [] + for c in not_connected_cmds: + aliases.extend(self._commands[c].aliases) + not_connected_cmds.extend(aliases) + + if not client.connected() and cmd not in not_connected_cmds: + self.write( + '{!error!}Not connected to a daemon, please use the connect command first.' + ) + return + + try: + options = parser.parse_args(args=args) + options.command = cmd + except TypeError as ex: + self.write('{!error!}Error parsing options: %s' % ex) + import traceback + + self.write('%s' % traceback.format_exc()) + return + except OptionParserError as ex: + import traceback + + log.warning('Error parsing command "%s": %s', args, ex) + self.write('{!error!} %s' % ex) + parser.print_help() + return + + if getattr(parser, '_exit', False): + return + return options + + def exec_command(self, options, *args): + """Execute a console command. + + Args: + options (argparse.Namespace): The command to execute. + + Returns: + Deferred: A deferred that fires when command has been executed. + + """ + try: + ret = self._commands[options.command].handle(options) + except Exception as ex: # pylint: disable=broad-except + self.write('{!error!} %s' % ex) + log.exception(ex) + import traceback + + self.write('%s' % traceback.format_exc()) + return defer.succeed(True) + else: + return ret + + +class BaseCommand: + usage = None + interactive_only = False + aliases = [] + _name = 'base' + epilog = '' + + def complete(self, text, *args): + return [] + + def handle(self, options): + pass + + @property + def name(self): + return self._name + + @property + def name_with_alias(self): + return '/'.join([self._name] + self.aliases) + + @property + def description(self): + return self.__doc__ + + def split(self, text): + text = text.replace('\\', '\\\\') + result = shlex.split(text) + for i, s in enumerate(result): + result[i] = s.replace(r'\ ', ' ') + result = [s for s in result if s != ''] + return result + + def create_parser(self): + opts = { + 'prog': self.name_with_alias, + 'description': self.__doc__, + 'epilog': self.epilog, + } + if self.usage: + opts['usage'] = self.usage + parser = OptionParser(**opts) + parser.add_argument(self.name, metavar='') + parser.base_parser = parser + self.add_arguments(parser) + return parser + + def add_subparser(self, subparsers): + opts = { + 'prog': self.name_with_alias, + 'help': self.__doc__, + 'description': self.__doc__, + } + if self.usage: + opts['usage'] = self.usage + + # A workaround for aliases showing as duplicate command names in help output. + for cmd_name in sorted([self.name] + self.aliases): + if cmd_name not in subparsers._name_parser_map: + if cmd_name in self.aliases: + opts['help'] = _('`%s` alias' % self.name) + parser = subparsers.add_parser(cmd_name, **opts) + break + + self.add_arguments(parser) + + def add_arguments(self, parser): + pass diff --git a/deluge/ui/console/cmdline/commands/__init__.py b/deluge/ui/console/cmdline/commands/__init__.py new file mode 100644 index 0000000..39dbefe --- /dev/null +++ b/deluge/ui/console/cmdline/commands/__init__.py @@ -0,0 +1,3 @@ +from deluge.ui.console.cmdline.command import BaseCommand + +__all__ = ['BaseCommand'] diff --git a/deluge/ui/console/cmdline/commands/add.py b/deluge/ui/console/cmdline/commands/add.py new file mode 100644 index 0000000..706ae16 --- /dev/null +++ b/deluge/ui/console/cmdline/commands/add.py @@ -0,0 +1,117 @@ +# +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# 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 os +from base64 import b64encode +from urllib.parse import urlparse +from urllib.request import url2pathname + +from twisted.internet import defer + +import deluge.common +import deluge.component as component +from deluge.ui.client import client + +from . import BaseCommand + + +class Command(BaseCommand): + """Add torrents""" + + def add_arguments(self, parser): + parser.add_argument( + '-p', '--path', dest='path', help=_('Download folder for torrent') + ) + parser.add_argument( + '-m', + '--move-path', + dest='move_completed_path', + help=_('Move the completed torrent to this folder'), + ) + parser.add_argument( + 'torrents', + metavar='', + nargs='+', + help=_('One or more torrent files, URLs or magnet URIs'), + ) + + def handle(self, options): + self.console = component.get('ConsoleUI') + + t_options = {} + if options.path: + t_options['download_location'] = os.path.abspath( + os.path.expanduser(options.path) + ) + + if options.move_completed_path: + t_options['move_completed'] = True + t_options['move_completed_path'] = os.path.abspath( + os.path.expanduser(options.move_completed_path) + ) + + def on_success(result): + if not result: + self.console.write('{!error!}Torrent was not added: Already in session') + else: + self.console.write('{!success!}Torrent added!') + + def on_fail(result): + self.console.write('{!error!}Torrent was not added: %s' % result) + + # Keep a list of deferreds to make a DeferredList + deferreds = [] + for torrent in options.torrents: + if not torrent.strip(): + continue + if deluge.common.is_url(torrent): + self.console.write( + '{!info!}Attempting to add torrent from URL: %s' % torrent + ) + deferreds.append( + client.core.add_torrent_url(torrent, t_options) + .addCallback(on_success) + .addErrback(on_fail) + ) + elif deluge.common.is_magnet(torrent): + self.console.write( + '{!info!}Attempting to add torrent from magnet URI: %s' % torrent + ) + deferreds.append( + client.core.add_torrent_magnet(torrent, t_options) + .addCallback(on_success) + .addErrback(on_fail) + ) + else: + # Just a file + if urlparse(torrent).scheme == 'file': + torrent = url2pathname(urlparse(torrent).path) + path = os.path.abspath(os.path.expanduser(torrent)) + if not os.path.exists(path): + self.console.write('{!error!}%s does not exist!' % path) + continue + if not os.path.isfile(path): + self.console.write('{!error!}This is a directory!') + continue + self.console.write('{!info!}Attempting to add torrent: %s' % path) + filename = os.path.split(path)[-1] + with open(path, 'rb') as _file: + filedump = b64encode(_file.read()) + deferreds.append( + client.core.add_torrent_file_async(filename, filedump, t_options) + .addCallback(on_success) + .addErrback(on_fail) + ) + + return defer.DeferredList(deferreds) + + def complete(self, line): + return component.get('ConsoleUI').tab_complete_path( + line, ext='.torrent', sort='date' + ) diff --git a/deluge/ui/console/cmdline/commands/cache.py b/deluge/ui/console/cmdline/commands/cache.py new file mode 100644 index 0000000..fe6cd58 --- /dev/null +++ b/deluge/ui/console/cmdline/commands/cache.py @@ -0,0 +1,28 @@ +# +# Copyright (C) 2009 Andrew Resch +# +# 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 deluge.component as component +from deluge.ui.client import client +from deluge.ui.common import DISK_CACHE_KEYS + +from . import BaseCommand + + +class Command(BaseCommand): + """Show information about the disk cache""" + + def handle(self, options): + self.console = component.get('ConsoleUI') + + def on_cache_status(status): + for key, value in sorted(status.items()): + self.console.write(f'{{!info!}}{key}: {{!input!}}{value}') + + return client.core.get_session_status(DISK_CACHE_KEYS).addCallback( + on_cache_status + ) diff --git a/deluge/ui/console/cmdline/commands/config.py b/deluge/ui/console/cmdline/commands/config.py new file mode 100644 index 0000000..8b31ca3 --- /dev/null +++ b/deluge/ui/console/cmdline/commands/config.py @@ -0,0 +1,136 @@ +# +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# 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 json +import logging +import re + +import deluge.component as component +import deluge.ui.console.utils.colors as colors +from deluge.ui.client import client + +from . import BaseCommand + +log = logging.getLogger(__name__) + + +def json_eval(source): + """Evaluates string as json data and returns Python objects.""" + if source == '': + return source + + src = source.splitlines()[0] + + # Substitutions to enable usage of pythonic syntax. + if src.startswith('(') and src.endswith(')'): + src = re.sub(r'^\((.*)\)$', r'[\1]', src) + elif src.lower() in ('true', 'false'): + src = src.lower() + elif src.lower() == 'none': + src = 'null' + + try: + return json.loads(src) + except ValueError: + return src + + +class Command(BaseCommand): + """Show and set configuration values""" + + usage = _('Usage: config [--set ] [ [...] ]') + + def add_arguments(self, parser): + set_group = parser.add_argument_group('setting a value') + set_group.add_argument( + '-s', + '--set', + action='store', + metavar='', + help=_('set value for this key'), + ) + set_group.add_argument( + 'values', metavar='', nargs='+', help=_('Value to set') + ) + get_group = parser.add_argument_group('getting values') + get_group.add_argument( + 'keys', + metavar='', + nargs='*', + help=_('one or more keys separated by space'), + ) + + def handle(self, options): + self.console = component.get('ConsoleUI') + if options.set: + return self._set_config(options) + else: + return self._get_config(options) + + def _get_config(self, options): + def _on_get_config(config): + string = '' + for key in sorted(config): + if key not in options.values: + continue + + color = '{!white,black,bold!}' + value = config[key] + try: + color = colors.type_color[type(value)] + except KeyError: + pass + + # We need to format dicts for printing + if isinstance(value, dict): + import pprint + + value = pprint.pformat(value, 2, 80) + new_value = [] + for line in value.splitlines(): + new_value.append(f'{color}{line}') + value = '\n'.join(new_value) + + string += f'{key}: {color}{value}\n' + self.console.write(string.strip()) + + return client.core.get_config().addCallback(_on_get_config) + + def _set_config(self, options): + config = component.get('CoreConfig') + key = options.set + val = ' '.join(options.values) + + try: + val = json_eval(val) + except Exception as ex: + self.console.write('{!error!}%s' % ex) + return + + if key not in config: + self.console.write('{!error!}Invalid key: %s' % key) + return + + if not isinstance(config[key], type(val)): + try: + val = type(config[key])(val) + except TypeError: + self.config.write( + '{!error!}Configuration value provided has incorrect type.' + ) + return + + def on_set_config(result): + self.console.write('{!success!}Configuration value successfully updated.') + + self.console.write(f'Setting "{key}" to: {val!r}') + return client.core.set_config({key: val}).addCallback(on_set_config) + + def complete(self, text): + return [k for k in component.get('CoreConfig') if k.startswith(text)] diff --git a/deluge/ui/console/cmdline/commands/connect.py b/deluge/ui/console/cmdline/commands/connect.py new file mode 100644 index 0000000..4c76de3 --- /dev/null +++ b/deluge/ui/console/cmdline/commands/connect.py @@ -0,0 +1,78 @@ +# +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# 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.ui.client import client + +from . import BaseCommand + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """Connect to a new deluge server""" + + usage = _('Usage: connect [] []') + + def add_arguments(self, parser): + parser.add_argument( + 'host', help=_('Daemon host and port'), metavar='' + ) + parser.add_argument( + 'username', help=_('Username'), metavar='', nargs='?', default='' + ) + parser.add_argument( + 'password', help=_('Password'), metavar='', nargs='?', default='' + ) + + def add_parser(self, subparsers): + parser = subparsers.add_parser( + self.name, help=self.__doc__, description=self.__doc__, prog='connect' + ) + self.add_arguments(parser) + + def handle(self, options): + self.console = component.get('ConsoleUI') + + host = options.host + try: + host, port = host.split(':') + port = int(port) + except ValueError: + port = 58846 + + def do_connect(): + d = client.connect(host, port, options.username, options.password) + + def on_connect(result): + if self.console.interactive: + self.console.write(f'{{!success!}}Connected to {host}:{port}!') + return component.start() + + def on_connect_fail(result): + self.console.write( + f'{{!error!}}Failed to connect to {host}:{port} with reason: {result.value.message}' + ) + return result + + d.addCallbacks(on_connect, on_connect_fail) + return d + + if client.connected(): + + def on_disconnect(result): + if self.console.statusbars: + self.console.statusbars.update_statusbars() + return do_connect() + + return client.disconnect().addCallback(on_disconnect) + else: + return do_connect() diff --git a/deluge/ui/console/cmdline/commands/debug.py b/deluge/ui/console/cmdline/commands/debug.py new file mode 100644 index 0000000..af48a8b --- /dev/null +++ b/deluge/ui/console/cmdline/commands/debug.py @@ -0,0 +1,37 @@ +# +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# 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 twisted.internet import defer + +import deluge.component as component +import deluge.log + +from . import BaseCommand + + +class Command(BaseCommand): + """Enable and disable debugging""" + + def add_arguments(self, parser): + parser.add_argument( + 'state', metavar='', choices=['on', 'off'], help=_('The new state') + ) + + def handle(self, options): + if options.state == 'on': + deluge.log.set_logger_level('debug') + elif options.state == 'off': + deluge.log.set_logger_level('error') + else: + component.get('ConsoleUI').write('{!error!}%s' % self.usage) + + return defer.succeed(True) + + def complete(self, text): + return [x for x in ['on', 'off'] if x.startswith(text)] diff --git a/deluge/ui/console/cmdline/commands/gui.py b/deluge/ui/console/cmdline/commands/gui.py new file mode 100644 index 0000000..575bc9b --- /dev/null +++ b/deluge/ui/console/cmdline/commands/gui.py @@ -0,0 +1,27 @@ +# +# Copyright (C) 2011 Nick Lanham +# +# 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 . import BaseCommand + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """Enable interactive mode""" + + interactive_only = True + + def handle(self, options): + console = component.get('ConsoleUI') + at = console.set_mode('TorrentList') + at.go_top = True + at.resume() diff --git a/deluge/ui/console/cmdline/commands/halt.py b/deluge/ui/console/cmdline/commands/halt.py new file mode 100644 index 0000000..608f2de --- /dev/null +++ b/deluge/ui/console/cmdline/commands/halt.py @@ -0,0 +1,32 @@ +# +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# 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 deluge.component as component +from deluge.ui.client import client + +from . import BaseCommand + + +class Command(BaseCommand): + """Shutdown the deluge server.""" + + def handle(self, options): + self.console = component.get('ConsoleUI') + + def on_shutdown(result): + self.console.write('{!success!}Daemon was shutdown') + + def on_shutdown_fail(reason): + self.console.write('{!error!}Unable to shutdown daemon: %s' % reason) + + return ( + client.daemon.shutdown() + .addCallback(on_shutdown) + .addErrback(on_shutdown_fail) + ) diff --git a/deluge/ui/console/cmdline/commands/help.py b/deluge/ui/console/cmdline/commands/help.py new file mode 100644 index 0000000..754dadb --- /dev/null +++ b/deluge/ui/console/cmdline/commands/help.py @@ -0,0 +1,71 @@ +# +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# 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 twisted.internet import defer + +import deluge.component as component + +from . import BaseCommand + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """Displays help on other commands""" + + def add_arguments(self, parser): + parser.add_argument( + 'commands', metavar='', nargs='*', help=_('One or more commands') + ) + + def handle(self, options): + self.console = component.get('ConsoleUI') + self._commands = self.console._commands + deferred = defer.succeed(True) + if options.commands: + for arg in options.commands: + try: + cmd = self._commands[arg] + except KeyError: + self.console.write('{!error!}Unknown command %s' % arg) + continue + try: + parser = cmd.create_parser() + self.console.write(parser.format_help()) + except AttributeError: + self.console.write(cmd.__doc__ or 'No help for this command') + self.console.write(' ') + else: + self.console.set_batch_write(True) + cmds_doc = '' + for cmd in sorted(self._commands): + if cmd in self._commands[cmd].aliases: + continue + parser = self._commands[cmd].create_parser() + cmd_doc = ( + '{!info!}' + + '%-9s' % self._commands[cmd].name_with_alias + + '{!input!} - ' + + self._commands[cmd].__doc__ + + '\n ' + + parser.format_usage() + or '' + ) + cmds_doc += parser.formatter.format_colors(cmd_doc) + self.console.write(cmds_doc) + self.console.write(' ') + self.console.write('For help on a specific command, use ` --help`') + self.console.set_batch_write(False) + + return deferred + + def complete(self, line): + return [x for x in component.get('ConsoleUI')._commands if x.startswith(line)] diff --git a/deluge/ui/console/cmdline/commands/info.py b/deluge/ui/console/cmdline/commands/info.py new file mode 100644 index 0000000..7ea9a67 --- /dev/null +++ b/deluge/ui/console/cmdline/commands/info.py @@ -0,0 +1,488 @@ +# +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# 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 os.path import sep as dirsep + +import deluge.component as component +import deluge.ui.console.utils.colors as colors +from deluge.common import TORRENT_STATE, fsize, fspeed +from deluge.ui.client import client +from deluge.ui.common import FILE_PRIORITY +from deluge.ui.console.utils.format_utils import ( + f_progressbar, + f_seedrank_dash, + format_date_never, + format_progress, + format_time, + ftotal_sized, + pad_string, + remove_formatting, + shorten_hash, + strwidth, + trim_string, +) + +from . import BaseCommand + +STATUS_KEYS = [ + 'state', + 'download_location', + 'tracker_host', + 'tracker_status', + 'next_announce', + 'name', + 'total_size', + 'progress', + 'num_seeds', + 'total_seeds', + 'num_peers', + 'total_peers', + 'eta', + 'download_payload_rate', + 'upload_payload_rate', + 'ratio', + 'distributed_copies', + 'num_pieces', + 'piece_length', + 'total_done', + 'files', + 'file_priorities', + 'file_progress', + 'peers', + 'is_seed', + 'is_finished', + 'active_time', + 'seeding_time', + 'time_since_transfer', + 'last_seen_complete', + 'seed_rank', + 'all_time_download', + 'total_uploaded', + 'total_payload_download', + 'total_payload_upload', + 'time_added', + 'label', +] + +# Add filter specific state to torrent states +STATES = ['Active'] + TORRENT_STATE + + +class Command(BaseCommand): + """Show information about the torrents""" + + sort_help = 'sort items. Possible keys: ' + ', '.join(STATUS_KEYS) + + epilog = """ + You can give the first few characters of a torrent-id to identify the torrent. + + Tab Completion in interactive mode (info *pattern*):\n + | First press of will output up to 15 matches; + | hitting a second time, will print 15 more matches; + | and a third press will print all remaining matches. + | (To modify behaviour of third , set `third_tab_lists_all` to False) +""" + + def add_arguments(self, parser): + parser.add_argument( + '-v', + '--verbose', + action='store_true', + default=False, + dest='verbose', + help=_('Show more information per torrent.'), + ) + parser.add_argument( + '-d', + '--detailed', + action='store_true', + default=False, + dest='detailed', + help=_('Show more detailed information including files and peers.'), + ) + parser.add_argument( + '-s', + '--state', + action='store', + dest='state', + help=_('Show torrents with state STATE: %s.' % (', '.join(STATES))), + ) + parser.add_argument( + '--sort', + action='store', + type=str, + default='', + dest='sort', + help=self.sort_help, + ) + parser.add_argument( + '--sort-reverse', + action='store', + type=str, + default='', + dest='sort_rev', + help=_('Same as --sort but items are in reverse order.'), + ) + parser.add_argument( + 'torrent_ids', + metavar='', + nargs='*', + help=_('One or more torrent ids. If none is given, list all'), + ) + + def add_subparser(self, subparsers): + parser = subparsers.add_parser( + self.name, + prog=self.name, + help=self.__doc__, + description=self.__doc__, + epilog=self.epilog, + ) + self.add_arguments(parser) + + def handle(self, options): + self.console = component.get('ConsoleUI') + # Compile a list of torrent_ids to request the status of + torrent_ids = [] + + if options.torrent_ids: + for t_id in options.torrent_ids: + torrent_ids.extend(self.console.match_torrent(t_id)) + else: + torrent_ids.extend(self.console.match_torrent('')) + + def on_torrents_status(status): + # Print out the information for each torrent + sort_key = options.sort + sort_reverse = False + if not sort_key: + sort_key = options.sort_rev + sort_reverse = True + if not sort_key: + sort_key = 'name' + sort_reverse = False + if sort_key not in STATUS_KEYS: + self.console.write('') + self.console.write( + '{!error!}Unknown sort key: ' + sort_key + ', will sort on name' + ) + sort_key = 'name' + sort_reverse = False + for key, value in sorted( + status.items(), + key=lambda x: x[1].get(sort_key), + reverse=sort_reverse, + ): + self.show_info(key, status[key], options.verbose, options.detailed) + + def on_torrents_status_fail(reason): + self.console.write('{!error!}Error getting torrent info: %s' % reason) + + status_dict = {'id': torrent_ids} + + if options.state: + options.state = options.state.capitalize() + if options.state in STATES: + status_dict.state = options.state + else: + self.console.write('Invalid state: %s' % options.state) + self.console.write('Possible values are: %s.' % (', '.join(STATES))) + return + + d = client.core.get_torrents_status(status_dict, STATUS_KEYS) + d.addCallback(on_torrents_status) + d.addErrback(on_torrents_status_fail) + return d + + def show_file_info(self, torrent_id, status): + spaces_per_level = 2 + + if hasattr(self.console, 'screen'): + cols = self.console.screen.cols + else: + cols = 80 + + prevpath = [] + for index, torrent_file in enumerate(status['files']): + filename = torrent_file['path'].split(dirsep)[-1] + filepath = torrent_file['path'].split(dirsep)[:-1] + + for depth, subdir in enumerate(filepath): + indent = ' ' * depth * spaces_per_level + if depth >= len(prevpath): + self.console.write(f'{indent}{{!cyan!}}{subdir}') + elif subdir != prevpath[depth]: + self.console.write(f'{indent}{{!cyan!}}{subdir}') + + depth = len(filepath) + + indent = ' ' * depth * spaces_per_level + + col_filename = indent + filename + col_size = ' ({!cyan!}%s{!input!})' % fsize(torrent_file['size']) + col_progress = ' {!input!}%.2f%%' % (status['file_progress'][index] * 100) + + col_priority = ' {!info!}Priority: ' + + file_priority = FILE_PRIORITY[status['file_priorities'][index]] + + if status['file_progress'][index] != 1.0: + if file_priority == 'Skip': + col_priority += '{!error!}' + else: + col_priority += '{!success!}' + else: + col_priority += '{!input!}' + col_priority += file_priority + + def tlen(string): + return strwidth(remove_formatting(string)) + + col_all_info = col_size + col_progress + col_priority + # Check how much space we've got left after writing all the info + space_left = cols - tlen(col_all_info) + # And how much we will potentially have with the longest possible column + maxlen_space_left = cols - tlen(' (1000.0 MiB) 100.00% Priority: Normal') + if maxlen_space_left > tlen(col_filename) + 1: + # If there is enough space, pad it all nicely + col_all_info = '' + col_all_info += ' (' + spaces_to_add = tlen(' (1000.0 MiB)') - tlen(col_size) + col_all_info += ' ' * spaces_to_add + col_all_info += col_size[2:] + spaces_to_add = tlen(' 100.00%') - tlen(col_progress) + col_all_info += ' ' * spaces_to_add + col_all_info += col_progress + spaces_to_add = tlen(' Priority: Normal') - tlen(col_priority) + col_all_info += col_priority + col_all_info += ' ' * spaces_to_add + # And remember to put it to the left! + col_filename = pad_string( + col_filename, maxlen_space_left - 2, side='right' + ) + elif space_left > tlen(col_filename) + 1: + # If there is enough space, put the info to the right + col_filename = pad_string(col_filename, space_left - 2, side='right') + else: + # And if there is not, shorten the name + col_filename = trim_string(col_filename, space_left, True) + self.console.write(col_filename + col_all_info) + + prevpath = filepath + + def show_peer_info(self, torrent_id, status): + if len(status['peers']) == 0: + self.console.write(' None') + else: + s = '' + for peer in status['peers']: + if peer['seed']: + s += '%sSeed\t{!input!}' % colors.state_color['Seeding'] + else: + s += '%sPeer\t{!input!}' % colors.state_color['Downloading'] + + s += peer['country'] + '\t' + + if peer['ip'].count(':') == 1: + # IPv4 + s += peer['ip'] + else: + # IPv6 + s += '[{}]:{}'.format( + ':'.join(peer['ip'].split(':')[:-1]), + peer['ip'].split(':')[-1], + ) + + c = peer['client'] + s += '\t' + c + + if len(c) < 16: + s += '\t\t' + else: + s += '\t' + s += '{}{}\t{}{}'.format( + colors.state_color['Seeding'], + fspeed(peer['up_speed']), + colors.state_color['Downloading'], + fspeed(peer['down_speed']), + ) + s += '\n' + + self.console.write(s[:-1]) + + def show_info(self, torrent_id, status, verbose=False, detailed=False): + """ + Writes out the torrents information to the screen. + + Format depends on switches given. + """ + self.console.set_batch_write(True) + + if hasattr(self.console, 'screen'): + cols = self.console.screen.cols + else: + cols = 80 + + sep = ' ' + + if verbose or detailed: + self.console.write('{!info!}Name: {!input!}%s' % (status['name'])) + self.console.write('{!info!}ID: {!input!}%s' % (torrent_id)) + s = '{{!info!}}State: {}{}'.format( + colors.state_color[status['state']], + status['state'], + ) + # Only show speed if active + if status['state'] in ('Seeding', 'Downloading'): + if status['state'] != 'Seeding': + s += sep + s += '{!info!}Down Speed: {!input!}%s' % fspeed( + status['download_payload_rate'], shortform=True + ) + s += sep + s += '{!info!}Up Speed: {!input!}%s' % fspeed( + status['upload_payload_rate'], shortform=True + ) + self.console.write(s) + + if status['state'] in ('Seeding', 'Downloading', 'Queued'): + s = '{{!info!}}Seeds: {{!input!}}{} ({})'.format( + status['num_seeds'], + status['total_seeds'], + ) + s += sep + s += '{{!info!}}Peers: {{!input!}}{} ({})'.format( + status['num_peers'], + status['total_peers'], + ) + s += sep + s += ( + '{!info!}Availability: {!input!}%.2f' % status['distributed_copies'] + ) + s += sep + s += '{!info!}Seed Rank: {!input!}%s' % f_seedrank_dash( + status['seed_rank'], status['seeding_time'] + ) + self.console.write(s) + + total_done = fsize(status['total_done'], shortform=True) + total_size = fsize(status['total_size'], shortform=True) + if total_done == total_size: + s = '{!info!}Size: {!input!}%s' % (total_size) + else: + s = f'{{!info!}}Size: {{!input!}}{total_done}/{total_size}' + s += sep + s += '{!info!}Downloaded: {!input!}%s' % fsize( + status['all_time_download'], shortform=True + ) + s += sep + s += '{!info!}Uploaded: {!input!}%s' % fsize( + status['total_uploaded'], shortform=True + ) + s += sep + s += '{!info!}Share Ratio: {!input!}%.2f' % status['ratio'] + self.console.write(s) + + s = '{!info!}ETA: {!input!}%s' % format_time(status['eta']) + s += sep + s += '{!info!}Seeding: {!input!}%s' % format_time(status['seeding_time']) + s += sep + s += '{!info!}Active: {!input!}%s' % format_time(status['active_time']) + self.console.write(s) + + s = '{!info!}Last Transfer: {!input!}%s' % format_time( + status['time_since_transfer'] + ) + s += sep + s += '{!info!}Complete Seen: {!input!}%s' % format_date_never( + status['last_seen_complete'] + ) + self.console.write(s) + + s = '{!info!}Tracker: {!input!}%s' % status['tracker_host'] + self.console.write(s) + + self.console.write( + '{!info!}Tracker status: {!input!}%s' % status['tracker_status'] + ) + + if not status['is_finished']: + pbar = f_progressbar( + status['progress'], cols - (13 + len('%.2f%%' % status['progress'])) + ) + s = '{{!info!}}Progress: {{!input!}}{:.2f}% {}'.format( + status['progress'], pbar + ) + self.console.write(s) + + s = '{!info!}Download Folder: {!input!}%s' % status['download_location'] + self.console.write(s) + + if 'label' in status: + s = '{!info!}Label: {!input!}%s' % status['label'] + self.console.write(s) + + if detailed: + self.console.write('\n{!info!}Files in torrent') + self.show_file_info(torrent_id, status) + self.console.write('{!info!}Connected peers') + self.show_peer_info(torrent_id, status) + else: + up_color = colors.state_color['Seeding'] + down_color = colors.state_color['Downloading'] + + s = '{}{}'.format( + colors.state_color[status['state']], + '[' + status['state'][0] + ']', + ) + + s += ' {!info!}' + format_progress(status['progress']).rjust(6, ' ') + s += ' {!input!}%s' % (status['name']) + + # Shorten the ID if it's necessary. Pretty hacky + # XXX: should make a nice function for it that can partition and shorten stuff + space_left = cols - strwidth('[S] 99.99% ' + status['name']) + + if self.console.interactive and space_left >= len(sep + torrent_id): + # Not enough line space so shorten the hash (for interactive mode). + torrent_id = shorten_hash(torrent_id, space_left) + s += sep + s += '{!cyan!}%s' % torrent_id + self.console.write(s) + + dl_info = '{!info!}DL: {!input!}' + dl_info += '%s' % ftotal_sized( + status['all_time_download'], status['total_payload_download'] + ) + + if status['download_payload_rate'] > 0: + dl_info += ' @ {}{}'.format( + down_color, + fspeed(status['download_payload_rate'], shortform=True), + ) + + ul_info = ' {!info!}UL: {!input!}' + ul_info += '%s' % ftotal_sized( + status['total_uploaded'], status['total_payload_upload'] + ) + if status['upload_payload_rate'] > 0: + ul_info += ' @ {}{}'.format( + up_color, + fspeed(status['upload_payload_rate'], shortform=True), + ) + + eta = ' {!info!}ETA: {!magenta!}%s' % format_time(status['eta']) + + self.console.write(' ' + dl_info + ul_info + eta + '\n') + + self.console.set_batch_write(False) + + def complete(self, line): + # We use the ConsoleUI torrent tab complete method + return component.get('ConsoleUI').tab_complete_torrent(line) diff --git a/deluge/ui/console/cmdline/commands/manage.py b/deluge/ui/console/cmdline/commands/manage.py new file mode 100644 index 0000000..e5ea9b2 --- /dev/null +++ b/deluge/ui/console/cmdline/commands/manage.py @@ -0,0 +1,114 @@ +# +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# 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 twisted.internet import defer + +import deluge.component as component +from deluge.ui.client import client +from deluge.ui.console.utils.common import TORRENT_OPTIONS + +from . import BaseCommand + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """Show and manage per-torrent options""" + + usage = _('Usage: manage [--set ] [ [...] ]') + + def add_arguments(self, parser): + parser.add_argument( + 'torrent', + metavar='', + help=_('an expression matched against torrent ids and torrent names'), + ) + set_group = parser.add_argument_group('setting a value') + set_group.add_argument( + '-s', + '--set', + action='store', + metavar='', + help=_('set value for this key'), + ) + set_group.add_argument( + 'values', metavar='', nargs='+', help=_('Value to set') + ) + get_group = parser.add_argument_group('getting values') + get_group.add_argument( + 'keys', + metavar='', + nargs='*', + help=_('one or more keys separated by space'), + ) + + def handle(self, options): + self.console = component.get('ConsoleUI') + if options.set: + return self._set_option(options) + else: + return self._get_option(options) + + def _get_option(self, options): + def on_torrents_status(status): + for torrentid, data in status.items(): + self.console.write('') + if 'name' in data: + self.console.write('{!info!}Name: {!input!}%s' % data.get('name')) + self.console.write('{!info!}ID: {!input!}%s' % torrentid) + for k, v in data.items(): + if k != 'name': + self.console.write(f'{{!info!}}{k}: {{!input!}}{v}') + + def on_torrents_status_fail(reason): + self.console.write('{!error!}Failed to get torrent data.') + + torrent_ids = self.console.match_torrent(options.torrent) + + request_options = [] + for opt in options.values: + if opt not in TORRENT_OPTIONS: + self.console.write('{!error!}Unknown torrent option: %s' % opt) + return + request_options.append(opt) + if not request_options: + request_options = list(TORRENT_OPTIONS) + request_options.append('name') + + d = client.core.get_torrents_status({'id': torrent_ids}, request_options) + d.addCallbacks(on_torrents_status, on_torrents_status_fail) + return d + + def _set_option(self, options): + deferred = defer.Deferred() + key = options.set + val = ' '.join(options.values) + torrent_ids = self.console.match_torrent(options.torrent) + + if key not in TORRENT_OPTIONS: + self.console.write('{!error!}Invalid key: %s' % key) + return + + val = TORRENT_OPTIONS[key](val) + + def on_set_config(result): + self.console.write('{!success!}Torrent option successfully updated.') + deferred.callback(True) + + self.console.write(f'Setting {key} to {val} for torrents {torrent_ids}..') + client.core.set_torrent_options(torrent_ids, {key: val}).addCallback( + on_set_config + ) + return deferred + + def complete(self, line): + # We use the ConsoleUI torrent tab complete method + return component.get('ConsoleUI').tab_complete_torrent(line) diff --git a/deluge/ui/console/cmdline/commands/move.py b/deluge/ui/console/cmdline/commands/move.py new file mode 100644 index 0000000..67ee0af --- /dev/null +++ b/deluge/ui/console/cmdline/commands/move.py @@ -0,0 +1,94 @@ +# +# Copyright (C) 2011 Nick Lanham +# +# 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.path + +import deluge.component as component +from deluge.ui.client import client + +from . import BaseCommand + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """Move torrents' storage location""" + + def add_arguments(self, parser): + parser.add_argument( + 'torrent_ids', + metavar='', + nargs='+', + help=_('One or more torrent ids'), + ) + parser.add_argument( + 'path', metavar='', help=_('The path to move the torrents to') + ) + + def handle(self, options): + self.console = component.get('ConsoleUI') + + if os.path.exists(options.path) and not os.path.isdir(options.path): + self.console.write( + '{!error!}Cannot Move Download Folder: %s exists and is not a directory' + % options.path + ) + return + + ids = [] + names = [] + for t_id in options.torrent_ids: + tid = self.console.match_torrent(t_id) + ids.extend(tid) + names.append(self.console.get_torrent_name(tid)) + + def on_move(res): + msg = 'Moved "{}" to {}'.format(', '.join(names), options.path) + self.console.write(msg) + log.info(msg) + + d = client.core.move_storage(ids, options.path) + d.addCallback(on_move) + return d + + def complete(self, line): + line = os.path.abspath(os.path.expanduser(line)) + ret = [] + if os.path.exists(line): + # This is a correct path, check to see if it's a directory + if os.path.isdir(line): + # Directory, so we need to show contents of directory + # ret.extend(os.listdir(line)) + for f in os.listdir(line): + # Skip hidden + if f.startswith('.'): + continue + f = os.path.join(line, f) + if os.path.isdir(f): + f += '/' + ret.append(f) + else: + # This is a file, but we could be looking for another file that + # shares a common prefix. + for f in os.listdir(os.path.dirname(line)): + if f.startswith(os.path.split(line)[1]): + ret.append(os.path.join(os.path.dirname(line), f)) + else: + # This path does not exist, so lets do a listdir on it's parent + # and find any matches. + ret = [] + if os.path.isdir(os.path.dirname(line)): + for f in os.listdir(os.path.dirname(line)): + if f.startswith(os.path.split(line)[1]): + p = os.path.join(os.path.dirname(line), f) + + if os.path.isdir(p): + p += '/' + ret.append(p) + return ret diff --git a/deluge/ui/console/cmdline/commands/pause.py b/deluge/ui/console/cmdline/commands/pause.py new file mode 100644 index 0000000..1334242 --- /dev/null +++ b/deluge/ui/console/cmdline/commands/pause.py @@ -0,0 +1,45 @@ +# +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# 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 deluge.component as component +from deluge.ui.client import client + +from . import BaseCommand + + +class Command(BaseCommand): + """Pause torrents""" + + usage = 'pause [ * | [ ...] ]' + + def add_arguments(self, parser): + parser.add_argument( + 'torrent_ids', + metavar='', + nargs='+', + help=_('One or more torrent ids. Use "*" to pause all torrents'), + ) + + def handle(self, options): + self.console = component.get('ConsoleUI') + + if options.torrent_ids[0] == '*': + client.core.pause_session() + return + + torrent_ids = [] + for arg in options.torrent_ids: + torrent_ids.extend(self.console.match_torrent(arg)) + + if torrent_ids: + return client.core.pause_torrent(torrent_ids) + + def complete(self, line): + # We use the ConsoleUI torrent tab complete method + return component.get('ConsoleUI').tab_complete_torrent(line) diff --git a/deluge/ui/console/cmdline/commands/plugin.py b/deluge/ui/console/cmdline/commands/plugin.py new file mode 100644 index 0000000..c424cb2 --- /dev/null +++ b/deluge/ui/console/cmdline/commands/plugin.py @@ -0,0 +1,140 @@ +# +# Copyright (C) 2009 Andrew Resch +# +# 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 deluge.component as component +import deluge.configmanager +from deluge.ui.client import client + +from . import BaseCommand + + +class Command(BaseCommand): + """Manage plugins""" + + def add_arguments(self, parser): + parser.add_argument( + '-l', + '--list', + action='store_true', + default=False, + dest='list', + help=_('Lists available plugins'), + ) + parser.add_argument( + '-s', + '--show', + action='store_true', + default=False, + dest='show', + help=_('Shows enabled plugins'), + ) + parser.add_argument( + '-e', '--enable', dest='enable', nargs='+', help=_('Enables a plugin') + ) + parser.add_argument( + '-d', '--disable', dest='disable', nargs='+', help=_('Disables a plugin') + ) + parser.add_argument( + '-r', + '--reload', + action='store_true', + default=False, + dest='reload', + help=_('Reload list of available plugins'), + ) + parser.add_argument( + '-i', '--install', help=_('Install a plugin from an .egg file') + ) + + def handle(self, options): + self.console = component.get('ConsoleUI') + + if options.reload: + client.core.pluginmanager.rescan_plugins() + self.console.write('{!green!}Plugin list successfully reloaded') + return + + elif options.list: + + def on_available_plugins(result): + self.console.write('{!info!}Available Plugins:') + for p in result: + self.console.write('{!input!} ' + p) + + return client.core.get_available_plugins().addCallback(on_available_plugins) + + elif options.show: + + def on_enabled_plugins(result): + self.console.write('{!info!}Enabled Plugins:') + for p in result: + self.console.write('{!input!} ' + p) + + return client.core.get_enabled_plugins().addCallback(on_enabled_plugins) + + elif options.enable: + + def on_available_plugins(result): + plugins = {} + for p in result: + plugins[p.lower()] = p + for arg in options.enable: + if arg.lower() in plugins: + client.core.enable_plugin(plugins[arg.lower()]) + + return client.core.get_available_plugins().addCallback(on_available_plugins) + + elif options.disable: + + def on_enabled_plugins(result): + plugins = {} + for p in result: + plugins[p.lower()] = p + for arg in options.disable: + if arg.lower() in plugins: + client.core.disable_plugin(plugins[arg.lower()]) + + return client.core.get_enabled_plugins().addCallback(on_enabled_plugins) + + elif options.install: + import os.path + import shutil + from base64 import b64encode + + filepath = options.install + + if not os.path.exists(filepath): + self.console.write('{!error!}Invalid path: %s' % filepath) + return + + config_dir = deluge.configmanager.get_config_dir() + filename = os.path.split(filepath)[1] + shutil.copyfile(filepath, os.path.join(config_dir, 'plugins', filename)) + + client.core.rescan_plugins() + + if not client.is_localhost(): + # We need to send this plugin to the daemon + with open(filepath, 'rb') as _file: + filedump = b64encode(_file.read()) + try: + client.core.upload_plugin(filename, filedump) + client.core.rescan_plugins() + except Exception: + self.console.write( + '{!error!}An error occurred, plugin was not installed' + ) + + self.console.write( + '{!green!}Plugin was successfully installed: %s' % filename + ) + + def complete(self, line): + return component.get('ConsoleUI').tab_complete_path( + line, ext='.egg', sort='name', dirs_first=-1 + ) diff --git a/deluge/ui/console/cmdline/commands/quit.py b/deluge/ui/console/cmdline/commands/quit.py new file mode 100644 index 0000000..4459dfc --- /dev/null +++ b/deluge/ui/console/cmdline/commands/quit.py @@ -0,0 +1,22 @@ +# +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# 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 deluge.component as component + +from . import BaseCommand + + +class Command(BaseCommand): + """Exit the client""" + + aliases = ['exit'] + interactive_only = True + + def handle(self, options): + component.get('ConsoleUI').quit() diff --git a/deluge/ui/console/cmdline/commands/recheck.py b/deluge/ui/console/cmdline/commands/recheck.py new file mode 100644 index 0000000..046cb0b --- /dev/null +++ b/deluge/ui/console/cmdline/commands/recheck.py @@ -0,0 +1,44 @@ +# +# Copyright (C) 2009 Andrew Resch +# +# 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 deluge.component as component +from deluge.ui.client import client + +from . import BaseCommand + + +class Command(BaseCommand): + """Forces a recheck of the torrent data""" + + usage = 'recheck [ * | [ ...] ]' + + def add_arguments(self, parser): + parser.add_argument( + 'torrent_ids', + metavar='', + nargs='+', + help=_('One or more torrent ids'), + ) + + def handle(self, options): + self.console = component.get('ConsoleUI') + + if options.torrent_ids[0] == '*': + client.core.force_recheck(self.console.match_torrent('')) + return + + torrent_ids = [] + for arg in options.torrent_ids: + torrent_ids.extend(self.console.match_torrent(arg)) + + if torrent_ids: + return client.core.force_recheck(torrent_ids) + + def complete(self, line): + # We use the ConsoleUI torrent tab complete method + return component.get('ConsoleUI').tab_complete_torrent(line) diff --git a/deluge/ui/console/cmdline/commands/resume.py b/deluge/ui/console/cmdline/commands/resume.py new file mode 100644 index 0000000..27b8528 --- /dev/null +++ b/deluge/ui/console/cmdline/commands/resume.py @@ -0,0 +1,45 @@ +# +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# 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 deluge.component as component +from deluge.ui.client import client + +from . import BaseCommand + + +class Command(BaseCommand): + """Resume torrents""" + + usage = _('Usage: resume [ * | [ ...] ]') + + def add_arguments(self, parser): + parser.add_argument( + 'torrent_ids', + metavar='', + nargs='+', + help=_('One or more torrent ids. Use "*" to resume all torrents'), + ) + + def handle(self, options): + self.console = component.get('ConsoleUI') + + if options.torrent_ids[0] == '*': + client.core.resume_session() + return + + torrent_ids = [] + for t_id in options.torrent_ids: + torrent_ids.extend(self.console.match_torrent(t_id)) + + if torrent_ids: + return client.core.resume_torrent(torrent_ids) + + def complete(self, line): + # We use the ConsoleUI torrent tab complete method + return component.get('ConsoleUI').tab_complete_torrent(line) diff --git a/deluge/ui/console/cmdline/commands/rm.py b/deluge/ui/console/cmdline/commands/rm.py new file mode 100644 index 0000000..4a3fd00 --- /dev/null +++ b/deluge/ui/console/cmdline/commands/rm.py @@ -0,0 +1,82 @@ +# +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# 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.ui.client import client + +from . import BaseCommand + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """Remove a torrent""" + + aliases = ['del'] + + def add_arguments(self, parser): + parser.add_argument( + '--remove_data', + action='store_true', + default=False, + help=_('Also removes the torrent data'), + ) + parser.add_argument( + '-c', + '--confirm', + action='store_true', + default=False, + help=_('List the matching torrents without removing.'), + ) + parser.add_argument( + 'torrent_ids', + metavar='', + nargs='+', + help=_('One or more torrent ids'), + ) + + def handle(self, options): + self.console = component.get('ConsoleUI') + torrent_ids = self.console.match_torrents(options.torrent_ids) + + if not options.confirm: + self.console.write( + '{!info!}%d %s %s{!info!}' + % ( + len(torrent_ids), + _n('torrent', 'torrents', len(torrent_ids)), + _n('match', 'matches', len(torrent_ids)), + ) + ) + for t_id in torrent_ids: + name = self.console.get_torrent_name(t_id) + self.console.write('* %-50s (%s)' % (name, t_id)) + self.console.write( + _('Confirm with -c to remove the listed torrents (Count: %d)') + % len(torrent_ids) + ) + return + + def on_removed_finished(errors): + if errors: + self.console.write( + 'Error(s) occurred when trying to delete torrent(s).' + ) + for t_id, e_msg in errors: + self.console.write(f'Error removing torrent {t_id} : {e_msg}') + + log.info('Removing %d torrents', len(torrent_ids)) + d = client.core.remove_torrents(torrent_ids, options.remove_data) + d.addCallback(on_removed_finished) + + def complete(self, line): + # We use the ConsoleUI torrent tab complete method + return component.get('ConsoleUI').tab_complete_torrent(line) diff --git a/deluge/ui/console/cmdline/commands/status.py b/deluge/ui/console/cmdline/commands/status.py new file mode 100644 index 0000000..05c9796 --- /dev/null +++ b/deluge/ui/console/cmdline/commands/status.py @@ -0,0 +1,116 @@ +# +# Copyright (C) 2011 Nick Lanham +# +# 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 twisted.internet import defer + +import deluge.component as component +from deluge.common import TORRENT_STATE, fspeed +from deluge.ui.client import client + +from . import BaseCommand + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """Shows various status information from the daemon""" + + def add_arguments(self, parser): + parser.add_argument( + '-r', + '--raw', + action='store_true', + default=False, + dest='raw', + help=_( + 'Raw values for upload/download rates (without KiB/s suffix)' + '(useful for scripts that want to do their own parsing)' + ), + ) + parser.add_argument( + '-n', + '--no-torrents', + action='store_false', + default=True, + dest='show_torrents', + help=_('Do not show torrent status (Improves command speed)'), + ) + + def handle(self, options): + self.console = component.get('ConsoleUI') + self.status = None + self.torrents = 1 if options.show_torrents else 0 + self.raw = options.raw + + def on_session_status(status): + self.status = status + + def on_torrents_status(status): + self.torrents = status + + def on_torrents_status_fail(reason): + log.warning('Failed to retrieve session status: %s', reason) + self.torrents = -2 + + deferreds = [] + + ds = client.core.get_session_status( + [ + 'peer.num_peers_connected', + 'payload_upload_rate', + 'payload_download_rate', + 'dht.dht_nodes', + ] + ) + ds.addCallback(on_session_status) + deferreds.append(ds) + + if options.show_torrents: + dt = client.core.get_torrents_status({}, ['state']) + dt.addCallback(on_torrents_status) + dt.addErrback(on_torrents_status_fail) + deferreds.append(dt) + + return defer.DeferredList(deferreds).addCallback(self.print_status) + + def print_status(self, *args): + self.console.set_batch_write(True) + if self.raw: + self.console.write( + '{!info!}Total upload: %f' % self.status['payload_upload_rate'] + ) + self.console.write( + '{!info!}Total download: %f' % self.status['payload_download_rate'] + ) + else: + self.console.write( + '{!info!}Total upload: %s' % fspeed(self.status['payload_upload_rate']) + ) + self.console.write( + '{!info!}Total download: %s' + % fspeed(self.status['payload_download_rate']) + ) + self.console.write('{!info!}DHT Nodes: %i' % self.status['dht.dht_nodes']) + + if isinstance(self.torrents, int): + if self.torrents == -2: + self.console.write('{!error!}Error getting torrent info') + else: + self.console.write('{!info!}Total torrents: %i' % len(self.torrents)) + state_counts = {} + for state in TORRENT_STATE: + state_counts[state] = 0 + for t in self.torrents: + s = self.torrents[t] + state_counts[s['state']] += 1 + for state in TORRENT_STATE: + self.console.write('{!info!} %s: %i' % (state, state_counts[state])) + + self.console.set_batch_write(False) diff --git a/deluge/ui/console/cmdline/commands/update_tracker.py b/deluge/ui/console/cmdline/commands/update_tracker.py new file mode 100644 index 0000000..c05569d --- /dev/null +++ b/deluge/ui/console/cmdline/commands/update_tracker.py @@ -0,0 +1,44 @@ +# +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# 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 deluge.component as component +from deluge.ui.client import client + +from . import BaseCommand + + +class Command(BaseCommand): + """Update tracker for torrent(s)""" + + usage = 'update_tracker [ * | [ ...] ]' + aliases = ['reannounce'] + + def add_arguments(self, parser): + parser.add_argument( + 'torrent_ids', + metavar='', + nargs='+', + help='One or more torrent ids. "*" updates all torrents', + ) + + def handle(self, options): + self.console = component.get('ConsoleUI') + args = options.torrent_ids + if options.torrent_ids[0] == '*': + args = [''] + + torrent_ids = [] + for arg in args: + torrent_ids.extend(self.console.match_torrent(arg)) + + client.core.force_reannounce(torrent_ids) + + def complete(self, line): + # We use the ConsoleUI torrent tab complete method + return component.get('ConsoleUI').tab_complete_torrent(line) diff --git a/deluge/ui/console/console.py b/deluge/ui/console/console.py new file mode 100644 index 0000000..f91563f --- /dev/null +++ b/deluge/ui/console/console.py @@ -0,0 +1,163 @@ +# +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# 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 fnmatch +import logging +import os +import sys + +import deluge.common +from deluge.argparserbase import ArgParserBase, DelugeTextHelpFormatter +from deluge.ui.ui import UI + +log = logging.getLogger(__name__) + +# +# Note: Cannot import from console.main here because it imports the twisted reactor. +# Console is imported from console/__init__.py loaded by the script entry points +# defined in setup.py +# + + +def load_commands(command_dir): + def get_command(name): + command = getattr( + __import__( + 'deluge.ui.console.cmdline.commands.%s' % name, {}, {}, ['Command'] + ), + 'Command', + )() + command._name = name + return command + + try: + dir_list = fnmatch.filter(os.listdir(command_dir), '*.py') + except OSError: + return {} + + commands = [] + for filename in dir_list: + if filename.startswith('_'): + continue + cmd = get_command(os.path.splitext(filename)[0]) + for cmd_name in [cmd._name] + cmd.aliases: + commands.append((cmd_name, cmd)) + return dict(commands) + + +class LogStream: + out = sys.stdout + + def write(self, data): + self.out.write(data) + + def flush(self): + self.out.flush() + + +class Console(UI): + cmd_description = """Console or command-line user interface""" + + def __init__(self, *args, **kwargs): + super().__init__('console', *args, log_stream=LogStream(), **kwargs) + + group = self.parser.add_argument_group( + _('Console Options'), + _( + 'These daemon connect options will be ' + 'used for commands, or if console ui autoconnect is enabled.' + ), + ) + group.add_argument( + '-d', + '--daemon', + metavar='', + dest='daemon_addr', + help=_('Deluge daemon IP address to connect to (default 127.0.0.1)'), + default='127.0.0.1', + ) + group.add_argument( + '-p', + '--port', + metavar='', + dest='daemon_port', + type=int, + help=_('Deluge daemon port to connect to (default 58846)'), + default='58846', + ) + group.add_argument( + '-U', + '--username', + metavar='', + dest='daemon_user', + help=_('Deluge daemon username to use when connecting'), + ) + group.add_argument( + '-P', + '--password', + metavar='', + dest='daemon_pass', + help=_('Deluge daemon password to use when connecting'), + ) + # To properly print help message for the console commands ( e.g. deluge-console info -h), + # we add a subparser for each command which will trigger the help/usage when given + from deluge.ui.console.parser import ( # import here because (see top) + ConsoleCommandParser, + ) + + self.console_parser = ConsoleCommandParser( + parents=[self.parser], + add_help=False, + prog=self.parser.prog, + description='Starts the Deluge console interface', + formatter_class=lambda prog: DelugeTextHelpFormatter( + prog, max_help_position=33, width=90 + ), + ) + self.parser.subparser = self.console_parser + self.console_parser.base_parser = self.parser + subparsers = self.console_parser.add_subparsers( + title=_('Console Commands'), + help=_('Description'), + description=_('The following console commands are available:'), + metavar=_('Command'), + dest='command', + ) + from deluge.ui.console import UI_PATH # Must import here + + self.console_cmds = load_commands(os.path.join(UI_PATH, 'cmdline', 'commands')) + for cmd in sorted(self.console_cmds): + self.console_cmds[cmd].add_subparser(subparsers) + + def start(self): + if self.ui_args is None: + # Started directly by deluge-console script so must find the UI args manually + options, remaining = ArgParserBase(common_help=False).parse_known_args() + self.ui_args = remaining + + i = self.console_parser.find_subcommand(args=self.ui_args) + self.console_parser.subcommand = False + self.parser.subcommand = False if i == -1 else True + + super().start(self.console_parser) + from deluge.ui.console.main import ConsoleUI # import here because (see top) + + def run(options): + try: + c = ConsoleUI(self.options, self.console_cmds, self.parser.log_stream) + return c.start_ui() + except Exception as ex: + log.exception(ex) + raise + + return deluge.common.run_profiled( + run, + self.options, + output_file=self.options.profile, + do_profile=self.options.profile, + ) diff --git a/deluge/ui/console/eventlog.py b/deluge/ui/console/eventlog.py new file mode 100644 index 0000000..c1ee6ab --- /dev/null +++ b/deluge/ui/console/eventlog.py @@ -0,0 +1,125 @@ +import time + +import deluge.component as component +from deluge.decorators import maybe_coroutine +from deluge.ui.client import client +from deluge.ui.console.utils import colors + + +class EventLog(component.Component): + """ + Prints out certain events as they are received from the core. + """ + + def __init__(self): + component.Component.__init__(self, 'EventLog') + self.console = component.get('ConsoleUI') + self.prefix = '{!event!}* [%H:%M:%S] ' + self.date_change_format = 'On {!yellow!}%a, %d %b %Y{!input!} %Z:' + + event_callbacks = { + 'TorrentAddedEvent': self.on_torrent_added, + 'PreTorrentRemovedEvent': self.on_torrent_removed, + 'TorrentStateChangedEvent': self.on_torrent_state_changed, + 'TorrentFinishedEvent': self.on_torrent_finished, + 'NewVersionAvailableEvent': self.on_new_version_available, + 'SessionPausedEvent': self.on_session_paused, + 'SessionResumedEvent': self.on_session_resumed, + 'ConfigValueChangedEvent': self.on_config_value_changed, + 'PluginEnabledEvent': self.on_plugin_enabled, + 'PluginDisabledEvent': self.on_plugin_disabled, + } + + for event, callback in event_callbacks.items(): + client.register_event_handler(event, callback) + + self.previous_time = time.localtime(0) + + @maybe_coroutine + async def on_torrent_added(self, torrent_id, from_state): + if from_state: + return + + status = await client.core.get_torrent_status(torrent_id, ['name', 'state']) + self.write( + '{!green!}Torrent Added: {!info!}%s ({!cyan!}%s{!info!})' + % (status['name'], torrent_id) + ) + # Write out what state the added torrent took + self.on_torrent_state_changed(torrent_id, status['state']) + + def on_torrent_removed(self, torrent_id): + self.write( + '{!red!}Torrent Removed: {!info!}%s ({!cyan!}%s{!info!})' + % (self.console.get_torrent_name(torrent_id), torrent_id) + ) + + def on_torrent_state_changed(self, torrent_id, state): + # It's probably a new torrent, ignore it + if not state: + return + # Modify the state string color + if state in colors.state_color: + state = colors.state_color[state] + state + + t_name = self.console.get_torrent_name(torrent_id) + + # Again, it's most likely a new torrent + if not t_name: + return + + self.write(f'{state}: {{!info!}}{t_name} ({{!cyan!}}{torrent_id}{{!info!}})') + + def on_torrent_finished(self, torrent_id): + if component.get('TorrentList').config['ring_bell']: + import curses.beep + + curses.beep() + self.write( + '{!info!}Torrent Finished: %s ({!cyan!}%s{!info!})' + % (self.console.get_torrent_name(torrent_id), torrent_id) + ) + + def on_new_version_available(self, version): + self.write('{!input!}New Deluge version available: {!info!}%s' % (version)) + + def on_session_paused(self): + self.write('{!input!}Session Paused') + + def on_session_resumed(self): + self.write('{!green!}Session Resumed') + + def on_config_value_changed(self, key, value): + color = '{!white,black,bold!}' + try: + color = colors.type_color[type(value)] + except KeyError: + pass + + self.write(f'ConfigValueChanged: {{!input!}}{key}: {color}{value}') + + def write(self, s): + current_time = time.localtime() + + date_different = False + for field in ['tm_mday', 'tm_mon', 'tm_year']: + c = getattr(current_time, field) + p = getattr(self.previous_time, field) + if c != p: + date_different = True + + if date_different: + string = time.strftime(self.date_change_format) + self.console.write_event(' ') + self.console.write_event(string) + + p = time.strftime(self.prefix) + + self.console.write_event(p + s) + self.previous_time = current_time + + def on_plugin_enabled(self, name): + self.write('PluginEnabled: {!info!}%s' % name) + + def on_plugin_disabled(self, name): + self.write('PluginDisabled: {!info!}%s' % name) diff --git a/deluge/ui/console/main.py b/deluge/ui/console/main.py new file mode 100644 index 0000000..106169f --- /dev/null +++ b/deluge/ui/console/main.py @@ -0,0 +1,470 @@ +# +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# 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 locale +import logging +import os +import sys + +from twisted.internet import defer, error, reactor + +import deluge.common +import deluge.component as component +from deluge.configmanager import ConfigManager +from deluge.decorators import maybe_coroutine, overrides +from deluge.ui.client import client +from deluge.ui.console.eventlog import EventLog +from deluge.ui.console.modes.addtorrents import AddTorrents +from deluge.ui.console.modes.basemode import TermResizeHandler +from deluge.ui.console.modes.cmdline import CmdLine +from deluge.ui.console.modes.eventview import EventView +from deluge.ui.console.modes.preferences import Preferences +from deluge.ui.console.modes.torrentdetail import TorrentDetail +from deluge.ui.console.modes.torrentlist.torrentlist import TorrentList +from deluge.ui.console.utils import colors +from deluge.ui.console.utils.config import migrate_1_to_2 +from deluge.ui.console.widgets import StatusBars +from deluge.ui.coreconfig import CoreConfig +from deluge.ui.sessionproxy import SessionProxy + +log = logging.getLogger(__name__) + +DEFAULT_CONSOLE_PREFS = { + 'ring_bell': False, + 'first_run': True, + 'language': '', + 'torrentview': { + 'sort_primary': 'queue', + 'sort_secondary': 'name', + 'show_sidebar': True, + 'sidebar_width': 25, + 'separate_complete': True, + 'move_selection': True, + 'columns': {}, + }, + 'addtorrents': { + 'show_misc_files': False, # TODO: Showing/hiding this + 'show_hidden_folders': False, # TODO: Showing/hiding this + 'sort_column': 'date', + 'reverse_sort': True, + 'last_path': '~', + }, + 'cmdline': { + 'ignore_duplicate_lines': False, + 'third_tab_lists_all': False, + 'torrents_per_tab_press': 15, + 'save_command_history': True, + }, +} + + +class MockConsoleLog: + def write(self, data): + pass + + def flush(self): + pass + + +class ConsoleUI(component.Component, TermResizeHandler): + def __init__(self, options, cmds, log_stream): + component.Component.__init__(self, 'ConsoleUI') + TermResizeHandler.__init__(self) + self.options = options + self.log_stream = log_stream + + # keep track of events for the log view + self.events = [] + self.torrents = [] + self.statusbars = None + self.modes = {} + self.active_mode = None + self.initialized = False + + try: + locale.setlocale(locale.LC_ALL, '') + self.encoding = locale.getpreferredencoding() + except locale.Error: + self.encoding = sys.getdefaultencoding() + + log.debug('Using encoding: %s', self.encoding) + + # start up the session proxy + self.sessionproxy = SessionProxy() + + client.set_disconnect_callback(self.on_client_disconnect) + + # Set the interactive flag to indicate where we should print the output + self.interactive = True + self._commands = cmds + self.coreconfig = CoreConfig() + + def start_ui(self): + """Start the console UI. + + Note: When running console UI reactor.run() will be called which + effectively blocks this function making the return value + insignificant. However, when running unit tests, the reacor is + replaced by a mock object, leaving the return deferred object + necessary for the tests to run properly. + + Returns: + Deferred: If valid commands are provided, a deferred that fires when + all commands are executed. Else None is returned. + """ + if self.options.parsed_cmds: + # Non-Interactive mode + self.interactive = False + if not self._commands: + print('No valid console commands found') + return + + deferred = self.exec_args(self.options) + reactor.run() + return deferred + + # Interactive + + # We use the curses.wrapper function to prevent the console from getting + # messed up if an uncaught exception is experienced. + try: + from curses import wrapper + except ImportError: + wrapper = None + + if deluge.common.windows_check() and not wrapper: + print( + """\nDeluge-console does not run in interactive mode on Windows. \n +Please use commands from the command line, e.g.:\n +deluge-console.exe help +deluge-console.exe info +deluge-console.exe "add --help" +deluge-console.exe "add -p c:\\mytorrents c:\\new.torrent" +""" + ) + + # We don't ever want log output to terminal when running in + # interactive mode, so insert a dummy here + self.log_stream.out = MockConsoleLog() + + # Set Esc key delay to 0 to avoid a very annoying delay + # due to curses waiting in case of other key are pressed + # after ESC is pressed + os.environ.setdefault('ESCDELAY', '0') + + wrapper(self.run) + + @maybe_coroutine + async def quit(self): + if client.connected(): + await client.disconnect() + + try: + reactor.stop() + except error.ReactorNotRunning: + pass + + @maybe_coroutine + async def exec_args(self, options): + """Execute console commands from command line.""" + from deluge.ui.console.cmdline.command import Commander + + commander = Commander(self._commands) + try: + if not self.interactive and options.parsed_cmds[0].command == 'connect': + await commander.exec_command(options.parsed_cmds.pop(0)) + else: + daemon_options = ( + options.daemon_addr, + options.daemon_port, + options.daemon_user, + options.daemon_pass, + ) + log.info( + 'Connect: host=%s, port=%s, username=%s', + *daemon_options[0:3], + ) + await client.connect(*daemon_options) + except Exception as reason: + print( + 'Could not connect to daemon: %s:%s\n %s' + % (options.daemon_addr, options.daemon_port, reason) + ) + commander.do_command('quit') + + await self.start_console() + # Wait for RPCs in start() to finish before processing commands. + await self.started_deferred + + for cmd in options.parsed_cmds: + if cmd.command in ('quit', 'exit'): + break + await commander.exec_command(cmd) + + commander.do_command('quit') + + def run(self, stdscr): + """This method is called by the curses.wrapper to start the mainloop and screen. + + Args: + stdscr (_curses.curses window): curses screen passed in from curses.wrapper. + + """ + # We want to do an interactive session, so start up the curses screen and + # pass it the function that handles commands + colors.init_colors() + self.stdscr = stdscr + self.config = ConfigManager( + 'console.conf', defaults=DEFAULT_CONSOLE_PREFS, file_version=2 + ) + self.config.run_converter((0, 1), 2, migrate_1_to_2) + + self.statusbars = StatusBars() + from deluge.ui.console.modes.connectionmanager import ConnectionManager + + self.register_mode(ConnectionManager(stdscr, self.encoding), set_mode=True) + + torrentlist = self.register_mode(TorrentList(self.stdscr, self.encoding)) + self.register_mode(CmdLine(self.stdscr, self.encoding)) + self.register_mode(EventView(torrentlist, self.stdscr, self.encoding)) + self.register_mode( + TorrentDetail(torrentlist, self.stdscr, self.config, self.encoding) + ) + self.register_mode( + Preferences(torrentlist, self.stdscr, self.config, self.encoding) + ) + self.register_mode( + AddTorrents(torrentlist, self.stdscr, self.config, self.encoding) + ) + + self.eventlog = EventLog() + + self.active_mode.topbar = ( + '{!status!}Deluge ' + deluge.common.get_version() + ' Console' + ) + self.active_mode.bottombar = '{!status!}' + self.active_mode.refresh() + # Start the twisted mainloop + reactor.run() + + @overrides(TermResizeHandler) + def on_resize(self, *args): + rows, cols = super().on_resize(*args) + for mode in self.modes: + self.modes[mode].on_resize(rows, cols) + + def register_mode(self, mode, set_mode=False): + self.modes[mode.mode_name] = mode + if set_mode: + self.set_mode(mode.mode_name) + return mode + + def set_mode(self, mode_name, refresh=False): + log.debug('Setting console mode: %s', mode_name) + mode = self.modes.get(mode_name, None) + if mode is None: + log.error('Non-existent mode requested: %s', mode_name) + return + self.stdscr.erase() + + if self.active_mode: + self.active_mode.pause() + d = component.pause([self.active_mode.mode_name]) + + def on_mode_paused(result, mode, *args): + from deluge.ui.console.widgets.popup import PopupsHandler + + if isinstance(mode, PopupsHandler): + if mode.popup is not None: + # If popups are not removed, they are still referenced in the memory + # which can cause issues as the popup's screen will not be destroyed. + # This can lead to the popup border being visible for short periods + # while the current modes' screen is repainted. + log.error( + 'Mode "%s" still has popups available after being paused.' + ' Ensure all popups are removed on pause!', + mode.popup.title, + ) + + d.addCallback(on_mode_paused, self.active_mode) + reactor.removeReader(self.active_mode) + + self.active_mode = mode + self.statusbars.screen = self.active_mode + + # The Screen object is designed to run as a twisted reader so that it + # can use twisted's select poll for non-blocking user input. + reactor.addReader(self.active_mode) + self.stdscr.clear() + + if self.active_mode._component_state == 'Stopped': + component.start([self.active_mode.mode_name]) + else: + component.resume([self.active_mode.mode_name]) + + mode.resume() + if refresh: + mode.refresh() + return mode + + def switch_mode(self, func, error_smg): + def on_stop(arg): + if arg and True in arg[0]: + func() + else: + self.messages.append(('Error', error_smg)) + + component.stop(['TorrentList']).addCallback(on_stop) + + def is_active_mode(self, mode): + return mode == self.active_mode + + @maybe_coroutine + async def start_components(self): + if not self.interactive: + return await component.start(['SessionProxy', 'ConsoleUI', 'CoreConfig']) + + await component.start() + component.pause( + [ + 'TorrentList', + 'EventView', + 'AddTorrents', + 'TorrentDetail', + 'Preferences', + ] + ) + + @maybe_coroutine + async def start_console(self): + self.started_deferred = defer.Deferred() + + if self.initialized: + await component.stop(['SessionProxy']) + await component.start(['SessionProxy']) + else: + self.initialized = True + await self.start_components() + + @maybe_coroutine + async def start(self): + result = await client.core.get_session_state() + # Maintain a list of (torrent_id, name) for use in tab completion + self.torrents = [] + self.events = [] + + torrents = await client.core.get_torrents_status({'id': result}, ['name']) + for torrent_id, status in torrents.items(): + self.torrents.append((torrent_id, status['name'])) + + self.started_deferred.callback(True) + + # Register event handlers to keep the torrent list up-to-date + client.register_event_handler('TorrentAddedEvent', self.on_torrent_added) + client.register_event_handler('TorrentRemovedEvent', self.on_torrent_removed) + + @defer.inlineCallbacks + def on_torrent_added(self, event, from_state=False): + status = yield client.core.get_torrent_status(event, ['name']) + self.torrents.append((event, status['name'])) + + def on_torrent_removed(self, event): + for index, (tid, name) in enumerate(self.torrents): + if event == tid: + del self.torrents[index] + + def match_torrents(self, strings): + return list( + {torrent for string in strings for torrent in self.match_torrent(string)} + ) + + def match_torrent(self, string): + """ + Returns a list of torrent_id matches for the string. It will search both + torrent_ids and torrent names, but will only return torrent_ids. + + :param string: str, the string to match on + + :returns: list of matching torrent_ids. Will return an empty list if + no matches are found. + + """ + deluge.common.decode_bytes(string, self.encoding) + + if string == '*' or string == '': + return [tid for tid, name in self.torrents] + + match_func = '__eq__' + if string.startswith('*'): + string = string[1:] + match_func = 'endswith' + if string.endswith('*'): + match_func = '__contains__' if match_func == 'endswith' else 'startswith' + string = string[:-1] + + matches = [] + for tid, name in self.torrents: + deluge.common.decode_bytes(name, self.encoding) + if getattr(tid, match_func, None)(string) or getattr( + name, match_func, None + )(string): + matches.append(tid) + return matches + + def get_torrent_name(self, torrent_id): + for tid, name in self.torrents: + if torrent_id == tid: + return name + return None + + def set_batch_write(self, batch): + if self.interactive and isinstance( + self.active_mode, deluge.ui.console.modes.cmdline.CmdLine + ): + return self.active_mode.set_batch_write(batch) + + def tab_complete_torrent(self, line): + if self.interactive and isinstance( + self.active_mode, deluge.ui.console.modes.cmdline.CmdLine + ): + return self.active_mode.tab_complete_torrent(line) + + def tab_complete_path( + self, line, path_type='file', ext='', sort='name', dirs_first=True + ): + if self.interactive and isinstance( + self.active_mode, deluge.ui.console.modes.cmdline.CmdLine + ): + return self.active_mode.tab_complete_path( + line, path_type=path_type, ext=ext, sort=sort, dirs_first=dirs_first + ) + + def on_client_disconnect(self): + component.stop() + + def write(self, s): + if self.interactive: + if isinstance(self.active_mode, deluge.ui.console.modes.cmdline.CmdLine): + self.active_mode.write(s) + else: + component.get('CmdLine').add_line(s, False) + self.events.append(s) + else: + print(colors.strip_colors(s)) + + def write_event(self, s): + if self.interactive: + if isinstance(self.active_mode, deluge.ui.console.modes.cmdline.CmdLine): + self.events.append(s) + self.active_mode.write(s) + else: + component.get('CmdLine').add_line(s, False) + self.events.append(s) + else: + print(colors.strip_colors(s)) diff --git a/deluge/ui/console/modes/__init__.py b/deluge/ui/console/modes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deluge/ui/console/modes/add_util.py b/deluge/ui/console/modes/add_util.py new file mode 100644 index 0000000..9d29a1f --- /dev/null +++ b/deluge/ui/console/modes/add_util.py @@ -0,0 +1,92 @@ +# +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# Copyright (C) 2011 Nick Lanham +# +# 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 glob +import logging +import os +from base64 import b64encode + +import deluge.common +from deluge.ui.client import client +from deluge.ui.common import TorrentInfo + +log = logging.getLogger(__name__) + + +def _bracket_fixup(path): + if path.find('[') == -1 and path.find(']') == -1: + return path + sentinal = 256 + while path.find(chr(sentinal)) != -1: + sentinal += 1 + if sentinal > 65535: + log.error( + 'Cannot fix brackets in path, path contains all possible sentinal characters' + ) + return path + newpath = path.replace(']', chr(sentinal)) + newpath = newpath.replace('[', '[[]') + newpath = newpath.replace(chr(sentinal), '[]]') + return newpath + + +def add_torrent(t_file, options, success_cb, fail_cb, ress): + t_options = {} + if options['path']: + t_options['download_location'] = os.path.expanduser(options['path']) + t_options['add_paused'] = options['add_paused'] + + is_url = (options['path_type'] != 1) and ( + deluge.common.is_url(t_file) or options['path_type'] == 2 + ) + is_magnet = ( + not (is_url) and (options['path_type'] != 1) and deluge.common.is_magnet(t_file) + ) + + if is_url or is_magnet: + files = [t_file] + else: + files = glob.glob(_bracket_fixup(t_file)) + num_files = len(files) + ress['total'] = num_files + + if num_files <= 0: + fail_cb('Does not exist', t_file, ress) + + for f in files: + if is_url: + client.core.add_torrent_url(f, t_options).addCallback( + success_cb, f, ress + ).addErrback(fail_cb, f, ress) + elif is_magnet: + client.core.add_torrent_magnet(f, t_options).addCallback( + success_cb, f, ress + ).addErrback(fail_cb, f, ress) + else: + if not os.path.exists(f): + fail_cb('Does not exist', f, ress) + continue + if not os.path.isfile(f): + fail_cb('Is a directory', f, ress) + continue + + try: + TorrentInfo(f) + except Exception as ex: + fail_cb(ex.message, f, ress) + continue + + filename = os.path.split(f)[-1] + with open(f, 'rb') as _file: + filedump = b64encode(_file.read()) + + client.core.add_torrent_file_async( + filename, filedump, t_options + ).addCallback(success_cb, f, ress).addErrback(fail_cb, f, ress) diff --git a/deluge/ui/console/modes/addtorrents.py b/deluge/ui/console/modes/addtorrents.py new file mode 100644 index 0000000..217b63d --- /dev/null +++ b/deluge/ui/console/modes/addtorrents.py @@ -0,0 +1,536 @@ +# +# Copyright (C) 2012 Arek Stefański +# +# 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 base64 import b64encode + +import deluge.common +import deluge.component as component +from deluge.decorators import overrides +from deluge.ui.client import client +from deluge.ui.console.modes.basemode import BaseMode +from deluge.ui.console.modes.torrentlist.add_torrents_popup import report_add_status +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.utils import format_utils +from deluge.ui.console.widgets.popup import InputPopup, MessagePopup + +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 allows you to browse and add torrent files located on your \ +hard disk. Currently selected file is highlighted with a white background. +You can change the selected file using the up/down arrows or the \ +PgUp/PgDown keys. Home and End keys go to the first and last file \ +in current directory respectively. + +Select files with the 'm' key. Use 'M' for multi-selection. Press \ +enter key to add them to session. + +{!info!}'h'{!normal!} - Show this help + +{!info!}'<'{!normal!} and {!info!}'>'{!normal!} - Change sort column and/or order + +{!info!}'m'{!normal!} - Mark or unmark currently highlighted file +{!info!}'M'{!normal!} - Mark all files between current file and last selection. +{!info!}'c'{!normal!} - Clear selection. + +{!info!}Left Arrow{!normal!} - Go up in directory hierarchy. +{!info!}Right Arrow{!normal!} - Enter currently highlighted folder. + +{!info!}Enter{!normal!} - Enter currently highlighted folder or add torrents \ +if a file is highlighted + +{!info!}'q'{!normal!} - Go back to torrent overview +""" + + +class AddTorrents(BaseMode): + def __init__(self, parent_mode, stdscr, console_config, encoding=None): + self.console_config = console_config + self.parent_mode = parent_mode + self.popup = None + self.view_offset = 0 + self.cursel = 0 + self.marked = set() + self.last_mark = -1 + + path = os.path.expanduser(self.console_config['addtorrents']['last_path']) + + self.path_stack = ['/'] + path.strip('/').split('/') + self.path_stack_pos = len(self.path_stack) + self.listing_files = [] + self.listing_dirs = [] + + self.raw_rows = [] + self.raw_rows_files = [] + self.raw_rows_dirs = [] + self.formatted_rows = [] + + self.sort_column = self.console_config['addtorrents']['sort_column'] + self.reverse_sort = self.console_config['addtorrents']['reverse_sort'] + + BaseMode.__init__(self, stdscr, encoding) + + self._listing_space = self.rows - 5 + self.__refresh_listing() + + util.safe_curs_set(util.Curser.INVISIBLE) + self.stdscr.notimeout(0) + + @overrides(component.Component) + def start(self): + pass + + @overrides(component.Component) + def update(self): + pass + + def __refresh_listing(self): + path = os.path.join(*self.path_stack[: self.path_stack_pos]) + + listing = os.listdir(path) + + self.listing_files = [] + self.listing_dirs = [] + + self.raw_rows = [] + self.raw_rows_files = [] + self.raw_rows_dirs = [] + self.formatted_rows = [] + + for f in listing: + if os.path.isdir(os.path.join(path, f)): + if self.console_config['addtorrents']['show_hidden_folders']: + self.listing_dirs.append(f) + elif f[0] != '.': + self.listing_dirs.append(f) + elif os.path.isfile(os.path.join(path, f)): + if self.console_config['addtorrents']['show_misc_files']: + self.listing_files.append(f) + elif f.endswith('.torrent'): + self.listing_files.append(f) + + for dirname in self.listing_dirs: + row = [] + full_path = os.path.join(path, dirname) + try: + size = len(os.listdir(full_path)) + except OSError: + size = -1 + time = os.stat(full_path).st_mtime + + row = [dirname, size, time, full_path, 1] + + self.raw_rows.append(row) + self.raw_rows_dirs.append(row) + + # Highlight the directory we came from + if self.path_stack_pos < len(self.path_stack): + selected = self.path_stack[self.path_stack_pos] + ld = sorted(self.listing_dirs, key=lambda n: n.lower()) + c = ld.index(selected) + self.cursel = c + + if (self.view_offset + self._listing_space) <= self.cursel: + self.view_offset = self.cursel - self._listing_space + + for filename in self.listing_files: + row = [] + full_path = os.path.join(path, filename) + size = os.stat(full_path).st_size + time = os.stat(full_path).st_mtime + + row = [filename, size, time, full_path, 0] + + self.raw_rows.append(row) + self.raw_rows_files.append(row) + + self.__sort_rows() + + def __sort_rows(self): + self.console_config['addtorrents']['sort_column'] = self.sort_column + self.console_config['addtorrents']['reverse_sort'] = self.reverse_sort + self.console_config.save() + + self.raw_rows_dirs.sort(key=lambda r: r[0].lower()) + + if self.sort_column == 'name': + self.raw_rows_files.sort( + key=lambda r: r[0].lower(), reverse=self.reverse_sort + ) + elif self.sort_column == 'date': + self.raw_rows_files.sort(key=lambda r: r[2], reverse=self.reverse_sort) + self.raw_rows = self.raw_rows_dirs + self.raw_rows_files + self.__refresh_rows() + + def __refresh_rows(self): + self.formatted_rows = [] + + for row in self.raw_rows: + filename = deluge.common.decode_bytes(row[0]) + size = row[1] + time = row[2] + + if row[4]: + if size != -1: + size_str = '%i items' % size + else: + size_str = ' unknown' + + cols = [filename, size_str, deluge.common.fdate(time)] + widths = [self.cols - 35, 12, 23] + self.formatted_rows.append(format_utils.format_row(cols, widths)) + else: + # Size of .torrent file itself couldn't matter less so we'll leave it out + cols = [filename, deluge.common.fdate(time)] + widths = [self.cols - 23, 23] + self.formatted_rows.append(format_utils.format_row(cols, widths)) + + def scroll_list_up(self, distance): + self.cursel -= distance + if self.cursel < 0: + self.cursel = 0 + + if self.cursel < self.view_offset + 1: + self.view_offset = max(self.cursel - 1, 0) + + def scroll_list_down(self, distance): + self.cursel += distance + if self.cursel >= len(self.formatted_rows): + self.cursel = len(self.formatted_rows) - 1 + + if (self.view_offset + self._listing_space) <= self.cursel + 1: + self.view_offset = self.cursel - self._listing_space + 1 + + def set_popup(self, pu): + self.popup = pu + self.refresh() + + @overrides(BaseMode) + def on_resize(self, rows, cols): + BaseMode.on_resize(self, rows, cols) + if self.popup: + self.popup.handle_resize() + self._listing_space = self.rows - 5 + self.refresh() + + def refresh(self, lines=None): + if self.mode_paused(): + return + + # Update the status bars + self.stdscr.erase() + self.draw_statusbars() + + off = 1 + + # Render breadcrumbs + s = 'Location: ' + for i, e in enumerate(self.path_stack): + if e == '/': + if i == self.path_stack_pos - 1: + s += '{!black,red,bold!}root' + else: + s += '{!red,black,bold!}root' + else: + if i == self.path_stack_pos - 1: + s += '{!black,white,bold!}%s' % e + else: + s += '{!white,black,bold!}%s' % e + + if e != len(self.path_stack) - 1: + s += '{!white,black!}/' + + self.add_string(off, s) + off += 1 + + # Render header + cols = ['Name', 'Contents', 'Modification time'] + widths = [self.cols - 35, 12, 23] + s = '' + for i, (c, w) in enumerate(zip(cols, widths)): + cn = '' + if i == 0: + cn = 'name' + elif i == 2: + cn = 'date' + + if cn == self.sort_column: + s += '{!black,green,bold!}' + c.ljust(w - 2) + if self.reverse_sort: + s += '^ ' + else: + s += 'v ' + else: + s += '{!green,black,bold!}' + c.ljust(w) + self.add_string(off, s) + off += 1 + + # Render files and folders + for i, row in enumerate(self.formatted_rows[self.view_offset :]): + i += self.view_offset + # It's a folder + color_string = '' + if self.raw_rows[i][4]: + if self.raw_rows[i][1] == -1: + if i == self.cursel: + color_string = '{!black,red,bold!}' + else: + color_string = '{!red,black!}' + else: + if i == self.cursel: + color_string = '{!black,cyan,bold!}' + else: + color_string = '{!cyan,black!}' + + elif i == self.cursel: + if self.raw_rows[i][0] in self.marked: + color_string = '{!blue,white,bold!}' + else: + color_string = '{!black,white,bold!}' + elif self.raw_rows[i][0] in self.marked: + color_string = '{!white,blue,bold!}' + + self.add_string(off, color_string + row) + off += 1 + + if off > self.rows - 2: + break + + if not component.get('ConsoleUI').is_active_mode(self): + return + + self.stdscr.noutrefresh() + + if self.popup: + self.popup.refresh() + + curses.doupdate() + + def back_to_overview(self): + self.parent_mode.go_top = False + component.get('ConsoleUI').set_mode(self.parent_mode.mode_name) + + def _perform_action(self): + if self.cursel < len(self.listing_dirs): + self._enter_dir() + else: + s = self.raw_rows[self.cursel][0] + if s not in self.marked: + self.last_mark = self.cursel + self.marked.add(s) + self._show_add_dialog() + + def _enter_dir(self): + # Enter currently selected directory + dirname = self.raw_rows[self.cursel][0] + new_dir = self.path_stack_pos >= len(self.path_stack) + new_dir = new_dir or (dirname != self.path_stack[self.path_stack_pos]) + if new_dir: + self.path_stack = self.path_stack[: self.path_stack_pos] + self.path_stack.append(dirname) + + path = os.path.join(*self.path_stack[: self.path_stack_pos + 1]) + + if not os.access(path, os.R_OK): + self.path_stack = self.path_stack[: self.path_stack_pos] + self.popup = MessagePopup( + self, 'Error', '{!error!}Access denied: %s' % path + ) + self.__refresh_listing() + return + + self.path_stack_pos += 1 + + self.view_offset = 0 + self.cursel = 0 + self.last_mark = -1 + self.marked = set() + + self.__refresh_listing() + + def _show_add_dialog(self): + def _do_add(result, **kwargs): + ress = {'succ': 0, 'fail': 0, 'total': len(self.marked), 'fmsg': []} + + def fail_cb(msg, t_file, ress): + log.debug('failed to add torrent: %s: %s', t_file, msg) + ress['fail'] += 1 + ress['fmsg'].append(f'{{!input!}} * {t_file}: {{!error!}}{msg}') + if (ress['succ'] + ress['fail']) >= ress['total']: + report_add_status( + component.get('TorrentList'), + ress['succ'], + ress['fail'], + ress['fmsg'], + ) + + def success_cb(tid, t_file, ress): + if tid: + log.debug('added torrent: %s (%s)', t_file, tid) + ress['succ'] += 1 + if (ress['succ'] + ress['fail']) >= ress['total']: + report_add_status( + component.get('TorrentList'), + ress['succ'], + ress['fail'], + ress['fmsg'], + ) + else: + fail_cb('Already in session (probably)', t_file, ress) + + for m in self.marked: + filename = m + directory = os.path.join(*self.path_stack[: self.path_stack_pos]) + path = os.path.join(directory, filename) + with open(path, 'rb') as _file: + filedump = b64encode(_file.read()) + t_options = {} + if result['location']['value']: + t_options['download_location'] = result['location']['value'] + t_options['add_paused'] = result['add_paused']['value'] + + d = client.core.add_torrent_file_async(filename, filedump, t_options) + d.addCallback(success_cb, filename, ress) + d.addErrback(fail_cb, filename, ress) + + self.console_config['addtorrents']['last_path'] = os.path.join( + *self.path_stack[: self.path_stack_pos] + ) + self.console_config.save() + + self.back_to_overview() + + config = component.get('ConsoleUI').coreconfig + if config['add_paused']: + ap = 0 + else: + ap = 1 + self.popup = InputPopup( + self, 'Add Torrents (Esc to cancel)', close_cb=_do_add, height_req=17 + ) + + msg = 'Adding torrent files:' + for i, m in enumerate(self.marked): + name = m + msg += '\n * {!input!}%s' % name + if i == 5: + if i < len(self.marked): + msg += '\n {!red!}And %i more' % (len(self.marked) - 5) + break + self.popup.add_text(msg) + self.popup.add_spaces(1) + + self.popup.add_text_input( + 'location', 'Download Folder:', config['download_location'], complete=True + ) + self.popup.add_select_input( + 'add_paused', 'Add Paused:', ['Yes', 'No'], [True, False], ap + ) + + def _go_up(self): + # Go up in directory hierarchy + if self.path_stack_pos > 1: + self.path_stack_pos -= 1 + + self.view_offset = 0 + self.cursel = 0 + self.last_mark = -1 + self.marked = set() + + self.__refresh_listing() + + def read_input(self): + c = self.stdscr.getch() + + if self.popup: + if self.popup.handle_read(c): + self.popup = None + self.refresh() + return + + if util.is_printable_chr(c): + if chr(c) == 'Q': + component.get('ConsoleUI').quit() + elif chr(c) == 'q': + self.back_to_overview() + return + + # Navigate the torrent list + if c == curses.KEY_UP: + self.scroll_list_up(1) + elif c == curses.KEY_PPAGE: + self.scroll_list_up(self.rows // 2) + elif c == curses.KEY_HOME: + self.scroll_list_up(len(self.formatted_rows)) + elif c == curses.KEY_DOWN: + self.scroll_list_down(1) + elif c == curses.KEY_NPAGE: + self.scroll_list_down(self.rows // 2) + elif c == curses.KEY_END: + self.scroll_list_down(len(self.formatted_rows)) + elif c == curses.KEY_RIGHT: + if self.cursel < len(self.listing_dirs): + self._enter_dir() + elif c == curses.KEY_LEFT: + self._go_up() + elif c in [curses.KEY_ENTER, util.KEY_ENTER2]: + self._perform_action() + elif c == util.KEY_ESC: + self.back_to_overview() + else: + if util.is_printable_chr(c): + if chr(c) == 'h': + self.popup = MessagePopup(self, 'Help', HELP_STR, width_req=0.75) + elif chr(c) == '>': + if self.sort_column == 'date': + self.reverse_sort = not self.reverse_sort + else: + self.sort_column = 'date' + self.reverse_sort = True + self.__sort_rows() + elif chr(c) == '<': + if self.sort_column == 'name': + self.reverse_sort = not self.reverse_sort + else: + self.sort_column = 'name' + self.reverse_sort = False + self.__sort_rows() + elif chr(c) == 'm': + s = self.raw_rows[self.cursel][0] + if s in self.marked: + self.marked.remove(s) + else: + self.marked.add(s) + + self.last_mark = self.cursel + elif chr(c) == 'j': + self.scroll_list_down(1) + elif chr(c) == 'k': + self.scroll_list_up(1) + elif chr(c) == 'M': + if self.last_mark != -1: + if self.last_mark > self.cursel: + m = list(range(self.cursel, self.last_mark)) + else: + m = list(range(self.last_mark, self.cursel + 1)) + + for i in m: + s = self.raw_rows[i][0] + self.marked.add(s) + elif chr(c) == 'c': + self.marked.clear() + + self.refresh() diff --git a/deluge/ui/console/modes/basemode.py b/deluge/ui/console/modes/basemode.py new file mode 100644 index 0000000..a8ab1db --- /dev/null +++ b/deluge/ui/console/modes/basemode.py @@ -0,0 +1,360 @@ +# +# Copyright (C) 2011 Nick Lanham +# Copyright (C) 2009 Andrew Resch +# +# 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 signal +import struct +import sys +from typing import Tuple + +import deluge.component as component +import deluge.ui.console.utils.colors as colors +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.utils.format_utils import remove_formatting + +try: + import curses + import curses.panel +except ImportError: + pass + +try: + from fcntl import ioctl + from termios import TIOCGWINSZ +except ImportError: + pass + + +log = logging.getLogger(__name__) + + +class InputKeyHandler: + def __init__(self): + self._input_result = None + + def set_input_result(self, result): + self._input_result = result + + def get_input_result(self): + result = self._input_result + self._input_result = None + return result + + def handle_read(self, c): + """Handle a character read from curses screen + + Returns: + int: One of the constants defined in util.curses_util.ReadState. + ReadState.IGNORED: The key was not handled. Further processing should continue. + ReadState.READ: The key was read and processed. Do no further processing + ReadState.CHANGED: The key was read and processed. Internal state was changed + leaving data to be read by the caller. + + """ + return util.ReadState.IGNORED + + +class TermResizeHandler: + def __init__(self): + try: + signal.signal(signal.SIGWINCH, self.on_resize) + except ValueError as ex: + log.debug('TermResize unavailable, unable to catch SIGWINCH signal: %s', ex) + except AttributeError as ex: + log.debug('TermResize unavailable, no SIGWINCH signal on Windows: %s', ex) + + @staticmethod + def get_window_size(fd: int = 0) -> Tuple[int, int]: + """Return the tty window size as row, col.""" + return struct.unpack('4h', ioctl(fd, TIOCGWINSZ, b'\x00' * 8))[0:2] + + def on_resize(self, _signum, _frame): + """Handler for SIGWINCH when terminal changes size""" + rows, cols = self.get_window_size() + curses.resizeterm(rows, cols) + return rows, cols + + +class CursesStdIO: + """ + fake fd to be registered as a reader with the twisted reactor. + Curses classes needing input should extend this + """ + + def fileno(self): + """We want to select on FD 0""" + return 0 + + def doRead(self): # NOQA: N802 + """called when input is ready""" + pass + + def logPrefix(self): # NOQA: N802 + return 'CursesClient' + + +class BaseMode(CursesStdIO, component.Component): + def __init__( + self, stdscr, encoding=None, do_refresh=True, mode_name=None, depend=None + ): + """ + A mode that provides a curses screen designed to run as a reader in a twisted reactor. + This mode doesn't do much, just shows status bars and "Base Mode" on the screen + + Modes should subclass this and provide overrides for: + + do_read(self) - Handle user input + refresh(self) - draw the mode to the screen + add_string(self, row, string) - add a string of text to be displayed. + see method for detailed info + + The init method of a subclass *must* call BaseMode.__init__ + + Useful fields after calling BaseMode.__init__: + self.stdscr - the curses screen + self.rows - # of rows on the curses screen + self.cols - # of cols on the curses screen + self.topbar - top statusbar + self.bottombar - bottom statusbar + """ + self.mode_name = mode_name if mode_name else self.__class__.__name__ + component.Component.__init__(self, self.mode_name, 1, depend=depend) + self.stdscr = stdscr + # Make the input calls non-blocking + self.stdscr.nodelay(1) + + self.paused = False + # Strings for the 2 status bars + self.statusbars = component.get('StatusBars') + self.help_hstr = '{!status!} Press {!magenta,blue,bold!}[h]{!status!} for help' + + # Keep track of the screen size + self.rows, self.cols = self.stdscr.getmaxyx() + + if not encoding: + self.encoding = sys.getdefaultencoding() + else: + self.encoding = encoding + + # Do a refresh right away to draw the screen + if do_refresh: + self.refresh() + + def on_resize(self, rows, cols): + self.rows, self.cols = rows, cols + + def connectionLost(self, reason): # NOQA: N802 + self.close() + + def add_string(self, row, string, scr=None, **kwargs): + if scr: + screen = scr + else: + screen = self.stdscr + + return add_string(row, string, screen, self.encoding, **kwargs) + + def draw_statusbars( + self, + top_row=0, + bottom_row=-1, + topbar=None, + bottombar=None, + bottombar_help=True, + scr=None, + ): + self.add_string(top_row, topbar if topbar else self.statusbars.topbar, scr=scr) + bottombar = bottombar if bottombar else self.statusbars.bottombar + if bottombar_help: + if bottombar_help is True: + bottombar_help = self.help_hstr + bottombar += ( + ' ' + * ( + self.cols + - len(remove_formatting(bottombar)) + - len(remove_formatting(bottombar_help)) + ) + + bottombar_help + ) + self.add_string(self.rows + bottom_row, bottombar, scr=scr) + + # This mode doesn't do anything with popups + def set_popup(self, popup): + pass + + def pause(self): + self.paused = True + + def mode_paused(self): + return self.paused + + def resume(self): + self.paused = False + self.refresh() + + def refresh(self): + """ + Refreshes the screen. + Updates the lines based on the`:attr:lines` based on the `:attr:display_lines_offset` + attribute and the status bars. + """ + self.stdscr.erase() + self.draw_statusbars() + # Update the status bars + + self.add_string(1, '{!info!}Base Mode (or subclass has not overridden refresh)') + + self.stdscr.redrawwin() + self.stdscr.refresh() + + def doRead(self): # NOQA: N802 + """ + Called when there is data to be read, ie, input from the keyboard. + """ + # We wrap this function to catch exceptions and shutdown the mainloop + try: + self.read_input() + except Exception as ex: # pylint: disable=broad-except + log.exception(ex) + + def read_input(self): + # Read the character + self.stdscr.getch() + self.stdscr.refresh() + + def close(self): + """ + Clean up the curses stuff on exit. + """ + curses.nocbreak() + self.stdscr.keypad(0) + curses.echo() + curses.endwin() + + +def add_string( + row, fstring, screen, encoding, col=0, pad=True, pad_char=' ', trim='..', leaveok=0 +): + """ + Adds a string to the desired `:param:row`. + + Args: + row(int): the row number to write the string + row(int): the row number to write the string + fstring(str): the (formatted) string of text to add + scr(curses.window): optional window to add string to instead of self.stdscr + col(int): optional starting column offset + pad(bool): optional bool if the string should be padded out to the width of the screen + trim(bool): optional bool if the string should be trimmed if it is too wide for the screen + + The text can be formatted with color using the following format: + + "{!fg, bg, attributes, ...!}" + + See: http://docs.python.org/library/curses.html#constants for attributes. + + Alternatively, it can use some built-in scheme for coloring. + See colors.py for built-in schemes. + + "{!scheme!}" + + Examples: + + "{!blue, black, bold!}My Text is {!white, black!}cool" + "{!info!}I am some info text!" + "{!error!}Uh oh!" + + Returns: + int: the next row + + """ + try: + parsed = colors.parse_color_string(fstring) + except colors.BadColorString as ex: + log.error('Cannot add bad color string %s: %s', fstring, ex) + return + + if leaveok: + screen.leaveok(leaveok) + + max_y, max_x = screen.getmaxyx() + for index, (color, string) in enumerate(parsed): + # Skip printing chars beyond max_x + if col >= max_x: + break + + if index + 1 == len(parsed) and pad: + # This is the last string so lets append some padding to it + string += pad_char * (max_x - (col + len(string))) + + if col + len(string) > max_x: + remaining_chrs = max(0, max_x - col) + if trim: + string = string[0 : max(0, remaining_chrs - len(trim))] + trim + else: + string = string[0:remaining_chrs] + + try: + screen.addstr(row, col, string.encode(encoding), color) + except curses.error: + # Ignore exception for writing offscreen. + pass + + col += len(string) + + if leaveok: + screen.leaveok(0) + + return row + 1 + + +def mkpanel(color, rows, cols, tly, tlx): + win = curses.newwin(rows, cols, tly, tlx) + pan = curses.panel.new_panel(win) + if curses.has_colors(): + win.bkgdset(ord(' '), curses.color_pair(color)) + else: + win.bkgdset(ord(' '), curses.A_BOLD) + return pan + + +def mkwin(color, rows, cols, tly, tlx): + win = curses.newwin(rows, cols, tly, tlx) + if curses.has_colors(): + win.bkgdset(ord(' '), curses.color_pair(color)) + else: + win.bkgdset(ord(' '), curses.A_BOLD) + return win + + +def mkpad(color, rows, cols): + win = curses.newpad(rows, cols) + if curses.has_colors(): + win.bkgdset(ord(' '), curses.color_pair(color)) + else: + win.bkgdset(ord(' '), curses.A_BOLD) + return win + + +def move_cursor(screen, row, col): + try: + screen.move(row, col) + except curses.error as ex: + import traceback + + log.warning( + 'Error on screen.move(%s, %s): (curses.LINES: %s, curses.COLS: %s) Error: %s\nStack: %s', + row, + col, + curses.LINES, + curses.COLS, + ex, + ''.join(traceback.format_stack()), + ) diff --git a/deluge/ui/console/modes/cmdline.py b/deluge/ui/console/modes/cmdline.py new file mode 100644 index 0000000..7b0ff2d --- /dev/null +++ b/deluge/ui/console/modes/cmdline.py @@ -0,0 +1,845 @@ +# +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# 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 +import re + +import deluge.component as component +import deluge.configmanager +from deluge.decorators import overrides +from deluge.ui.console.cmdline.command import Commander +from deluge.ui.console.modes.basemode import BaseMode, move_cursor +from deluge.ui.console.utils import colors +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.utils.format_utils import ( + delete_alt_backspace, + remove_formatting, + strwidth, +) + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) +LINES_BUFFER_SIZE = 5000 +INPUT_HISTORY_SIZE = 500 +MAX_HISTFILE_SIZE = 2000 + + +def complete_line(line, possible_matches): + """Find the common prefix of possible matches. + + Proritizing matching-case elements. + """ + + if not possible_matches: + return line + + line = line.replace(r'\ ', ' ') + + matches1 = [] + matches2 = [] + + for match in possible_matches: + match = remove_formatting(match) + match = match.replace(r'\ ', ' ') + m1, m2 = '', '' + for i, c in enumerate(line): + if m1 and m2: + break + if not m1 and c != line[i]: + m1 = line[:i] + if not m2 and c.lower() != line[i].lower(): + m2 = line[:i] + if not m1: + matches1.append(match) + elif not m2: + matches2.append(match) + + possible_matches = matches1 + matches2 + + maxlen = 9001 + + for match in possible_matches[1:]: + for i, c in enumerate(match): + try: + if c.lower() != possible_matches[0][i].lower(): + maxlen = min(maxlen, i) + break + except IndexError: + maxlen = min(maxlen, i) + break + + return possible_matches[0][:maxlen].replace(' ', r'\ ') + + +def commonprefix(m): + """Returns the longest common leading component from list of pathnames.""" + if not m: + return '' + s1 = min(m) + s2 = max(m) + for i, c in enumerate(s1): + if c != s2[i]: + return s1[:i] + return s2 + + +class CmdLine(BaseMode, Commander): + def __init__(self, stdscr, encoding=None): + # Get a handle to the main console + self.console = component.get('ConsoleUI') + Commander.__init__(self, self.console._commands, interactive=True) + + self.batch_write = False + + # A list of strings to be displayed based on the offset (scroll) + self.lines = [] + # The offset to display lines + self.display_lines_offset = 0 + + # Holds the user input and is cleared on 'enter' + self.input = '' + self.input_incomplete = '' + + # Keep track of where the cursor is + self.input_cursor = 0 + # Keep a history of inputs + self.input_history = [] + self.input_history_index = 0 + + # Keep track of double- and multi-tabs + self.tab_count = 0 + + self.console_config = component.get('TorrentList').config + + # To avoid having to truncate the file every time we're writing + # or doing it on exit(and therefore relying on an error-less + # or in other words clean exit, we're going to have two files + # that we swap around based on length + config_dir = deluge.configmanager.get_config_dir() + self.history_file = [ + os.path.join(config_dir, 'cmd_line.hist1'), + os.path.join(config_dir, 'cmd_line.hist2'), + ] + self._hf_lines = [0, 0] + if self.console_config['cmdline']['save_command_history']: + try: + with open(self.history_file[0], encoding='utf8') as _file: + lines1 = _file.read().splitlines() + self._hf_lines[0] = len(lines1) + except OSError: + lines1 = [] + self._hf_lines[0] = 0 + + try: + with open(self.history_file[1], encoding='utf8') as _file: + lines2 = _file.read().splitlines() + self._hf_lines[1] = len(lines2) + except OSError: + lines2 = [] + self._hf_lines[1] = 0 + + # The non-full file is the active one + if self._hf_lines[0] > self._hf_lines[1]: + self.lines = lines1 + lines2 + else: + self.lines = lines2 + lines1 + + if len(self.lines) > MAX_HISTFILE_SIZE: + self.lines = self.lines[-MAX_HISTFILE_SIZE:] + + # Instead of having additional input history file, we can + # simply scan for lines beginning with ">>> " + for i, line in enumerate(self.lines): + line = remove_formatting(line) + if line.startswith('>>> '): + console_input = line[4:] + if self.console_config['cmdline']['ignore_duplicate_lines']: + if len(self.input_history) > 0: + if self.input_history[-1] != console_input: + self.input_history.append(console_input) + else: + self.input_history.append(console_input) + + self.input_history_index = len(self.input_history) + + # show the cursor + util.safe_curs_set(util.Curser.VERY_VISIBLE) + BaseMode.__init__(self, stdscr, encoding, depend=['SessionProxy']) + + @overrides(component.Component) + def update(self): + if not component.get('ConsoleUI').is_active_mode(self): + return + # Update just the status bars + self.draw_statusbars(bottom_row=-2, bottombar_help=False) + move_cursor(self.stdscr, self.rows - 1, min(self.input_cursor, curses.COLS - 1)) + self.stdscr.refresh() + + @overrides(BaseMode) + def pause(self): + self.stdscr.leaveok(0) + + @overrides(BaseMode) + def resume(self): + util.safe_curs_set(util.Curser.VERY_VISIBLE) + + @overrides(BaseMode) + def read_input(self): + # Read the character + c = self.stdscr.getch() + + # Either ESC or ALT+ + if c == util.KEY_ESC: + n = self.stdscr.getch() + if n == -1: + # Escape key + return + c = [c, n] + + # We remove the tab count if the key wasn't a tab + if c != util.KEY_TAB: + self.tab_count = 0 + + # We clear the input string and send it to the command parser on ENTER + if c in [curses.KEY_ENTER, util.KEY_ENTER2]: + if self.input: + if self.input.endswith('\\'): + self.input = self.input[:-1] + self.input_cursor -= 1 + self.add_line('{!yellow,black,bold!}>>>{!input!} %s' % self.input) + self.do_command(self.input) + if len(self.input_history) == INPUT_HISTORY_SIZE: + # Remove the oldest input history if the max history size + # is reached. + del self.input_history[0] + if self.console_config['cmdline']['ignore_duplicate_lines']: + if len(self.input_history) > 0: + if self.input_history[-1] != self.input: + self.input_history.append(self.input) + else: + self.input_history.append(self.input) + else: + self.input_history.append(self.input) + self.input_history_index = len(self.input_history) + self.input = '' + self.input_incomplete = '' + self.input_cursor = 0 + self.stdscr.refresh() + + # Run the tab completer function + elif c == util.KEY_TAB: + # Keep track of tab hit count to know when it's double-hit + self.tab_count += 1 + + if self.tab_completer: + # We only call the tab completer function if we're at the end of + # the input string on the cursor is on a space + if ( + self.input_cursor == len(self.input) + or self.input[self.input_cursor] == ' ' + ): + self.input, self.input_cursor = self.tab_completer( + self.input, self.input_cursor, self.tab_count + ) + + # We use the UP and DOWN keys to cycle through input history + elif c == curses.KEY_UP: + if self.input_history_index - 1 >= 0: + if self.input_history_index == len(self.input_history): + # We're moving from non-complete input so save it just incase + # we move back down to it. + self.input_incomplete = self.input + # Going back in the history + self.input_history_index -= 1 + self.input = self.input_history[self.input_history_index] + self.input_cursor = len(self.input) + elif c == curses.KEY_DOWN: + if self.input_history_index + 1 < len(self.input_history): + # Going forward in the history + self.input_history_index += 1 + self.input = self.input_history[self.input_history_index] + self.input_cursor = len(self.input) + elif self.input_history_index + 1 == len(self.input_history): + # We're moving back down to an incomplete input + self.input_history_index += 1 + self.input = self.input_incomplete + self.input_cursor = len(self.input) + + # Cursor movement + elif c == curses.KEY_LEFT: + if self.input_cursor: + self.input_cursor -= 1 + elif c == curses.KEY_RIGHT: + if self.input_cursor < len(self.input): + self.input_cursor += 1 + elif c == curses.KEY_HOME: + self.input_cursor = 0 + elif c == curses.KEY_END: + self.input_cursor = len(self.input) + + # Scrolling through buffer + elif c == curses.KEY_PPAGE: + self.display_lines_offset += self.rows - 3 + # We substract 3 for the unavailable lines and 1 extra due to len(self.lines) + if self.display_lines_offset > (len(self.lines) - 4 - self.rows): + self.display_lines_offset = len(self.lines) - 4 - self.rows + + self.refresh() + elif c == curses.KEY_NPAGE: + self.display_lines_offset -= self.rows - 3 + if self.display_lines_offset < 0: + self.display_lines_offset = 0 + self.refresh() + + # Delete a character in the input string based on cursor position + elif c in [curses.KEY_BACKSPACE, util.KEY_BACKSPACE2]: + if self.input and self.input_cursor > 0: + self.input = ( + self.input[: self.input_cursor - 1] + + self.input[self.input_cursor :] + ) + self.input_cursor -= 1 + # Delete a word when alt+backspace is pressed + elif c == [util.KEY_ESC, util.KEY_BACKSPACE2] or c == [ + util.KEY_ESC, + curses.KEY_BACKSPACE, + ]: + self.input, self.input_cursor = delete_alt_backspace( + self.input, self.input_cursor + ) + elif c == curses.KEY_DC: + if self.input and self.input_cursor < len(self.input): + self.input = ( + self.input[: self.input_cursor] + + self.input[self.input_cursor + 1 :] + ) + + # A key to add to the input string + else: + if 31 < c < 256: + # Emulate getwch + stroke = chr(c) + uchar = stroke + while not uchar: + try: + uchar = stroke.decode(self.encoding) + except UnicodeDecodeError: + c = self.stdscr.getch() + stroke += chr(c) + + if uchar: + if self.input_cursor == len(self.input): + self.input += uchar + else: + # Insert into string + self.input = ( + self.input[: self.input_cursor] + + uchar + + self.input[self.input_cursor :] + ) + + # Move the cursor forward + self.input_cursor += 1 + + self.refresh() + + @overrides(BaseMode) + def on_resize(self, rows, cols): + BaseMode.on_resize(self, rows, cols) + self.stdscr.erase() + self.refresh() + + @overrides(BaseMode) + def refresh(self): + """ + Refreshes the screen. + Updates the lines based on the`:attr:lines` based on the `:attr:display_lines_offset` + attribute and the status bars. + """ + if not component.get('ConsoleUI').is_active_mode(self): + return + self.stdscr.erase() + + # Update the status bars + self.add_string(0, self.statusbars.topbar) + self.add_string(self.rows - 2, self.statusbars.bottombar) + + # The number of rows minus the status bars and the input line + available_lines = self.rows - 3 + # If the amount of lines exceeds the number of rows, we need to figure out + # which ones to display based on the offset + if len(self.lines) > available_lines: + # Get the lines to display based on the offset + offset = len(self.lines) - self.display_lines_offset + lines = self.lines[-(available_lines - offset) : offset] + elif len(self.lines) == available_lines: + lines = self.lines + else: + lines = [''] * (available_lines - len(self.lines)) + lines.extend(self.lines) + + # Add the lines to the screen + for index, line in enumerate(lines): + self.add_string(index + 1, line) + + # Add the input string + self.add_string(self.rows - 1, self.input, pad=False, trim=False) + + move_cursor(self.stdscr, self.rows - 1, min(self.input_cursor, curses.COLS - 1)) + self.stdscr.redrawwin() + self.stdscr.refresh() + + def add_line(self, text, refresh=True): + """ + Add a line to the screen. This will be showed between the two bars. + The text can be formatted with color using the following format: + + "{!fg, bg, attributes, ...!}" + + See: http://docs.python.org/library/curses.html#constants for attributes. + + Alternatively, it can use some built-in scheme for coloring. + See colors.py for built-in schemes. + + "{!scheme!}" + + Examples: + + "{!blue, black, bold!}My Text is {!white, black!}cool" + "{!info!}I am some info text!" + "{!error!}Uh oh!" + + :param text: the text to show + :type text: string + :param refresh: if True, the screen will refresh after the line is added + :type refresh: bool + + """ + + if self.console_config['cmdline']['save_command_history']: + # Determine which file is the active one + # If both are under maximum, it's first, otherwise it's the one not full + if ( + self._hf_lines[0] < MAX_HISTFILE_SIZE + and self._hf_lines[1] < MAX_HISTFILE_SIZE + ): + active_file = 0 + elif self._hf_lines[0] == MAX_HISTFILE_SIZE: + active_file = 1 + else: + active_file = 0 + + # Write the line + with open(self.history_file[active_file], 'a', encoding='utf8') as _file: + _file.write(text + '\n') + + # And increment line counter + self._hf_lines[active_file] += 1 + + # If the active file reaches max size, we truncate it + # therefore swapping the currently active file + if self._hf_lines[active_file] == MAX_HISTFILE_SIZE: + self._hf_lines[1 - active_file] = 0 + with open( + self.history_file[1 - active_file], 'w', encoding='utf8' + ) as _file: + _file.truncate(0) + + def get_line_chunks(line): + """ + Returns a list of 2-tuples (color string, text) + + """ + if not line or line.count('{!') != line.count('!}'): + return [] + + chunks = [] + if not line.startswith('{!'): + begin = line.find('{!') + if begin == -1: + begin = len(line) + chunks.append(('', line[:begin])) + line = line[begin:] + + while line: + # We know the line starts with "{!" here + end_color = line.find('!}') + next_color = line.find('{!', end_color) + if next_color == -1: + next_color = len(line) + chunks.append((line[: end_color + 2], line[end_color + 2 : next_color])) + line = line[next_color:] + return chunks + + for line in text.splitlines(): + # We need to check for line lengths here and split as necessary + try: + line_length = colors.get_line_width(line) + except colors.BadColorString: + log.error('Passed a bad colored line: %s', line) + continue + + if line_length >= (self.cols - 1): + s = '' + # The length of the text without the color tags + s_len = 0 + # We need to split this over multiple lines + for chunk in get_line_chunks(line): + if (strwidth(chunk[1]) + s_len) < (self.cols - 1): + # This chunk plus the current string in 's' isn't over + # the maximum width, so just append the color tag and text + s += chunk[0] + chunk[1] + s_len += strwidth(chunk[1]) + else: + # The chunk plus the current string in 's' is too long. + # We need to take as much of the chunk and put it into 's' + # with the color tag. + remain = (self.cols - 1) - s_len + s += chunk[0] + chunk[1][:remain] + # We append the line since it's full + self.lines.append(s) + # Start a new 's' with the remainder chunk + s = chunk[0] + chunk[1][remain:] + s_len = strwidth(chunk[1][remain:]) + # Append the final string which may or may not be the full width + if s: + self.lines.append(s) + else: + self.lines.append(line) + + while len(self.lines) > LINES_BUFFER_SIZE: + # Remove the oldest line if the max buffer size has been reached + del self.lines[0] + + if refresh: + self.refresh() + + def _add_string(self, row, string): + """ + Adds a string to the desired `:param:row`. + + :param row: int, the row number to write the string + + """ + col = 0 + try: + parsed = colors.parse_color_string(string) + except colors.BadColorString as ex: + log.error('Cannot add bad color string %s: %s', string, ex) + return + + for index, (color, p_str) in enumerate(parsed): + if index + 1 == len(parsed): + # This is the last string so lets append some " " to it + p_str += ' ' * (self.cols - (col + strwidth(p_str)) - 1) + try: + self.stdscr.addstr(row, col, p_str.encode(self.encoding), color) + except curses.error: + pass + + col += strwidth(p_str) + + def set_batch_write(self, batch): + """ + When this is set the screen is not refreshed after a `:meth:write` until + this is set to False. + + :param batch: set True to prevent screen refreshes after a `:meth:write` + :type batch: bool + + """ + self.batch_write = batch + if not batch: + self.refresh() + + def write(self, line): + """ + Writes a line out + + :param line: str, the line to print + + """ + + self.add_line(line, not self.batch_write) + + def tab_completer(self, line, cursor, hits): + """ + Called when the user hits 'tab' and will autocomplete or show options. + If a command is already supplied in the line, this function will call the + complete method of the command. + + :param line: str, the current input string + :param cursor: int, the cursor position in the line + :param second_hit: bool, if this is the second time in a row the tab key + has been pressed + + :returns: 2-tuple (string, cursor position) + + """ + # First check to see if there is no space, this will mean that it's a + # command that needs to be completed. + + # We don't want to split by escaped spaces + def split(string): + return re.split(r'(? len(line): + line = new_line + cursor = len(line) + elif hits >= 2: + max_list = self.console_config['cmdline']['torrents_per_tab_press'] + match_count = len(possible_matches) + listed = (hits - 2) * max_list + pages = (match_count - 1) // max_list + 1 + left = match_count - listed + if hits == 2: + self.write(' ') + + if match_count >= 4: + self.write('{!green!}Autocompletion matches:') + # Only list some of the matching torrents as there can be hundreds of them + if self.console_config['cmdline']['third_tab_lists_all']: + if hits == 2 and left > max_list: + for i in range(listed, listed + max_list): + match = possible_matches[i] + self.write(match.replace(r'\ ', ' ')) + self.write( + '{!error!}And %i more. Press to list them' + % (left - max_list) + ) + else: + self.tab_count = 0 + for match in possible_matches[listed:]: + self.write(match.replace(r'\ ', ' ')) + else: + if left > max_list: + for i in range(listed, listed + max_list): + match = possible_matches[i] + self.write(match.replace(r'\ ', ' ')) + self.write( + '{!error!}And %i more (%i/%i). Press to view more' + % (left - max_list, hits - 1, pages) + ) + else: + self.tab_count = 0 + for match in possible_matches[listed:]: + self.write(match.replace(r'\ ', ' ')) + if hits > 2: + self.write( + '{!green!}Finished listing %i torrents (%i/%i)' + % (match_count, hits - 1, pages) + ) + + # We only want to print eventual colors or other control characters, not return them + line = remove_formatting(line) + cursor = len(line) + return (line, cursor) + + def tab_complete_path( + self, line, path_type='file', ext='', sort='name', dirs_first=1 + ): + self.console = component.get('ConsoleUI') + + line = line.replace('\\ ', ' ') + line = os.path.abspath(os.path.expanduser(line)) + ret = [] + if os.path.exists(line): + # This is a correct path, check to see if it's a directory + if os.path.isdir(line): + # Directory, so we need to show contents of directory + # ret.extend(os.listdir(line)) + try: + for f in os.listdir(line): + # Skip hidden + if f.startswith('.'): + continue + f = os.path.join(line, f) + if os.path.isdir(f): + if os.sep == '\\': # Windows path support + f += '\\' + else: # Unix + f += '/' + elif not f.endswith(ext): + continue + ret.append(f) + except OSError: + self.console.write('{!error!}Permission denied: {!info!}%s' % line) + else: + try: + # This is a file, but we could be looking for another file that + # shares a common prefix. + for f in os.listdir(os.path.dirname(line)): + if f.startswith(os.path.split(line)[1]): + ret.append(os.path.join(os.path.dirname(line), f)) + except OSError: + self.console.write('{!error!}Permission denied: {!info!}%s' % line) + else: + # This path does not exist, so lets do a listdir on it's parent + # and find any matches. + try: + ret = [] + if os.path.isdir(os.path.dirname(line)): + for f in os.listdir(os.path.dirname(line)): + if f.startswith(os.path.split(line)[1]): + p = os.path.join(os.path.dirname(line), f) + + if os.path.isdir(p): + if os.sep == '\\': # Windows path support + p += '\\' + else: # Unix + p += '/' + ret.append(p) + except OSError: + self.console.write('{!error!}Permission denied: {!info!}%s' % line) + + if sort == 'date': + ret = sorted(ret, key=os.path.getmtime, reverse=True) + + if dirs_first == 1: + ret = sorted(ret, key=os.path.isdir, reverse=True) + elif dirs_first == -1: + ret = sorted(ret, key=os.path.isdir, reverse=False) + + # Highlight directory names + for i, filename in enumerate(ret): + if os.path.isdir(filename): + ret[i] = '{!cyan!}%s' % filename + + for i in range(0, len(ret)): + ret[i] = ret[i].replace(' ', r'\ ') + return ret + + def tab_complete_torrent(self, line): + """ + Completes torrent_ids or names. + + :param line: str, the string to complete + + :returns: list of matches + + """ + + empty = len(line) == 0 + + # Remove dangling backslashes to avoid breaking shlex + if line.endswith('\\'): + line = line[:-1] + + raw_line = line + line = line.replace('\\ ', ' ') + + possible_matches = [] + possible_matches2 = [] + + match_count = 0 + match_count2 = 0 + for torrent_id, torrent_name in self.console.torrents: + if torrent_id.startswith(line): + match_count += 1 + if torrent_name.startswith(line): + match_count += 1 + elif torrent_name.lower().startswith(line.lower()): + match_count2 += 1 + + # Find all possible matches + for torrent_id, torrent_name in self.console.torrents: + # Escape spaces to avoid, for example, expanding "Doc" into "Doctor Who" and removing + # everything containing one of these words + escaped_name = torrent_name.replace(' ', '\\ ') + # If we only matched one torrent, don't add the full name or it'll also get autocompleted + if match_count == 1: + if torrent_id.startswith(line): + possible_matches.append(torrent_id) + break + if torrent_name.startswith(line): + possible_matches.append(escaped_name) + break + elif match_count == 0 and match_count2 == 1: + if torrent_name.lower().startswith(line.lower()): + possible_matches.append(escaped_name) + break + else: + line_len = len(raw_line) + + # Let's avoid listing all torrents twice if there's no pattern + if not empty and torrent_id.startswith(line): + # Highlight the matching part + text = '{{!info!}}{}{{!input!}}{} - "{}"'.format( + torrent_id[:line_len], + torrent_id[line_len:], + torrent_name, + ) + possible_matches.append(text) + if torrent_name.startswith(line): + text = '{{!info!}}{}{{!input!}}{} ({{!cyan!}}{}{{!input!}})'.format( + escaped_name[:line_len], + escaped_name[line_len:], + torrent_id, + ) + possible_matches.append(text) + elif torrent_name.lower().startswith(line.lower()): + text = '{{!info!}}{}{{!input!}}{} ({{!cyan!}}{}{{!input!}})'.format( + escaped_name[:line_len], + escaped_name[line_len:], + torrent_id, + ) + possible_matches2.append(text) + + return possible_matches + possible_matches2 diff --git a/deluge/ui/console/modes/connectionmanager.py b/deluge/ui/console/modes/connectionmanager.py new file mode 100644 index 0000000..ce8b6f5 --- /dev/null +++ b/deluge/ui/console/modes/connectionmanager.py @@ -0,0 +1,211 @@ +# +# Copyright (C) 2011 Nick Lanham +# +# 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 BaseMode +from deluge.ui.console.utils.curses_util import is_printable_chr +from deluge.ui.console.widgets.popup import InputPopup, PopupsHandler, SelectablePopup +from deluge.ui.hostlist import HostList + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) + + +class ConnectionManager(BaseMode, PopupsHandler): + def __init__(self, stdscr, encoding=None): + PopupsHandler.__init__(self) + self.statuses = {} + self.all_torrents = None + self.hostlist = HostList() + BaseMode.__init__(self, stdscr, encoding=encoding) + + def update_select_host_popup(self): + if self.popup and not isinstance(self.popup, SelectablePopup): + # Ignore MessagePopup on popup stack upon connect fail + return + + selected_index = self.popup.current_selection() if self.popup else None + + popup = SelectablePopup( + self, + _('Select Host'), + self._host_selected, + border_off_west=1, + active_wrap=True, + ) + popup.add_header( + "{!white,black,bold!}'Q'=%s, 'a'=%s, 'D'=%s" + % (_('Quit'), _('Add Host'), _('Delete Host')), + space_below=True, + ) + + for host_entry in self.hostlist.get_hosts_info(): + host_id, hostname, port, user = host_entry + host_status = self.statuses.get(host_id) + + state = host_status[1] if host_status else 'Offline' + state_color = 'green' if state in ('Online', 'Connected') else 'red' + host_str = f'{hostname}:{port} [{state}]' + + args = {'data': host_id, 'foreground': state_color} + popup.add_line( + host_id, host_str, selectable=True, use_underline=True, **args + ) + + if selected_index: + popup.set_selection(selected_index) + + self.push_popup(popup, clear=True) + self.inlist = True + self.refresh() + + def update_hosts_status(self): + def on_host_status(status_info): + self.statuses[status_info[0]] = status_info + self.update_select_host_popup() + + for host_entry in self.hostlist.get_hosts_info(): + self.hostlist.get_host_status(host_entry[0]).addCallback(on_host_status) + + def _on_connected(self, result): + def on_console_start(result): + component.get('ConsoleUI').set_mode('TorrentList') + + d = component.get('ConsoleUI').start_console() + d.addCallback(on_console_start) + + def _on_connect_fail(self, result): + self.report_message('Failed to connect!', result.getErrorMessage()) + self.refresh() + if hasattr(result, 'getTraceback'): + log.exception(result) + + def _host_selected(self, selected_host, *args, **kwargs): + if selected_host in self.statuses: + d = self.hostlist.connect_host(selected_host) + d.addCallback(self._on_connected) + d.addErrback(self._on_connect_fail) + + def _do_add(self, result, **kwargs): + if not result or kwargs.get('close', False): + self.pop_popup() + else: + self.add_host( + result['hostname']['value'], + result['port']['value'], + result['username']['value'], + result['password']['value'], + ) + + def add_popup(self): + self.inlist = False + popup = InputPopup( + self, + _('Add Host (Up & Down arrows to navigate, Esc to cancel)'), + border_off_north=1, + border_off_east=1, + close_cb=self._do_add, + ) + popup.add_text_input('hostname', _('Hostname:')) + popup.add_text_input('port', _('Port:')) + popup.add_text_input('username', _('Username:')) + popup.add_text_input('password', _('Password:')) + self.push_popup(popup, clear=True) + self.refresh() + + def add_host(self, hostname, port, username, password): + log.info('Adding host: %s', hostname) + if port.isdecimal(): + port = int(port) + try: + self.hostlist.add_host(hostname, port, username, password) + except ValueError as ex: + self.report_message(_('Error adding host'), f'{hostname}: {ex}') + else: + self.pop_popup() + + def delete_host(self, host_id): + log.info('Deleting host: %s', host_id) + self.hostlist.remove_host(host_id) + self.update_select_host_popup() + + @overrides(component.Component) + def start(self): + self.refresh() + + @overrides(component.Component) + def update(self): + self.update_hosts_status() + + @overrides(BaseMode) + def pause(self): + self.pop_popup() + BaseMode.pause(self) + + @overrides(BaseMode) + def resume(self): + BaseMode.resume(self) + self.refresh() + + @overrides(BaseMode) + def refresh(self): + if self.mode_paused(): + return + + self.stdscr.erase() + self.draw_statusbars() + self.stdscr.noutrefresh() + + if not self.popup: + self.update_select_host_popup() + + if self.popup: + self.popup.refresh() + + curses.doupdate() + + @overrides(BaseMode) + def on_resize(self, rows, cols): + BaseMode.on_resize(self, rows, cols) + + if self.popup: + self.popup.handle_resize() + + self.stdscr.erase() + self.refresh() + + @overrides(BaseMode) + def read_input(self): + c = self.stdscr.getch() + + if is_printable_chr(c): + if chr(c) == 'Q': + component.get('ConsoleUI').quit() + elif self.inlist: + if chr(c) == 'q': + return + elif chr(c) == 'D': + host_index = self.popup.current_selection() + host_id = self.popup.inputs[host_index].name + self.delete_host(host_id) + return + elif chr(c) == 'a': + self.add_popup() + return + + if self.popup: + if self.popup.handle_read(c) and self.popup.closed(): + self.pop_popup() + self.refresh() diff --git a/deluge/ui/console/modes/eventview.py b/deluge/ui/console/modes/eventview.py new file mode 100644 index 0000000..b6e63b0 --- /dev/null +++ b/deluge/ui/console/modes/eventview.py @@ -0,0 +1,112 @@ +# +# Copyright (C) 2011 Nick Lanham +# +# 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 BaseMode +from deluge.ui.console.utils import curses_util as util + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) + + +class EventView(BaseMode): + def __init__(self, parent_mode, stdscr, encoding=None): + BaseMode.__init__(self, stdscr, encoding) + self.parent_mode = parent_mode + self.offset = 0 + + def back_to_overview(self): + component.get('ConsoleUI').set_mode(self.parent_mode.mode_name) + + @overrides(component.Component) + def update(self): + self.refresh() + + @overrides(BaseMode) + def refresh(self): + """ + This method just shows each line of the event log + """ + events = component.get('ConsoleUI').events + + self.stdscr.erase() + self.draw_statusbars() + + if events: + for i, event in enumerate(events): + if i - self.offset >= self.rows - 2: + more = len(events) - self.offset - self.rows + 2 + if more > 0: + self.add_string(i - self.offset, ' (And %i more)' % more) + break + + elif i - self.offset < 0: + continue + try: + self.add_string(i + 1 - self.offset, event) + except curses.error: + pass # This'll just cut the line. Note: This seriously should be fixed in a better way + else: + self.add_string(1, '{!white,black,bold!}No events to show yet') + + if not component.get('ConsoleUI').is_active_mode(self): + return + + self.stdscr.noutrefresh() + curses.doupdate() + + @overrides(BaseMode) + def on_resize(self, rows, cols): + BaseMode.on_resize(self, rows, cols) + self.refresh() + + @overrides(BaseMode) + def read_input(self): + c = self.stdscr.getch() + + if c in [ord('q'), util.KEY_ESC]: + self.back_to_overview() + return + + # TODO: Scroll event list + jumplen = self.rows - 3 + num_events = len(component.get('ConsoleUI').events) + + if c == curses.KEY_UP: + self.offset -= 1 + elif c == curses.KEY_PPAGE: + self.offset -= jumplen + elif c == curses.KEY_HOME: + self.offset = 0 + elif c == curses.KEY_DOWN: + self.offset += 1 + elif c == curses.KEY_NPAGE: + self.offset += jumplen + elif c == curses.KEY_END: + self.offset += num_events + elif c == ord('j'): + self.offset += 1 + elif c == ord('k'): + self.offset -= 1 + + if self.offset <= 0: + self.offset = 0 + elif num_events > self.rows - 3: + if self.offset > num_events - self.rows + 3: + self.offset = num_events - self.rows + 3 + else: + self.offset = 0 + + self.refresh() diff --git a/deluge/ui/console/modes/preferences/__init__.py b/deluge/ui/console/modes/preferences/__init__.py new file mode 100644 index 0000000..e827d91 --- /dev/null +++ b/deluge/ui/console/modes/preferences/__init__.py @@ -0,0 +1,3 @@ +from deluge.ui.console.modes.preferences.preferences import Preferences + +__all__ = ['Preferences'] diff --git a/deluge/ui/console/modes/preferences/preference_panes.py b/deluge/ui/console/modes/preferences/preference_panes.py new file mode 100644 index 0000000..b47bc4b --- /dev/null +++ b/deluge/ui/console/modes/preferences/preference_panes.py @@ -0,0 +1,757 @@ +# +# Copyright (C) 2011 Nick Lanham +# +# 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.common import is_interface +from deluge.decorators import overrides +from deluge.i18n import get_languages +from deluge.ui.client import client +from deluge.ui.common import DISK_CACHE_KEYS +from deluge.ui.console.widgets import BaseInputPane, BaseWindow +from deluge.ui.console.widgets.fields import FloatSpinInput, TextInput +from deluge.ui.console.widgets.popup import PopupsHandler + +log = logging.getLogger(__name__) + + +class BasePreferencePane(BaseInputPane, BaseWindow, PopupsHandler): + def __init__(self, name, preferences): + PopupsHandler.__init__(self) + self.preferences = preferences + BaseWindow.__init__( + self, + '%s' % name, + self.pane_width, + preferences.height, + posy=1, + posx=self.pane_x_pos, + ) + BaseInputPane.__init__(self, preferences, border_off_east=1) + self.name = name + + # have we scrolled down in the list + self.input_offset = 0 + + @overrides(BaseInputPane) + def handle_read(self, c): + if self.popup: + ret = self.popup.handle_read(c) + if self.popup and self.popup.closed(): + self.pop_popup() + self.refresh() + return ret + return BaseInputPane.handle_read(self, c) + + @property + def visible_content_pane_height(self): + y, x = self.visible_content_pane_size + return y + + @property + def pane_x_pos(self): + return self.preferences.sidebar_width + + @property + def pane_width(self): + return self.preferences.width + + @property + def cols(self): + return self.pane_width + + @property + def rows(self): + return self.preferences.height + + def is_active_pane(self): + return self.preferences.is_active_pane(self) + + def create_pane(self, core_conf, console_config): + pass + + def add_config_values(self, conf_dict): + for ipt in self.inputs: + if ipt.has_input(): + # Need special cases for in/out ports or proxy since they are tuples or dicts. + if ipt.name == 'listen_ports_to' or ipt.name == 'listen_ports_from': + conf_dict['listen_ports'] = ( + self.infrom.get_value(), + self.into.get_value(), + ) + elif ipt.name == 'out_ports_to' or ipt.name == 'out_ports_from': + conf_dict['outgoing_ports'] = ( + self.outfrom.get_value(), + self.outto.get_value(), + ) + elif ipt.name == 'listen_interface': + listen_interface = ipt.get_value().strip() + if is_interface(listen_interface) or not listen_interface: + conf_dict['listen_interface'] = listen_interface + elif ipt.name == 'outgoing_interface': + outgoing_interface = ipt.get_value().strip() + if is_interface(outgoing_interface) or not outgoing_interface: + conf_dict['outgoing_interface'] = outgoing_interface + elif ipt.name.startswith('proxy_'): + if ipt.name == 'proxy_type': + conf_dict.setdefault('proxy', {})['type'] = ipt.get_value() + elif ipt.name == 'proxy_username': + conf_dict.setdefault('proxy', {})['username'] = ipt.get_value() + elif ipt.name == 'proxy_password': + conf_dict.setdefault('proxy', {})['password'] = ipt.get_value() + elif ipt.name == 'proxy_hostname': + conf_dict.setdefault('proxy', {})['hostname'] = ipt.get_value() + elif ipt.name == 'proxy_port': + conf_dict.setdefault('proxy', {})['port'] = ipt.get_value() + elif ipt.name == 'proxy_hostnames': + conf_dict.setdefault('proxy', {})[ + 'proxy_hostnames' + ] = ipt.get_value() + elif ipt.name == 'proxy_peer_connections': + conf_dict.setdefault('proxy', {})[ + 'proxy_peer_connections' + ] = ipt.get_value() + elif ipt.name == 'proxy_tracker_connections': + conf_dict.setdefault('proxy', {})[ + 'proxy_tracker_connections' + ] = ipt.get_value() + elif ipt.name == 'force_proxy': + conf_dict.setdefault('proxy', {})['force_proxy'] = ipt.get_value() + elif ipt.name == 'anonymous_mode': + conf_dict.setdefault('proxy', {})[ + 'anonymous_mode' + ] = ipt.get_value() + else: + conf_dict[ipt.name] = ipt.get_value() + + if hasattr(ipt, 'get_child'): + c = ipt.get_child() + conf_dict[c.name] = c.get_value() + + def update_values(self, conf_dict): + for ipt in self.inputs: + if ipt.has_input(): + try: + ipt.set_value(conf_dict[ipt.name]) + except KeyError: # just ignore if it's not in dict + pass + if hasattr(ipt, 'get_child'): + try: + c = ipt.get_child() + c.set_value(conf_dict[c.name]) + except KeyError: # just ignore if it's not in dict + pass + + def render(self, mode, screen, width, focused): + height = self.get_content_height() + self.ensure_content_pane_height(height) + self.screen.erase() + + if focused and self.active_input == -1: + self.move_active_down(1) + + self.render_inputs(focused=focused) + + @overrides(BaseWindow) + def refresh(self): + BaseWindow.refresh(self) + if self.popup: + self.popup.refresh() + + def update(self, active): + pass + + +class InterfacePane(BasePreferencePane): + def __init__(self, preferences): + BasePreferencePane.__init__(self, ' %s ' % _('Interface'), preferences) + + @overrides(BasePreferencePane) + def create_pane(self, core_conf, console_config): + self.add_header(_('General options')) + + self.add_checked_input( + 'ring_bell', + _('Ring system bell when a download finishes'), + console_config['ring_bell'], + ) + self.add_header('Console UI', space_above=True) + self.add_checked_input( + 'separate_complete', + _('List complete torrents after incomplete regardless of sorting order'), + console_config['torrentview']['separate_complete'], + ) + self.add_checked_input( + 'move_selection', + _('Move selection when moving torrents in the queue'), + console_config['torrentview']['move_selection'], + ) + + langs = get_languages() + langs.insert(0, ('', 'System Default')) + self.add_combo_input( + 'language', _('Language'), langs, default=console_config['language'] + ) + self.add_header(_('Command Line Mode'), space_above=True) + self.add_checked_input( + 'ignore_duplicate_lines', + _('Do not store duplicate input in history'), + console_config['cmdline']['ignore_duplicate_lines'], + ) + self.add_checked_input( + 'save_command_history', + _('Store and load command line history in command line mode'), + console_config['cmdline']['save_command_history'], + ) + self.add_header('') + self.add_checked_input( + 'third_tab_lists_all', + _('Third tab lists all remaining torrents in command line mode'), + console_config['cmdline']['third_tab_lists_all'], + ) + self.add_int_spin_input( + 'torrents_per_tab_press', + _('Torrents per tab press'), + console_config['cmdline']['torrents_per_tab_press'], + min_val=5, + max_val=10000, + ) + + +class DownloadsPane(BasePreferencePane): + def __init__(self, preferences): + BasePreferencePane.__init__(self, ' %s ' % _('Downloads'), preferences) + + @overrides(BasePreferencePane) + def create_pane(self, core_conf, console_config): + self.add_header(_('Folders')) + self.add_text_input( + 'download_location', + '%s:' % _('Download To'), + core_conf['download_location'], + complete=True, + activate_input=True, + col='+1', + ) + cmptxt = TextInput( + self.preferences, + 'move_completed_path', + None, + self.move, + self.pane_width, + core_conf['move_completed_path'], + False, + ) + self.add_checkedplus_input( + 'move_completed', + '%s:' % _('Move completed to'), + cmptxt, + core_conf['move_completed'], + ) + copytxt = TextInput( + self.preferences, + 'torrentfiles_location', + None, + self.move, + self.pane_width, + core_conf['torrentfiles_location'], + False, + ) + self.add_checkedplus_input( + 'copy_torrent_file', + '%s:' % _('Copy of .torrent files to'), + copytxt, + core_conf['copy_torrent_file'], + ) + self.add_checked_input( + 'del_copy_torrent_file', + _('Delete copy of torrent file on remove'), + core_conf['del_copy_torrent_file'], + ) + + self.add_header(_('Options'), space_above=True) + self.add_checked_input( + 'prioritize_first_last_pieces', + ('Prioritize first and last pieces of torrent'), + core_conf['prioritize_first_last_pieces'], + ) + self.add_checked_input( + 'sequential_download', + _('Sequential download'), + core_conf['sequential_download'], + ) + self.add_checked_input('add_paused', _('Add Paused'), core_conf['add_paused']) + self.add_checked_input( + 'pre_allocate_storage', + _('Pre-Allocate disk space'), + core_conf['pre_allocate_storage'], + ) + + +class NetworkPane(BasePreferencePane): + def __init__(self, preferences): + BasePreferencePane.__init__(self, ' %s ' % _('Network'), preferences) + + @overrides(BasePreferencePane) + def create_pane(self, core_conf, console_config): + self.add_header(_('Incomming Ports')) + inrand = self.add_checked_input( + 'random_port', + 'Use Random Ports Active Port: %d' % self.preferences.active_port, + core_conf['random_port'], + ) + listen_ports = core_conf['listen_ports'] + self.infrom = self.add_int_spin_input( + 'listen_ports_from', + ' %s:' % _('From'), + value=listen_ports[0], + min_val=0, + max_val=65535, + ) + self.infrom.set_depend(inrand, inverse=True) + self.into = self.add_int_spin_input( + 'listen_ports_to', + ' %s:' % _('To'), + value=listen_ports[1], + min_val=0, + max_val=65535, + ) + self.into.set_depend(inrand, inverse=True) + + self.add_header(_('Outgoing Ports'), space_above=True) + outrand = self.add_checked_input( + 'random_outgoing_ports', + _('Use Random Ports'), + core_conf['random_outgoing_ports'], + ) + out_ports = core_conf['outgoing_ports'] + self.outfrom = self.add_int_spin_input( + 'out_ports_from', + ' %s:' % _('From'), + value=out_ports[0], + min_val=0, + max_val=65535, + ) + self.outfrom.set_depend(outrand, inverse=True) + self.outto = self.add_int_spin_input( + 'out_ports_to', + ' %s:' % _('To'), + value=out_ports[1], + min_val=0, + max_val=65535, + ) + self.outto.set_depend(outrand, inverse=True) + + self.add_header(_('Incoming Interface'), space_above=True) + self.add_text_input( + 'listen_interface', + _('IP address of the interface to listen on (leave empty for default):'), + core_conf['listen_interface'], + ) + + self.add_header(_('Outgoing Interface'), space_above=True) + self.add_text_input( + 'outgoing_interface', + _( + 'The network interface name or IP address for outgoing ' + 'BitTorrent connections. (Leave empty for default.):' + ), + core_conf['outgoing_interface'], + ) + + self.add_header('TOS', space_above=True) + self.add_text_input('peer_tos', 'Peer TOS Byte:', core_conf['peer_tos']) + + self.add_header(_('Network Extras'), space_above=True) + self.add_checked_input('upnp', 'UPnP', core_conf['upnp']) + self.add_checked_input('natpmp', 'NAT-PMP', core_conf['natpmp']) + self.add_checked_input('utpex', 'Peer Exchange', core_conf['utpex']) + self.add_checked_input('lsd', 'LSD', core_conf['lsd']) + self.add_checked_input('dht', 'DHT', core_conf['dht']) + + self.add_header(_('Encryption'), space_above=True) + self.add_select_input( + 'enc_in_policy', + '%s:' % _('Inbound'), + [_('Forced'), _('Enabled'), _('Disabled')], + [0, 1, 2], + core_conf['enc_in_policy'], + active_default=True, + col='+1', + ) + self.add_select_input( + 'enc_out_policy', + '%s:' % _('Outbound'), + [_('Forced'), _('Enabled'), _('Disabled')], + [0, 1, 2], + core_conf['enc_out_policy'], + active_default=True, + ) + self.add_select_input( + 'enc_level', + '%s:' % _('Level'), + [_('Handshake'), _('Full Stream'), _('Either')], + [0, 1, 2], + core_conf['enc_level'], + active_default=True, + ) + + +class BandwidthPane(BasePreferencePane): + def __init__(self, preferences): + BasePreferencePane.__init__(self, ' %s ' % _('Bandwidth'), preferences) + + @overrides(BasePreferencePane) + def create_pane(self, core_conf, console_config): + self.add_header(_('Global Bandwidth Usage')) + self.add_int_spin_input( + 'max_connections_global', + '%s:' % _('Maximum Connections'), + core_conf['max_connections_global'], + min_val=-1, + max_val=9000, + ) + self.add_int_spin_input( + 'max_upload_slots_global', + '%s:' % _('Maximum Upload Slots'), + core_conf['max_upload_slots_global'], + min_val=-1, + max_val=9000, + ) + self.add_float_spin_input( + 'max_download_speed', + '%s:' % _('Maximum Download Speed (KiB/s)'), + core_conf['max_download_speed'], + min_val=-1.0, + max_val=60000.0, + ) + self.add_float_spin_input( + 'max_upload_speed', + '%s:' % _('Maximum Upload Speed (KiB/s)'), + core_conf['max_upload_speed'], + min_val=-1.0, + max_val=60000.0, + ) + self.add_int_spin_input( + 'max_half_open_connections', + '%s:' % _('Maximum Half-Open Connections'), + core_conf['max_half_open_connections'], + min_val=-1, + max_val=9999, + ) + self.add_int_spin_input( + 'max_connections_per_second', + '%s:' % _('Maximum Connection Attempts per Second'), + core_conf['max_connections_per_second'], + min_val=-1, + max_val=9999, + ) + self.add_checked_input( + 'ignore_limits_on_local_network', + _('Ignore limits on local network'), + core_conf['ignore_limits_on_local_network'], + ) + self.add_checked_input( + 'rate_limit_ip_overhead', + _('Rate Limit IP Overhead'), + core_conf['rate_limit_ip_overhead'], + ) + self.add_header(_('Per Torrent Bandwidth Usage'), space_above=True) + self.add_int_spin_input( + 'max_connections_per_torrent', + '%s:' % _('Maximum Connections'), + core_conf['max_connections_per_torrent'], + min_val=-1, + max_val=9000, + ) + self.add_int_spin_input( + 'max_upload_slots_per_torrent', + '%s:' % _('Maximum Upload Slots'), + core_conf['max_upload_slots_per_torrent'], + min_val=-1, + max_val=9000, + ) + self.add_float_spin_input( + 'max_download_speed_per_torrent', + '%s:' % _('Maximum Download Speed (KiB/s)'), + core_conf['max_download_speed_per_torrent'], + min_val=-1.0, + max_val=60000.0, + ) + self.add_float_spin_input( + 'max_upload_speed_per_torrent', + '%s:' % _('Maximum Upload Speed (KiB/s)'), + core_conf['max_upload_speed_per_torrent'], + min_val=-1.0, + max_val=60000.0, + ) + + +class OtherPane(BasePreferencePane): + def __init__(self, preferences): + BasePreferencePane.__init__(self, ' %s ' % _('Other'), preferences) + + @overrides(BasePreferencePane) + def create_pane(self, core_conf, console_config): + self.add_header(_('System Information')) + self.add_info_field('info1', ' Help us improve Deluge by sending us your', '') + self.add_info_field( + 'info2', ' Python version, PyGTK version, OS and processor', '' + ) + self.add_info_field( + 'info3', ' types. Absolutely no other information is sent.', '' + ) + self.add_checked_input( + 'send_info', + _('Yes, please send anonymous statistics.'), + core_conf['send_info'], + ) + self.add_header(_('GeoIP Database'), space_above=True) + self.add_text_input( + 'geoip_db_location', 'Location:', core_conf['geoip_db_location'] + ) + + +class DaemonPane(BasePreferencePane): + def __init__(self, preferences): + BasePreferencePane.__init__(self, ' %s ' % _('Daemon'), preferences) + + @overrides(BasePreferencePane) + def create_pane(self, core_conf, console_config): + self.add_header('Port') + self.add_int_spin_input( + 'daemon_port', + '%s:' % _('Daemon Port'), + core_conf['daemon_port'], + min_val=0, + max_val=65535, + ) + self.add_header('Connections', space_above=True) + self.add_checked_input( + 'allow_remote', _('Allow remote connections'), core_conf['allow_remote'] + ) + self.add_header('Other', space_above=True) + self.add_checked_input( + 'new_release_check', + _('Periodically check the website for new releases'), + core_conf['new_release_check'], + ) + + +class QueuePane(BasePreferencePane): + def __init__(self, preferences): + BasePreferencePane.__init__(self, ' %s ' % _('Queue'), preferences) + + @overrides(BasePreferencePane) + def create_pane(self, core_conf, console_config): + self.add_header(_('New Torrents')) + self.add_checked_input( + 'queue_new_to_top', _('Queue to top'), core_conf['queue_new_to_top'] + ) + self.add_header(_('Active Torrents'), True) + self.add_int_spin_input( + 'max_active_limit', + '%s:' % _('Total'), + core_conf['max_active_limit'], + min_val=-1, + max_val=9999, + ) + self.add_int_spin_input( + 'max_active_downloading', + '%s:' % _('Downloading'), + core_conf['max_active_downloading'], + min_val=-1, + max_val=9999, + ) + self.add_int_spin_input( + 'max_active_seeding', + '%s:' % _('Seeding'), + core_conf['max_active_seeding'], + min_val=-1, + max_val=9999, + ) + self.add_checked_input( + 'dont_count_slow_torrents', + 'Ignore slow torrents', + core_conf['dont_count_slow_torrents'], + ) + self.add_checked_input( + 'auto_manage_prefer_seeds', + 'Prefer seeding torrents', + core_conf['auto_manage_prefer_seeds'], + ) + self.add_header(_('Seeding Rotation'), space_above=True) + self.add_float_spin_input( + 'share_ratio_limit', + '%s:' % _('Share Ratio'), + core_conf['share_ratio_limit'], + precision=2, + min_val=-1.0, + max_val=100.0, + ) + self.add_float_spin_input( + 'seed_time_ratio_limit', + '%s:' % _('Time Ratio'), + core_conf['seed_time_ratio_limit'], + precision=2, + min_val=-1.0, + max_val=100.0, + ) + self.add_int_spin_input( + 'seed_time_limit', + '%s:' % _('Time (m)'), + core_conf['seed_time_limit'], + min_val=1, + max_val=10000, + ) + seedratio = FloatSpinInput( + self.mode, + 'stop_seed_ratio', + '', + self.move, + core_conf['stop_seed_ratio'], + precision=2, + inc_amt=0.1, + min_val=0.5, + max_val=100.0, + ) + self.add_checkedplus_input( + 'stop_seed_at_ratio', + '%s:' % _('Share Ratio Reached'), + seedratio, + core_conf['stop_seed_at_ratio'], + ) + self.add_checked_input( + 'remove_seed_at_ratio', + _('Remove torrent (Unchecked pauses torrent)'), + core_conf['remove_seed_at_ratio'], + ) + + +class ProxyPane(BasePreferencePane): + def __init__(self, preferences): + BasePreferencePane.__init__(self, ' %s ' % _('Proxy'), preferences) + + @overrides(BasePreferencePane) + def create_pane(self, core_conf, console_config): + proxy = core_conf['proxy'] + + self.add_header(_('Proxy Settings')) + self.add_header(_('Proxy'), space_above=True) + self.add_int_spin_input( + 'proxy_type', '%s:' % _('Type'), proxy['type'], min_val=0, max_val=5 + ) + self.add_text_input('proxy_username', '%s:' % _('Username'), proxy['username']) + self.add_text_input('proxy_password', '%s:' % _('Password'), proxy['password']) + self.add_text_input('proxy_hostname', '%s:' % _('Hostname'), proxy['hostname']) + self.add_int_spin_input( + 'proxy_port', '%s:' % _('Port'), proxy['port'], min_val=0, max_val=65535 + ) + self.add_checked_input( + 'proxy_hostnames', _('Proxy Hostnames'), proxy['proxy_hostnames'] + ) + self.add_checked_input( + 'proxy_peer_connections', _('Proxy Peers'), proxy['proxy_peer_connections'] + ) + self.add_checked_input( + 'proxy_tracker_connections', + _('Proxy Trackers'), + proxy['proxy_tracker_connections'], + ) + self.add_header('%s' % _('Force Proxy'), space_above=True) + self.add_checked_input('force_proxy', _('Force Proxy'), proxy['force_proxy']) + self.add_checked_input( + 'anonymous_mode', _('Hide Client Identity'), proxy['anonymous_mode'] + ) + self.add_header('%s' % _('Proxy Type Help'), space_above=True) + self.add_text_area( + 'proxy_text_area', + ' 0: None 1: Socks4\n' + ' 2: Socks5 3: Socks5 Auth\n' + ' 4: HTTP 5: HTTP Auth\n' + ' 6: I2P', + ) + + +class CachePane(BasePreferencePane): + def __init__(self, preferences): + BasePreferencePane.__init__(self, ' %s ' % _('Cache'), preferences) + self.created = False + + @overrides(BasePreferencePane) + def create_pane(self, core_conf, console_config): + self.core_conf = core_conf + + def build_pane(self, core_conf, status): + self.created = True + self.add_header(_('Settings'), space_below=True) + self.add_int_spin_input( + 'cache_size', + '%s:' % _('Cache Size (16 KiB blocks)'), + core_conf['cache_size'], + min_val=0, + max_val=99999, + ) + self.add_int_spin_input( + 'cache_expiry', + '%s:' % _('Cache Expiry (seconds)'), + core_conf['cache_expiry'], + min_val=1, + max_val=32000, + ) + self.add_header(' %s' % _('Write'), space_above=True) + self.add_info_field( + 'blocks_written', + ' %s:' % _('Blocks Written'), + status['disk.num_blocks_written'], + ) + self.add_info_field( + 'writes', ' %s:' % _('Writes'), status['disk.num_write_ops'] + ) + self.add_info_field( + 'write_hit_ratio', + ' %s:' % _('Write Cache Hit Ratio'), + '%.2f' % status['write_hit_ratio'], + ) + self.add_header(' %s' % _('Read')) + self.add_info_field( + 'blocks_read', ' %s:' % _('Blocks Read'), status['disk.num_blocks_read'] + ) + self.add_info_field('reads', ' %s:' % _('Reads'), status['disk.num_read_ops']) + self.add_info_field( + 'read_hit_ratio', + ' %s:' % _('Read Cache Hit Ratio'), + '%.2f' % status['read_hit_ratio'], + ) + self.add_header(' %s' % _('Size')) + self.add_info_field( + 'cache_size_info', + ' %s:' % _('Cache Size'), + status['disk.disk_blocks_in_use'], + ) + self.add_info_field( + 'read_cache_size', + ' %s:' % _('Read Cache Size'), + status['disk.read_cache_blocks'], + ) + + @overrides(BasePreferencePane) + def update(self, active): + if active: + client.core.get_session_status(DISK_CACHE_KEYS).addCallback( + self.update_cache_status_fields + ) + + def update_cache_status_fields(self, status): + if not self.created: + self.build_pane(self.core_conf, status) + else: + for ipt in self.inputs: + if not ipt.has_input() and ipt.name in status: + ipt.set_value(status[ipt.name]) + self.preferences.refresh() diff --git a/deluge/ui/console/modes/preferences/preferences.py b/deluge/ui/console/modes/preferences/preferences.py new file mode 100644 index 0000000..2c95323 --- /dev/null +++ b/deluge/ui/console/modes/preferences/preferences.py @@ -0,0 +1,376 @@ +# +# Copyright (C) 2011 Nick Lanham +# +# 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.decorators import overrides +from deluge.ui.client import client +from deluge.ui.console.modes.basemode import BaseMode +from deluge.ui.console.modes.preferences.preference_panes import ( + BandwidthPane, + CachePane, + DaemonPane, + DownloadsPane, + InterfacePane, + NetworkPane, + OtherPane, + ProxyPane, + QueuePane, +) +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.widgets.fields import SelectInput +from deluge.ui.console.widgets.popup import MessagePopup, PopupsHandler +from deluge.ui.console.widgets.sidebar import Sidebar + +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 lets you view and configure various options in deluge. + +There are three main sections to this screen. Only one section is active at a time. \ +You can switch the active section by hitting TAB (or Shift-TAB to go back one) + +The section on the left displays the various categories that the settings fall in. \ +You can navigate the list using the up/down arrows + +The section on the right shows the settings for the selected category. When this \ +section is active you can navigate the various settings with the up/down arrows. \ +Special keys for each input type are described below. + +The final section is at the bottom right, the: [Cancel] [Apply] [OK] buttons. +When this section is active, simply select the option you want using the arrow +keys and press Enter to confim. + + +Special keys for various input types are as follows: +- For text inputs you can simply type in the value. + +{|indent: |}- For numeric inputs (indicated by the value being in []s), you can type a value, \ +or use PageUp and PageDown to increment/decrement the value. + +- For checkbox inputs use the spacebar to toggle + +{|indent: |}- For checkbox plus something else inputs (the something else being only visible \ +when you check the box) you can toggle the check with space, use the right \ +arrow to edit the other value, and escape to get back to the check box. + +""" + + +class ZONE: + length = 3 + CATEGORIES, PREFRENCES, ACTIONS = list(range(length)) + + +class PreferenceSidebar(Sidebar): + def __init__(self, torrentview, width): + height = curses.LINES - 2 + Sidebar.__init__( + self, torrentview, width, height, title=None, border_off_north=1 + ) + self.categories = [ + _('Interface'), + _('Downloads'), + _('Network'), + _('Bandwidth'), + _('Other'), + _('Daemon'), + _('Queue'), + _('Proxy'), + _('Cache'), + ] + for name in self.categories: + self.add_text_field( + name, + name, + selectable=True, + font_unfocused_active='bold', + color_unfocused_active='white,black', + ) + + def on_resize(self): + self.resize_window(curses.LINES - 2, self.width) + + +class Preferences(BaseMode, PopupsHandler): + def __init__(self, parent_mode, stdscr, console_config, encoding=None): + BaseMode.__init__(self, stdscr, encoding=encoding, do_refresh=False) + PopupsHandler.__init__(self) + self.parent_mode = parent_mode + self.cur_cat = 0 + self.messages = deque() + self.action_input = None + self.config_loaded = False + self.console_config = console_config + self.active_port = -1 + self.active_zone = ZONE.CATEGORIES + self.sidebar_width = 15 # Width of the categories pane + + self.sidebar = PreferenceSidebar(parent_mode, self.sidebar_width) + self.sidebar.set_focused(True) + self.sidebar.active_input = 0 + + self._calc_sizes(resize=False) + + self.panes = [ + InterfacePane(self), + DownloadsPane(self), + NetworkPane(self), + BandwidthPane(self), + OtherPane(self), + DaemonPane(self), + QueuePane(self), + ProxyPane(self), + CachePane(self), + ] + + self.action_input = SelectInput( + self, None, None, [_('Cancel'), _('Apply'), _('OK')], [0, 1, 2], 0 + ) + + def load_config(self): + if self.config_loaded: + return + + def on_get_config(core_config): + self.core_config = core_config + self.config_loaded = True + for p in self.panes: + p.create_pane(core_config, self.console_config) + self.refresh() + + client.core.get_config().addCallback(on_get_config) + + def on_get_listen_port(port): + self.active_port = port + + client.core.get_listen_port().addCallback(on_get_listen_port) + + @property + def height(self): + # top/bottom bars: 2, Action buttons (Cancel/Apply/OK): 1 + return self.rows - 3 + + @property + def width(self): + return self.prefs_width + + def _calc_sizes(self, resize=True): + self.prefs_width = self.cols - self.sidebar_width + + if not resize: + return + + for p in self.panes: + p.resize_window(self.height, p.pane_width) + + def _draw_preferences(self): + self.cur_cat = self.sidebar.active_input + self.panes[self.cur_cat].render( + self, self.stdscr, self.prefs_width, self.active_zone == ZONE.PREFRENCES + ) + self.panes[self.cur_cat].refresh() + + def _draw_actions(self): + selected = self.active_zone == ZONE.ACTIONS + self.stdscr.hline(self.rows - 3, self.sidebar_width, b'_', self.cols) + self.action_input.render( + self.stdscr, + self.rows - 2, + width=self.cols, + active=selected, + focus=True, + col=self.cols - 22, + ) + + @overrides(BaseMode) + def on_resize(self, rows, cols): + BaseMode.on_resize(self, rows, cols) + self._calc_sizes() + + if self.popup: + self.popup.handle_resize() + + self.sidebar.on_resize() + self.refresh() + + @overrides(component.Component) + def update(self): + for i, p in enumerate(self.panes): + self.panes[i].update(i == self.cur_cat) + + @overrides(BaseMode) + def resume(self): + BaseMode.resume(self) + self.sidebar.show() + + @overrides(BaseMode) + def refresh(self): + if ( + not component.get('ConsoleUI').is_active_mode(self) + or not self.config_loaded + ): + return + + if self.popup is None and self.messages: + title, msg = self.messages.popleft() + self.push_popup(MessagePopup(self, title, msg)) + + self.stdscr.erase() + self.draw_statusbars() + self._draw_actions() + # Necessary to force updating the stdscr + self.stdscr.noutrefresh() + + self.sidebar.refresh() + + # do this last since it moves the cursor + self._draw_preferences() + + if self.popup: + self.popup.refresh() + + curses.doupdate() + + def _apply_prefs(self): + if self.core_config is None: + return + + def update_conf_value(key, source_dict, dest_dict, updated): + if dest_dict[key] != source_dict[key]: + dest_dict[key] = source_dict[key] + updated = True + return updated + + new_core_config = {} + for pane in self.panes: + if not isinstance(pane, InterfacePane): + pane.add_config_values(new_core_config) + # Apply Core Prefs + if client.connected(): + # Only do this if we're connected to a daemon + config_to_set = {} + for key in new_core_config: + # The values do not match so this needs to be updated + if self.core_config[key] != new_core_config[key]: + config_to_set[key] = new_core_config[key] + + if config_to_set: + # Set each changed config value in the core + client.core.set_config(config_to_set) + client.force_call(True) + # Update the configuration + self.core_config.update(config_to_set) + + # Update Interface Prefs + new_console_config = {} + didupdate = False + for pane in self.panes: + # could just access panes by index, but that would break if panes + # are ever reordered, so do it the slightly slower but safer way + if isinstance(pane, InterfacePane): + pane.add_config_values(new_console_config) + for k in ['ring_bell', 'language']: + didupdate = update_conf_value( + k, new_console_config, self.console_config, didupdate + ) + for k in ['separate_complete', 'move_selection']: + didupdate = update_conf_value( + k, + new_console_config, + self.console_config['torrentview'], + didupdate, + ) + for k in [ + 'ignore_duplicate_lines', + 'save_command_history', + 'third_tab_lists_all', + 'torrents_per_tab_press', + ]: + didupdate = update_conf_value( + k, new_console_config, self.console_config['cmdline'], didupdate + ) + + if didupdate: + self.parent_mode.on_config_changed() + + def _update_preferences(self, core_config): + self.core_config = core_config + for pane in self.panes: + pane.update_values(core_config) + + def _actions_read(self, c): + self.action_input.handle_read(c) + if c in [curses.KEY_ENTER, util.KEY_ENTER2]: + # take action + if self.action_input.selected_index == 0: # Cancel + self.back_to_parent() + elif self.action_input.selected_index == 1: # Apply + self._apply_prefs() + client.core.get_config().addCallback(self._update_preferences) + elif self.action_input.selected_index == 2: # OK + self._apply_prefs() + self.back_to_parent() + + def back_to_parent(self): + component.get('ConsoleUI').set_mode(self.parent_mode.mode_name) + + @overrides(BaseMode) + def read_input(self): + c = self.stdscr.getch() + + if self.popup: + if self.popup.handle_read(c): + self.pop_popup() + self.refresh() + return + + if util.is_printable_chr(c): + char = chr(c) + if char == 'Q': + component.get('ConsoleUI').quit() + elif char == 'h': + self.push_popup(MessagePopup(self, 'Preferences Help', HELP_STR)) + + if self.sidebar.has_focus() and c == util.KEY_ESC: + self.back_to_parent() + return + + def update_active_zone(val): + self.active_zone += val + if self.active_zone == -1: + self.active_zone = ZONE.length - 1 + else: + self.active_zone %= ZONE.length + self.sidebar.set_focused(self.active_zone == ZONE.CATEGORIES) + + if c == util.KEY_TAB: + update_active_zone(1) + elif c == curses.KEY_BTAB: + update_active_zone(-1) + else: + if self.active_zone == ZONE.CATEGORIES: + self.sidebar.handle_read(c) + elif self.active_zone == ZONE.PREFRENCES: + self.panes[self.cur_cat].handle_read(c) + elif self.active_zone == ZONE.ACTIONS: + self._actions_read(c) + + self.refresh() + + def is_active_pane(self, pane): + return pane == self.panes[self.cur_cat] diff --git a/deluge/ui/console/modes/torrentdetail.py b/deluge/ui/console/modes/torrentdetail.py new file mode 100644 index 0000000..4383d58 --- /dev/null +++ b/deluge/ui/console/modes/torrentdetail.py @@ -0,0 +1,1021 @@ +# +# Copyright (C) 2011 Nick Lanham +# +# 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.common import fsize +from deluge.decorators import overrides +from deluge.ui.client import client +from deluge.ui.common import FILE_PRIORITY +from deluge.ui.console.modes.basemode import BaseMode +from deluge.ui.console.modes.torrentlist.torrentactions import ( + ACTION, + torrent_actions_popup, +) +from deluge.ui.console.utils import colors +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.utils.column import get_column_value, torrent_data_fields +from deluge.ui.console.utils.format_utils import ( + format_priority, + format_progress, + format_row, +) +from deluge.ui.console.widgets.popup import ( + InputPopup, + MessagePopup, + PopupsHandler, + SelectablePopup, +) + +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 detailed information about a torrent, and also the \ +information about the individual files in the torrent. + +You can navigate the file list with the Up/Down arrows and use space to \ +collapse/expand the file tree. + +All popup windows can be closed/canceled by hitting the Esc key \ +(you might need to wait a second for an Esc to register) + +The actions you can perform and the keys to perform them are as follows: + +{!info!}'h'{!normal!} - Show this help + +{!info!}'a'{!normal!} - Show torrent actions popup. Here you can do things like \ +pause/resume, recheck, set torrent options and so on. + +{!info!}'r'{!normal!} - Rename currently highlighted folder or a file. You can't \ +rename multiple files at once so you need to first clear your selection \ +with {!info!}'c'{!normal!} + +{!info!}'m'{!normal!} - Mark or unmark a file or a folder +{!info!}'c'{!normal!} - Un-mark all files + +{!info!}Space{!normal!} - Expand/Collapse currently selected folder + +{!info!}Enter{!normal!} - Show priority popup in which you can set the \ +download priority of selected files and folders. + +{!info!}Left Arrow{!normal!} - Go back to torrent overview. +""" + + +class TorrentDetail(BaseMode, PopupsHandler): + def __init__(self, parent_mode, stdscr, console_config, encoding=None): + PopupsHandler.__init__(self) + self.console_config = console_config + self.parent_mode = parent_mode + self.torrentid = None + self.torrent_state = None + self._status_keys = [ + 'files', + 'name', + 'state', + 'download_payload_rate', + 'upload_payload_rate', + 'progress', + 'eta', + 'all_time_download', + 'total_uploaded', + 'ratio', + 'num_seeds', + 'total_seeds', + 'num_peers', + 'total_peers', + 'active_time', + 'seeding_time', + 'time_added', + 'distributed_copies', + 'num_pieces', + 'piece_length', + 'download_location', + 'file_progress', + 'file_priorities', + 'message', + 'total_wanted', + 'tracker_host', + 'owner', + 'seed_rank', + 'last_seen_complete', + 'completed_time', + 'time_since_transfer', + 'super_seeding', + ] + self.file_list = None + self.current_file = None + self.current_file_idx = 0 + self.file_off = 0 + self.more_to_draw = False + self.full_names = None + self.column_string = '' + self.files_sep = None + self.marked = {} + + BaseMode.__init__(self, stdscr, encoding) + self.column_names = ['Filename', 'Size', 'Progress', 'Priority'] + self.__update_columns() + + self._listing_start = self.rows // 2 + self._listing_space = self._listing_start - self._listing_start + + client.register_event_handler( + 'TorrentFileRenamedEvent', self._on_torrentfilerenamed_event + ) + client.register_event_handler( + 'TorrentFolderRenamedEvent', self._on_torrentfolderrenamed_event + ) + client.register_event_handler( + 'TorrentRemovedEvent', self._on_torrentremoved_event + ) + + util.safe_curs_set(util.Curser.INVISIBLE) + self.stdscr.notimeout(0) + + def set_torrent_id(self, torrentid): + self.torrentid = torrentid + self.file_list = None + + def back_to_overview(self): + component.get('ConsoleUI').set_mode(self.parent_mode.mode_name) + + @overrides(component.Component) + def start(self): + self.update() + + @overrides(component.Component) + def update(self, torrentid=None): + if torrentid: + self.set_torrent_id(torrentid) + + if self.torrentid: + component.get('SessionProxy').get_torrent_status( + self.torrentid, self._status_keys + ).addCallback(self.set_state) + + @overrides(BaseMode) + def pause(self): + self.set_torrent_id(None) + + @overrides(BaseMode) + def on_resize(self, rows, cols): + BaseMode.on_resize(self, rows, cols) + self.__update_columns() + if self.popup: + self.popup.handle_resize() + + self._listing_start = self.rows // 2 + self.refresh() + + def set_state(self, state): + if state.get('files'): + self.full_names = {x['index']: x['path'] for x in state['files']} + + need_prio_update = False + if not self.file_list: + # don't keep getting the files once we've got them once + if state.get('files'): + self.files_sep = '{!green,black,bold,underline!}%s' % ( + ('Files (torrent has %d files)' % len(state['files'])).center( + self.cols + ) + ) + self.file_list, self.file_dict = self.build_file_list( + state['files'], state['file_progress'], state['file_priorities'] + ) + else: + self.files_sep = '{!green,black,bold,underline!}%s' % ( + ('Files (File list unknown)').center(self.cols) + ) + need_prio_update = True + + self.__fill_progress(self.file_list, state['file_progress']) + + for i, prio in enumerate(state['file_priorities']): + if self.file_dict[i][6] != prio: + need_prio_update = True + self.file_dict[i][6] = prio + if need_prio_update and self.file_list: + self.__fill_prio(self.file_list) + del state['file_progress'] + del state['file_priorities'] + self.torrent_state = state + self.refresh() + + def build_file_list(self, torrent_files, progress, priority): + """Split file list from torrent state into a directory tree. + + Returns: + + Tuple: + A list of lists in the form: + [file/dir_name, index, size, children, expanded, progress, priority] + + Dictionary: + Map of file index for fast updating of progress and priorities. + """ + + file_list = [] + file_dict = {} + # directory index starts from total file count. + dir_idx = len(torrent_files) + for torrent_file in torrent_files: + cur = file_list + paths = torrent_file['path'].split('/') + for path in paths: + if not cur or path != cur[-1][0]: + child_list = [] + if path == paths[-1]: + file_progress = format_progress( + progress[torrent_file['index']] * 100 + ) + entry = [ + path, + torrent_file['index'], + torrent_file['size'], + child_list, + False, + file_progress, + priority[torrent_file['index']], + ] + file_dict[torrent_file['index']] = entry + else: + entry = [path, dir_idx, -1, child_list, False, 0, -1] + file_dict[dir_idx] = entry + dir_idx += 1 + cur.append(entry) + cur = child_list + else: + cur = cur[-1][3] + self.__build_sizes(file_list) + self.__fill_progress(file_list, progress) + + return file_list, file_dict + + # fill in the sizes of the directory entries based on their children + def __build_sizes(self, fs): + ret = 0 + for f in fs: + if f[2] == -1: + val = self.__build_sizes(f[3]) + ret += val + f[2] = val + else: + ret += f[2] + return ret + + # fills in progress fields in all entries based on progs + # returns the # of bytes complete in all the children of fs + def __fill_progress(self, fs, progs): + if not progs: + return 0 + tb = 0 + for f in fs: + if f[3]: # dir, has some children + bd = self.__fill_progress(f[3], progs) + f[5] = format_progress(bd // f[2] * 100) + else: # file, update own prog and add to total + bd = f[2] * progs[f[1]] + f[5] = format_progress(progs[f[1]] * 100) + tb += bd + return tb + + def __fill_prio(self, fs): + for f in fs: + if f[3]: # dir, so fill in children and compute our prio + self.__fill_prio(f[3]) + child_prios = [e[6] for e in f[3]] + if len(child_prios) > 1: + f[6] = -2 # mixed + else: + f[6] = child_prios.pop(0) + + def __update_columns(self): + self.column_widths = [-1, 15, 15, 20] + req = sum(col_width for col_width in self.column_widths if col_width >= 0) + if req > self.cols: # can't satisfy requests, just spread out evenly + cw = self.cols // len(self.column_names) + for i in range(0, len(self.column_widths)): + self.column_widths[i] = cw + else: + rem = self.cols - req + var_cols = len( + [col_width for col_width in self.column_widths if col_width < 0] + ) + vw = 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 = '{!green,black,bold!}%s' % ( + ''.join( + [ + '%s%s' + % ( + self.column_names[i], + ' ' * (self.column_widths[i] - len(self.column_names[i])), + ) + for i in range(0, len(self.column_names)) + ] + ) + ) + + def _on_torrentremoved_event(self, torrent_id): + if torrent_id == self.torrentid: + self.back_to_overview() + + def _on_torrentfilerenamed_event(self, torrent_id, index, new_name): + if torrent_id == self.torrentid: + self.file_dict[index][0] = new_name.split('/')[-1] + component.get('SessionProxy').get_torrent_status( + self.torrentid, self._status_keys + ).addCallback(self.set_state) + + def _on_torrentfolderrenamed_event(self, torrent_id, old_folder, new_folder): + if torrent_id == self.torrentid: + fe = None + fl = None + for i in old_folder.strip('/').split('/'): + if not fl: + fe = fl = self.file_list + s = [files for files in fl if files[0].strip('/') == i][0] + fe = s + fl = s[3] + fe[0] = new_folder.strip('/').rpartition('/')[-1] + + # self.__get_file_by_name(old_folder, self.file_list)[0] = new_folder.strip('/') + component.get('SessionProxy').get_torrent_status( + self.torrentid, self._status_keys + ).addCallback(self.set_state) + + def draw_files(self, files, depth, off, idx): + color_selected = 'blue' + color_partially_selected = 'magenta' + color_highlighted = 'white' + for fl in files: + # from sys import stderr + # print >> stderr, fl[6] + # kick out if we're going to draw too low on the screen + if off >= self.rows - 1: + self.more_to_draw = True + return -1, -1 + + # default color values + fg = 'white' + bg = 'black' + attr = '' + + priority_fg_color = { + -2: 'white', # Mixed + 0: 'red', # Skip + 1: 'yellow', # Low + 2: 'yellow', + 3: 'yellow', + 4: 'white', # Normal + 5: 'green', + 6: 'green', + 7: 'green', # High + } + + fg = priority_fg_color[fl[6]] + + if idx >= self.file_off: + # set fg/bg colors based on whether the file is selected/marked or not + + if fl[1] in self.marked: + bg = color_selected + if fl[3]: + if self.marked[fl[1]] < self.__get_contained_files_count( + file_list=fl[3] + ): + bg = color_partially_selected + attr = 'bold' + + if idx == self.current_file_idx: + self.current_file = fl + bg = color_highlighted + if fl[1] in self.marked: + fg = color_selected + if fl[3]: + if self.marked[fl[1]] < self.__get_contained_files_count( + file_list=fl[3] + ): + fg = color_partially_selected + else: + if fg == 'white': + fg = 'black' + attr = 'bold' + + if attr: + color_string = f'{{!{fg},{bg},{attr}!}}' + else: + color_string = f'{{!{fg},{bg}!}}' + + # actually draw the dir/file string + if fl[3] and fl[4]: # this is an expanded directory + xchar = 'v' + elif fl[3]: # collapsed directory + xchar = '>' + else: # file + xchar = '-' + + r = format_row( + [ + '{}{} {}'.format(' ' * depth, xchar, fl[0]), + fsize(fl[2]), + fl[5], + format_priority(fl[6]), + ], + self.column_widths, + ) + + self.add_string(off, f'{color_string}{r}', trim=False) + off += 1 + + if fl[3] and fl[4]: + # recurse if we have children and are expanded + off, idx = self.draw_files(fl[3], depth + 1, off, idx + 1) + if off < 0: + return (off, idx) + else: + idx += 1 + + return (off, idx) + + def __get_file_list_length(self, file_list=None): + """ + Counts length of the displayed file list. + """ + if file_list is None: + file_list = self.file_list + length = 0 + if file_list: + for element in file_list: + length += 1 + if element[3] and element[4]: + length += self.__get_file_list_length(element[3]) + return length + + def __get_contained_files_count(self, file_list=None, idx=None): + length = 0 + if file_list is None: + file_list = self.file_list + if idx is not None: + for element in file_list: + if element[1] == idx: + return self.__get_contained_files_count(file_list=element[3]) + elif element[3]: + c = self.__get_contained_files_count(file_list=element[3], idx=idx) + if c > 0: + return c + else: + for element in file_list: + length += 1 + if element[3]: + length -= 1 + length += self.__get_contained_files_count(element[3]) + return length + + def render_header(self, row): + status = self.torrent_state + + download_color = '{!info!}' + if status['download_payload_rate'] > 0: + download_color = colors.state_color['Downloading'] + + def add_field(name, row, pre_color='{!info!}', post_color='{!input!}'): + s = '{}{}: {}{}'.format( + pre_color, + torrent_data_fields[name]['name'], + post_color, + get_column_value(name, status), + ) + if row: + row = self.add_string(row, s) + return row + return s + + # Name + row = add_field('name', row) + # State + row = add_field('state', row) + + # Print DL info and ETA + s = add_field('downloaded', 0, download_color) + if status['progress'] != 100.0: + s += '/%s' % fsize(status['total_wanted']) + if status['download_payload_rate'] > 0: + s += ' {{!yellow!}}@ {}{}'.format( + download_color, + fsize(status['download_payload_rate']), + ) + s += add_field('eta', 0) + if s: + row = self.add_string(row, s) + + # Print UL info and ratio + s = add_field('uploaded', 0, download_color) + if status['upload_payload_rate'] > 0: + s += ' {{!yellow!}}@ {}{}'.format( + colors.state_color['Seeding'], + fsize(status['upload_payload_rate']), + ) + s += ' ' + add_field('ratio', 0) + row = self.add_string(row, s) + + # Seed/peer info + s = '{{!info!}}{}:{{!green!}} {} {{!input!}}({})'.format( + torrent_data_fields['seeds']['name'], + status['num_seeds'], + status['total_seeds'], + ) + row = self.add_string(row, s) + s = '{{!info!}}{}:{{!red!}} {} {{!input!}}({})'.format( + torrent_data_fields['peers']['name'], + status['num_peers'], + status['total_peers'], + ) + row = self.add_string(row, s) + + # Tracker + tracker_color = '{!green!}' if status['message'] == 'OK' else '{!red!}' + s = '{{!info!}}{}: {{!magenta!}}{}{{!input!}} says "{}{}{{!input!}}"'.format( + torrent_data_fields['tracker']['name'], + status['tracker_host'], + tracker_color, + status['message'], + ) + row = self.add_string(row, s) + + # Pieces and availability + s = '{{!info!}}{}: {{!yellow!}}{} {{!input!}}x {{!yellow!}}{}'.format( + torrent_data_fields['pieces']['name'], + status['num_pieces'], + fsize(status['piece_length']), + ) + if status['distributed_copies']: + s += '{{!info!}}{}: {{!input!}}{}'.format( + torrent_data_fields['seed_rank']['name'], + status['seed_rank'], + ) + row = self.add_string(row, s) + + # Time added + row = add_field('time_added', row) + # Time active + row = add_field('active_time', row) + if status['seeding_time']: + row = add_field('seeding_time', row) + # Download Folder + row = add_field('download_location', row) + # Seed Rank + row = add_field('seed_rank', row) + # Super Seeding + row = add_field('super_seeding', row) + # Last seen complete + row = add_field('last_seen_complete', row) + # Last activity + row = add_field('time_since_transfer', row) + # Owner + if status['owner']: + row = add_field('owner', row) + return row + # Last act + + @overrides(BaseMode) + def refresh(self, lines=None): + # Update the status bars + self.stdscr.erase() + self.draw_statusbars() + + row = 1 + if self.torrent_state: + row = self.render_header(row) + else: + self.add_string(1, 'Waiting for torrent state') + + row += 1 + + if self.files_sep: + self.add_string(row, self.files_sep) + row += 1 + + self._listing_start = row + self._listing_space = self.rows - self._listing_start + + self.add_string(row, self.column_string) + if self.file_list: + row += 1 + self.more_to_draw = False + self.draw_files(self.file_list, 0, row, 0) + + if not component.get('ConsoleUI').is_active_mode(self): + return + + self.stdscr.noutrefresh() + + if self.popup: + self.popup.refresh() + + curses.doupdate() + + def expcol_cur_file(self): + """ + Expand or collapse current file + """ + self.current_file[4] = not self.current_file[4] + self.refresh() + + def file_list_down(self, rows=1): + maxlen = self.__get_file_list_length() - 1 + + self.current_file_idx += rows + + if self.current_file_idx > maxlen: + self.current_file_idx = maxlen + + if self.current_file_idx > self.file_off + (self._listing_space - 3): + self.file_off = self.current_file_idx - (self._listing_space - 3) + + self.refresh() + + def file_list_up(self, rows=1): + self.current_file_idx = max(0, self.current_file_idx - rows) + self.file_off = min(self.file_off, self.current_file_idx) + self.refresh() + + # build list of priorities for all files in the torrent + # based on what is currently selected and a selected priority. + def build_prio_list(self, files, ret_list, parent_prio, selected_prio): + # has a priority been set on my parent (if so, I inherit it) + for f in files: + # Do not set priorities for the whole dir, just selected contents + if f[3]: + self.build_prio_list(f[3], ret_list, parent_prio, selected_prio) + else: # file, need to add to list + if f[1] in self.marked or parent_prio >= 0: + # selected (or parent selected), use requested priority + ret_list.append((f[1], selected_prio)) + else: + # not selected, just keep old priority + ret_list.append((f[1], f[6])) + + def do_priority(self, priority, was_empty): + plist = [] + self.build_prio_list(self.file_list, plist, -1, priority) + plist.sort() + priorities = [p[1] for p in plist] + client.core.set_torrent_options( + [self.torrentid], {'file_priorities': priorities} + ) + + if was_empty: + self.marked = {} + return True + + # show popup for priority selections + def show_priority_popup(self, was_empty): + def popup_func(name, data, was_empty, **kwargs): + if not name: + return + return self.do_priority(data[name], was_empty) + + if self.marked: + popup = SelectablePopup( + self, + 'Set File Priority', + popup_func, + border_off_north=1, + cb_args={'was_empty': was_empty}, + ) + popup.add_line( + 'skip_priority', + '_Skip', + foreground='red', + cb_arg=FILE_PRIORITY['Skip'], + was_empty=was_empty, + ) + popup.add_line( + 'low_priority', '_Low', cb_arg=FILE_PRIORITY['Low'], foreground='yellow' + ) + popup.add_line('normal_priority', '_Normal', cb_arg=FILE_PRIORITY['Normal']) + popup.add_line( + 'high_priority', + '_High', + cb_arg=FILE_PRIORITY['High'], + foreground='green', + ) + popup._selected = 1 + self.push_popup(popup) + + def __mark_unmark(self, idx): + """ + Selects or unselects file or a catalog(along with contained files) + """ + fc = self.__get_contained_files_count(idx=idx) + if idx not in self.marked: + # Not selected, select it + self.__mark_tree(self.file_list, idx) + elif self.marked[idx] < fc: + # Partially selected, unselect all contents + self.__unmark_tree(self.file_list, idx) + else: + # Selected, unselect it + self.__unmark_tree(self.file_list, idx) + + def __mark_tree(self, file_list, idx, mark_all=False): + """ + Given file_list of TorrentDetail and index of file or folder, + recursively selects all files contained + as well as marks folders higher in hierarchy as partially selected + """ + total_marked = 0 + for element in file_list: + marked = 0 + # Select the file if it's the one we want or + # if it's inside a directory that got selected + if (element[1] == idx) or mark_all: + # If it's a folder then select everything inside + if element[3]: + marked = self.__mark_tree(element[3], idx, True) + self.marked[element[1]] = marked + else: + marked = 1 + self.marked[element[1]] = 1 + else: + # Does not match but the item to be selected might be inside, recurse + if element[3]: + marked = self.__mark_tree(element[3], idx, False) + # Partially select the folder if it contains files that were selected + if marked > 0: + self.marked[element[1]] = marked + else: + if element[1] in self.marked: + # It's not the element we want but it's marked so count it + marked = 1 + # Count and then return total amount of files selected in all subdirectories + total_marked += marked + + return total_marked + + def __get_file_by_num(self, num, file_list, idx=0): + for element in file_list: + if idx == num: + return element + if element[3] and element[4]: + i = self.__get_file_by_num(num, element[3], idx + 1) + if not isinstance(i, int): + return i + idx = i + else: + idx += 1 + return idx + + def __get_file_by_name(self, name, file_list, idx=0): + for element in file_list: + if element[0].strip('/') == name.strip('/'): + return element + if element[3] and element[4]: + i = self.__get_file_by_name(name, element[3], idx + 1) + if not isinstance(i, int): + return i + else: + idx = i + else: + idx += 1 + return idx + + def __unmark_tree(self, file_list, idx, unmark_all=False): + """ + Given file_list of TorrentDetail and index of file or folder, + recursively deselects all files contained + as well as marks folders higher in hierarchy as unselected or partially selected + """ + total_marked = 0 + for element in file_list: + marked = 0 + # It's either the item we want to select or + # a contained item, deselect it + if (element[1] == idx) or unmark_all: + if element[1] in self.marked: + del self.marked[element[1]] + # Deselect all contents if it's a catalog + if element[3]: + self.__unmark_tree(element[3], idx, True) + else: + # Not file we wanted but it might be inside this folder, recurse inside + if element[3]: + marked = self.__unmark_tree(element[3], idx, False) + # If none of the contents remain selected, unselect this folder as well + if marked == 0: + if element[1] in self.marked: + del self.marked[element[1]] + # Otherwise update selection count + else: + self.marked[element[1]] = marked + else: + if element[1] in self.marked: + marked = 1 + + # Count and then return selection count so we can update + # directories higher up in the hierarchy + total_marked += marked + return total_marked + + def _selection_to_file_idx(self, file_list=None, idx=0, true_idx=0, closed=False): + if not file_list: + file_list = self.file_list + + for element in file_list: + if idx == self.current_file_idx: + return true_idx + + # It's a folder + if element[3]: + i = self._selection_to_file_idx( + element[3], idx + 1, true_idx, closed or not element[4] + ) + if isinstance(i, tuple): + idx, true_idx = i + if element[4]: + idx, true_idx = i + else: + idx += 1 + tmp, true_idx = i + else: + return i + else: + if not closed: + idx += 1 + true_idx += 1 + + return (idx, true_idx) + + def _get_full_folder_path(self, num, file_list=None, path='', idx=0): + if not file_list: + file_list = self.file_list + + for element in file_list: + if not element[3]: + idx += 1 + continue + if num == idx: + return f'{path}{element[0]}/' + if element[4]: + i = self._get_full_folder_path( + num, element[3], path + element[0] + '/', idx + 1 + ) + if not isinstance(i, int): + return i + idx = i + else: + idx += 1 + return idx + + def _do_rename_folder(self, torrent_id, folder, new_folder): + client.core.rename_folder(torrent_id, folder, new_folder) + + def _do_rename_file(self, torrent_id, file_idx, new_filename): + if not new_filename: + return + client.core.rename_files(torrent_id, [(file_idx, new_filename)]) + + def _show_rename_popup(self): + # Perhaps in the future: Renaming multiple files + if self.marked: + self.report_message( + 'Error (Enter to close)', + 'Sorry, you cannot rename multiple files, please clear ' + 'selection with {!info!}"c"{!normal!} key', + ) + else: + _file = self.__get_file_by_num(self.current_file_idx, self.file_list) + old_filename = _file[0] + idx = self._selection_to_file_idx() + tid = self.torrentid + + if _file[3]: + + def do_rename(result, **kwargs): + if ( + not result + or not result['new_foldername']['value'] + or kwargs.get('close', False) + ): + self.popup.close(None, call_cb=False) + return + old_fname = self._get_full_folder_path(self.current_file_idx) + new_fname = '{}/{}/'.format( + old_fname.strip('/').rpartition('/')[0], + result['new_foldername']['value'], + ) + self._do_rename_folder(tid, old_fname, new_fname) + + popup = InputPopup( + self, 'Rename folder (Esc to cancel)', close_cb=do_rename + ) + popup.add_text_input( + 'new_foldername', + 'Enter new folder name:', + old_filename.strip('/'), + complete=True, + ) + self.push_popup(popup) + else: + + def do_rename(result, **kwargs): + if ( + not result + or not result['new_filename']['value'] + or kwargs.get('close', False) + ): + self.popup.close(None, call_cb=False) + return + fname = '{}/{}'.format( + self.full_names[idx].rpartition('/')[0], + result['new_filename']['value'], + ) + self._do_rename_file(tid, idx, fname) + + popup = InputPopup(self, ' Rename file ', close_cb=do_rename) + popup.add_text_input( + 'new_filename', 'Enter new filename:', old_filename, complete=True + ) + self.push_popup(popup) + + @overrides(BaseMode) + def read_input(self): + c = self.stdscr.getch() + + if self.popup: + ret = self.popup.handle_read(c) + if ret != util.ReadState.IGNORED and self.popup.closed(): + self.pop_popup() + self.refresh() + return + + if c in [util.KEY_ESC, curses.KEY_LEFT, ord('q')]: + self.back_to_overview() + return util.ReadState.READ + + if not self.torrent_state: + # actions below only make sense if there is a torrent state + return + + # Navigate the torrent list + if c == curses.KEY_UP: + self.file_list_up() + elif c == curses.KEY_PPAGE: + self.file_list_up(self._listing_space - 2) + elif c == curses.KEY_HOME: + self.file_off = 0 + self.current_file_idx = 0 + elif c == curses.KEY_DOWN: + self.file_list_down() + elif c == curses.KEY_NPAGE: + self.file_list_down(self._listing_space - 2) + elif c == curses.KEY_END: + self.current_file_idx = self.__get_file_list_length() - 1 + self.file_off = self.current_file_idx - (self._listing_space - 3) + elif c == curses.KEY_DC: + torrent_actions_popup(self, [self.torrentid], action=ACTION.REMOVE) + elif c in [curses.KEY_ENTER, util.KEY_ENTER2]: + was_empty = self.marked == {} + self.__mark_tree(self.file_list, self.current_file[1]) + self.show_priority_popup(was_empty) + elif c == util.KEY_SPACE: + self.expcol_cur_file() + elif c == ord('m'): + if self.current_file: + self.__mark_unmark(self.current_file[1]) + elif c == ord('r'): + self._show_rename_popup() + elif c == ord('c'): + self.marked = {} + elif c == ord('a'): + torrent_actions_popup(self, [self.torrentid], details=False) + return + elif c == ord('o'): + torrent_actions_popup(self, [self.torrentid], action=ACTION.TORRENT_OPTIONS) + return + elif c == ord('h'): + self.push_popup(MessagePopup(self, 'Help', HELP_STR, width_req=0.75)) + elif c == ord('j'): + self.file_list_down() + elif c == ord('k'): + self.file_list_up() + + self.refresh() 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 +# +# 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 +# +# 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 +# +# 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 +# +# 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 +# +# 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 +# +# 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+ + if c == util.KEY_ESC: + n = self.stdscr.getch() + if n == -1: # Means it was the escape key + pass + else: # ALT+ + 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 +# +# 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) diff --git a/deluge/ui/console/parser.py b/deluge/ui/console/parser.py new file mode 100644 index 0000000..c0686b1 --- /dev/null +++ b/deluge/ui/console/parser.py @@ -0,0 +1,140 @@ +# +# Copyright (C) 2016 bendikro +# +# 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 argparse +import shlex + +import deluge.component as component +from deluge.ui.console.utils.colors import ConsoleColorFormatter + + +class OptionParserError(Exception): + pass + + +class ConsoleBaseParser(argparse.ArgumentParser): + def format_help(self): + """Differs from ArgumentParser.format_help by adding the raw epilog + as formatted in the string. Default behavior mangles the formatting. + + """ + # Handle epilog manually to keep the text formatting + epilog = self.epilog + self.epilog = '' + help_str = super().format_help() + if epilog is not None: + help_str += epilog + self.epilog = epilog + return help_str + + +class ConsoleCommandParser(ConsoleBaseParser): + def _split_args(self, args): + command_options = [] + for a in args: + if not a: + continue + if ';' in a: + cmd_lines = [arg.strip() for arg in a.split(';')] + elif ' ' in a: + cmd_lines = [a] + else: + continue + + for cmd_line in cmd_lines: + cmds = shlex.split(cmd_line) + cmd_options = super().parse_args(args=cmds) + cmd_options.command = cmds[0] + command_options.append(cmd_options) + + return command_options + + def parse_args(self, args=None): + """Parse known UI args and handle common and process group options. + + Notes: + If started by deluge entry script this has already been done. + + Args: + args (list, optional): The arguments to parse. + + Returns: + argparse.Namespace: The parsed arguments. + """ + from deluge.ui.ui_entry import AMBIGUOUS_CMD_ARGS + + self.base_parser.parse_known_ui_args(args, withhold=AMBIGUOUS_CMD_ARGS) + + multi_command = self._split_args(args) + # If multiple commands were passed to console + if multi_command: + # With multiple commands, normal parsing will fail, so only parse + # known arguments using the base parser, and then set + # options.parsed_cmds to the already parsed commands + options, remaining = self.base_parser.parse_known_args(args=args) + options.parsed_cmds = multi_command + else: + subcommand = False + if hasattr(self.base_parser, 'subcommand'): + subcommand = getattr(self.base_parser, 'subcommand') + if not subcommand: + # We must use parse_known_args to handle case when no subcommand + # is provided, because argparse does not support parsing without + # a subcommand + options, remaining = self.base_parser.parse_known_args(args=args) + # If any options remain it means they do not exist. Reparse with + # parse_args to trigger help message + if remaining: + options = self.base_parser.parse_args(args=args) + options.parsed_cmds = [] + else: + options = super().parse_args(args=args) + options.parsed_cmds = [options] + + if not hasattr(options, 'remaining'): + options.remaining = [] + + return options + + +class OptionParser(ConsoleBaseParser): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.formatter = ConsoleColorFormatter() + + def exit(self, status=0, msg=None): + self._exit = True + if msg: + print(msg) + + def error(self, msg): + """error(msg : string) + + Print a usage message incorporating 'msg' to stderr and exit. + If you override this in a subclass, it should not return -- it + should either exit or raise an exception. + """ + raise OptionParserError(msg) + + def print_usage(self, _file=None): + console = component.get('ConsoleUI') + if self.usage: + for line in self.format_usage().splitlines(): + console.write(line) + + def print_help(self, _file=None): + console = component.get('ConsoleUI') + console.set_batch_write(True) + for line in self.format_help().splitlines(): + console.write(line) + console.set_batch_write(False) + + def format_help(self): + """Return help formatted with colors.""" + help_str = super().format_help() + return self.formatter.format_colors(help_str) diff --git a/deluge/ui/console/utils/__init__.py b/deluge/ui/console/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deluge/ui/console/utils/colors.py b/deluge/ui/console/utils/colors.py new file mode 100644 index 0000000..cc414fe --- /dev/null +++ b/deluge/ui/console/utils/colors.py @@ -0,0 +1,323 @@ +# +# Copyright (C) 2009 Andrew Resch +# +# 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 re + +from deluge.ui.console.utils import format_utils + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) + +colors = [ + 'COLOR_BLACK', + 'COLOR_BLUE', + 'COLOR_CYAN', + 'COLOR_GREEN', + 'COLOR_MAGENTA', + 'COLOR_RED', + 'COLOR_WHITE', + 'COLOR_YELLOW', +] + +# {(fg, bg): pair_number, ...} +color_pairs = {('white', 'black'): 0} # Special case, can't be changed + +# Some default color schemes +schemes = { + 'input': ('white', 'black'), + 'normal': ('white', 'black'), + 'status': ('yellow', 'blue', 'bold'), + 'info': ('white', 'black', 'bold'), + 'error': ('red', 'black', 'bold'), + 'success': ('green', 'black', 'bold'), + 'event': ('magenta', 'black', 'bold'), + 'selected': ('black', 'white', 'bold'), + 'marked': ('white', 'blue', 'bold'), + 'selectedmarked': ('blue', 'white', 'bold'), + 'header': ('green', 'black', 'bold'), + 'filterstatus': ('green', 'blue', 'bold'), +} + +# Colors for various torrent states +state_color = { + 'Seeding': '{!blue,black,bold!}', + 'Downloading': '{!green,black,bold!}', + 'Paused': '{!white,black!}', + 'Checking': '{!green,black!}', + 'Queued': '{!yellow,black!}', + 'Error': '{!red,black,bold!}', + 'Moving': '{!green,black,bold!}', +} + +type_color = { + bool: '{!yellow,black,bold!}', + int: '{!green,black,bold!}', + float: '{!green,black,bold!}', + str: '{!cyan,black,bold!}', + list: '{!magenta,black,bold!}', + dict: '{!white,black,bold!}', +} + +tab_char = '\t' +color_tag_start = '{!' +color_tag_end = '!}' + + +def get_color_pair(fg, bg): + return color_pairs[(fg, bg)] + + +def init_colors(): + curses.start_color() + + # We want to redefine white/black as it makes underlining work for some terminals + # but can also fail on others, so we try/except + + def define_pair(counter, fg_name, bg_name, fg, bg): + try: + curses.init_pair(counter, fg, bg) + color_pairs[(fg_name, bg_name)] = counter + counter += 1 + except (curses.error, ValueError) as ex: + log.debug(f'Color pair {fg_name} {bg_name} not available: {ex}') + return counter + + # Create the color_pairs dict + counter = 1 + for fg in colors: + for bg in colors: + counter = define_pair( + counter, + fg[6:].lower(), + bg[6:].lower(), + getattr(curses, fg), + getattr(curses, bg), + ) + + counter = define_pair(counter, 'white', 'grey', curses.COLOR_WHITE, 241) + counter = define_pair(counter, 'black', 'whitegrey', curses.COLOR_BLACK, 249) + counter = define_pair(counter, 'magentadark', 'white', 99, curses.COLOR_WHITE) + + +class BadColorString(Exception): + pass + + +def check_tag_count(string): + """Raise BadColorString if color tag open/close not equal.""" + if string.count(color_tag_start) != string.count(color_tag_end): + raise BadColorString('Number of {! is not equal to number of !}') + + +def replace_tabs(line): + """ + Returns a string with tabs replaced with spaces. + + """ + for i in range(line.count(tab_char)): + tab_length = 8 - (len(line[: line.find(tab_char)]) % 8) + line = line.replace(tab_char, b' ' * tab_length, 1) + return line + + +def strip_colors(line): + """ + Returns a string with the color formatting removed. + + """ + check_tag_count(line) + + # Remove all the color tags + while line.find(color_tag_start) != -1: + tag_start = line.find(color_tag_start) + tag_end = line.find(color_tag_end) + 2 + line = line[:tag_start] + line[tag_end:] + + return line + + +def get_line_length(line): + """ + Returns the string length without the color formatting. + + """ + # Remove all the color tags + line = strip_colors(line) + + # Replace tabs with the appropriate amount of spaces + line = replace_tabs(line) + return len(line) + + +def get_line_width(line): + """ + Get width of string considering double width characters + + """ + # Remove all the color tags + line = strip_colors(line) + + # Replace tabs with the appropriate amount of spaces + line = replace_tabs(line) + return format_utils.strwidth(line) + + +def parse_color_string(string): + """Parses a string and returns a list of 2-tuples (color, string). + + Args: + string (str): The string to parse. + """ + check_tag_count(string) + + ret = [] + last_color_attr = None + # Keep track of where the strings + while string.find(color_tag_start) != -1: + begin = string.find(color_tag_start) + if begin > 0: + ret.append( + ( + curses.color_pair( + color_pairs[(schemes['input'][0], schemes['input'][1])] + ), + string[:begin], + ) + ) + + end = string.find(color_tag_end) + if end == -1: + raise BadColorString('Missing closing "!}"') + + # Get a list of attributes in the bracketed section + attrs = string[begin + 2 : end].split(',') + + if len(attrs) == 1 and not attrs[0].strip(' '): + raise BadColorString('No description in {! !}') + + def apply_attrs(cp, attrs): + # This function applies any additional attributes as necessary + for attr in attrs: + if attr == 'ignore': + continue + mode = '+' + if attr[0] in ['+', '-']: + mode = attr[0] + attr = attr[1:] + if mode == '+': + cp |= getattr(curses, 'A_' + attr.upper()) + else: + cp ^= getattr(curses, 'A_' + attr.upper()) + return cp + + # Check for a builtin type first + if attrs[0] in schemes: + pair = (schemes[attrs[0]][0], schemes[attrs[0]][1]) + if pair not in color_pairs: + log.debug('Color pair does not exist: %s, attrs: %s', pair, attrs) + pair = ('white', 'black') + # Get the color pair number + color_pair = curses.color_pair(color_pairs[pair]) + color_pair = apply_attrs(color_pair, schemes[attrs[0]][2:]) + last_color_attr = color_pair + else: + attrlist = ['blink', 'bold', 'dim', 'reverse', 'standout', 'underline'] + + if attrs[0][0] in ['+', '-']: + # Color is not given, so use last color + if last_color_attr is None: + raise BadColorString( + 'No color value given when no previous color was used!: %s' + % (attrs[0]) + ) + color_pair = last_color_attr + for i, attr in enumerate(attrs): + if attr[1:] not in attrlist: + raise BadColorString('Bad attribute value!: %s' % (attr)) + else: + # This is a custom color scheme + fg = attrs[0] + bg = 'black' # Default to 'black' if no bg is chosen + if len(attrs) > 1: + bg = attrs[1] + try: + pair = (fg, bg) + if pair not in color_pairs: + # Color pair missing, this could be because the + # terminal settings allows no colors. If background is white, we + # assume this means selection, and use "white", "black" + reverse + # To have white background and black foreground + log.debug('Color pair does not exist: %s', pair) + if pair[1] == 'white': + if attrs[2] == 'ignore': + attrs[2] = 'reverse' + else: + attrs.append('reverse') + pair = ('white', 'black') + color_pair = curses.color_pair(color_pairs[pair]) + last_color_attr = color_pair + attrs = attrs[2:] # Remove colors + except KeyError: + raise BadColorString(f'Bad color value in tag: {fg},{bg}') + # Check for additional attributes and OR them to the color_pair + color_pair = apply_attrs(color_pair, attrs) + last_color_attr = color_pair + # We need to find the text now, so lets try to find another {! and if + # there isn't one, then it's the rest of the string + next_begin = string.find(color_tag_start, end) + + if next_begin == -1: + ret.append((color_pair, replace_tabs(string[end + 2 :]))) + break + else: + ret.append((color_pair, replace_tabs(string[end + 2 : next_begin]))) + string = string[next_begin:] + + if not ret: + # There was no color scheme so we add it with a 0 for white on black + ret = [(0, string)] + return ret + + +class ConsoleColorFormatter: + """ + Format help in a way suited to deluge CmdLine mode - colors, format, indentation... + """ + + replace_dict = { + '': '{!green!}%s{!input!}', + '': '{!green!}%s{!input!}', + '': '{!green!}%s{!input!}', + '': '{!yellow!}%s{!input!}', + '\\.\\.\\.': '{!yellow!}%s{!input!}', + '\\s\\*\\s': '{!blue!}%s{!input!}', + '(?': '{!white!}%s{!input!}', + '[_A-Z]{3,}': '{!cyan!}%s{!input!}', + '': '{!cyan!}%s{!input!}', + '': '{!cyan!}%s{!input!}', + 'usage:': '{!info!}%s{!input!}', + '': '{!yellow!}%s{!input!}', + '': '{!green!}%s{!input!}', + } + + def format_colors(self, string): + def r(repl): + return lambda s: repl % s.group() + + for key, replacement in self.replace_dict.items(): + string = re.sub(key, r(replacement), string) + return string diff --git a/deluge/ui/console/utils/column.py b/deluge/ui/console/utils/column.py new file mode 100644 index 0000000..ecbe04b --- /dev/null +++ b/deluge/ui/console/utils/column.py @@ -0,0 +1,74 @@ +# +# Copyright (C) 2011 Nick Lanham +# +# 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 copy +import logging + +import deluge.common +from deluge.i18n import setup_translation +from deluge.ui.common import TORRENT_DATA_FIELD +from deluge.ui.console.utils import format_utils + +setup_translation() + +log = logging.getLogger(__name__) + +torrent_data_fields = copy.deepcopy(TORRENT_DATA_FIELD) + +formatters = { + 'queue': format_utils.format_queue, + 'name': lambda a, b: b, + 'state': None, + 'tracker': None, + 'download_location': None, + 'owner': None, + 'progress_state': format_utils.format_progress, + 'progress': format_utils.format_progress, + 'size': format_utils.format_size, + 'downloaded': format_utils.format_size, + 'uploaded': format_utils.format_size, + 'remaining': format_utils.format_size, + 'ratio': format_utils.format_float, + 'avail': format_utils.format_float, + 'seeds_peers_ratio': format_utils.format_float, + 'download_speed': format_utils.format_speed, + 'upload_speed': format_utils.format_speed, + 'max_download_speed': format_utils.format_speed, + 'max_upload_speed': format_utils.format_speed, + 'peers': format_utils.format_seeds_peers, + 'seeds': format_utils.format_seeds_peers, + 'time_added': deluge.common.fdate, + 'seeding_time': format_utils.format_time, + 'active_time': format_utils.format_time, + 'time_since_transfer': format_utils.format_date_dash, + 'finished_time': deluge.common.ftime, + 'last_seen_complete': format_utils.format_date_never, + 'completed_time': format_utils.format_date_dash, + 'eta': format_utils.format_time, + 'pieces': format_utils.format_pieces, +} + +for data_field in torrent_data_fields: + torrent_data_fields[data_field]['formatter'] = formatters.get(data_field, str) + + +def get_column_value(name, state): + col = torrent_data_fields[name] + + if col['formatter']: + args = [state[key] for key in col['status']] + return col['formatter'](*args) + else: + return state[col['status'][0]] + + +def get_required_fields(cols): + fields = [] + for col in cols: + fields.extend(torrent_data_fields[col]['status']) + return fields diff --git a/deluge/ui/console/utils/common.py b/deluge/ui/console/utils/common.py new file mode 100644 index 0000000..fdc88c4 --- /dev/null +++ b/deluge/ui/console/utils/common.py @@ -0,0 +1,20 @@ +# +# 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. +# + +TORRENT_OPTIONS = { + 'max_download_speed': float, + 'max_upload_speed': float, + 'max_connections': int, + 'max_upload_slots': int, + 'prioritize_first_last': bool, + 'is_auto_managed': bool, + 'stop_at_ratio': bool, + 'stop_ratio': float, + 'remove_at_ratio': bool, + 'move_completed': bool, + 'move_completed_path': str, + 'super_seeding': bool, +} diff --git a/deluge/ui/console/utils/config.py b/deluge/ui/console/utils/config.py new file mode 100644 index 0000000..debb52a --- /dev/null +++ b/deluge/ui/console/utils/config.py @@ -0,0 +1,118 @@ +def migrate_1_to_2(config): + """Create better structure by moving most settings out of dict root + and into sub categories. Some keys are also renamed to be consistent + with other UIs. + """ + + def move_key(source, dest, source_key, dest_key=None): + if dest_key is None: + dest_key = source_key + + dest[dest_key] = source[source_key] + del source[source_key] + + # These are moved to 'torrentview' sub dict + for k in [ + 'sort_primary', + 'sort_secondary', + 'move_selection', + 'separate_complete', + ]: + move_key(config, config['torrentview'], k) + + # These are moved to 'addtorrents' sub dict + for k in [ + 'show_misc_files', + 'show_hidden_folders', + 'sort_column', + 'reverse_sort', + 'last_path', + ]: + move_key(config, config['addtorrents'], 'addtorrents_%s' % k, dest_key=k) + + # These are moved to 'cmdline' sub dict + for k in [ + 'ignore_duplicate_lines', + 'torrents_per_tab_press', + 'third_tab_lists_all', + ]: + move_key(config, config['cmdline'], k) + + move_key( + config, + config['cmdline'], + 'save_legacy_history', + dest_key='save_command_history', + ) + + # Add key for localization + config['language'] = '' + + # Migrate column settings + columns = [ + 'queue', + 'size', + 'state', + 'progress', + 'seeds', + 'peers', + 'downspeed', + 'upspeed', + 'eta', + 'ratio', + 'avail', + 'added', + 'tracker', + 'savepath', + 'downloaded', + 'uploaded', + 'remaining', + 'owner', + 'downloading_time', + 'seeding_time', + 'completed', + 'seeds_peers_ratio', + 'complete_seen', + 'down_limit', + 'up_limit', + 'shared', + 'name', + ] + column_name_mapping = { + 'downspeed': 'download_speed', + 'upspeed': 'upload_speed', + 'added': 'time_added', + 'savepath': 'download_location', + 'completed': 'completed_time', + 'complete_seen': 'last_seen_complete', + 'down_limit': 'max_download_speed', + 'up_limit': 'max_upload_speed', + 'downloading_time': 'active_time', + } + + from deluge.ui.console.modes.torrentlist.torrentview import default_columns + + # These are moved to 'torrentview.columns' sub dict + for k in columns: + column_name = column_name_mapping.get(k, k) + config['torrentview']['columns'][column_name] = {} + if k == 'name': + config['torrentview']['columns'][column_name]['visible'] = True + else: + move_key( + config, + config['torrentview']['columns'][column_name], + 'show_%s' % k, + dest_key='visible', + ) + move_key( + config, + config['torrentview']['columns'][column_name], + '%s_width' % k, + dest_key='width', + ) + config['torrentview']['columns'][column_name]['order'] = default_columns[ + column_name + ]['order'] + + return config diff --git a/deluge/ui/console/utils/curses_util.py b/deluge/ui/console/utils/curses_util.py new file mode 100644 index 0000000..50b0444 --- /dev/null +++ b/deluge/ui/console/utils/curses_util.py @@ -0,0 +1,62 @@ +# +# Copyright (C) 2016 bendikro +# +# 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. +# + +try: + import curses +except ImportError: + pass + +KEY_BELL = 7 # CTRL-/ ^G (curses.keyname(KEY_BELL) == "^G") +KEY_TAB = 9 +KEY_ENTER2 = 10 +KEY_ESC = 27 +KEY_SPACE = 32 +KEY_BACKSPACE2 = 127 + +KEY_ALT_AND_ARROW_UP = 564 +KEY_ALT_AND_ARROW_DOWN = 523 + +KEY_ALT_AND_KEY_PPAGE = 553 +KEY_ALT_AND_KEY_NPAGE = 548 + +KEY_CTRL_AND_ARROW_UP = 566 +KEY_CTRL_AND_ARROW_DOWN = 525 + + +def is_printable_chr(c): + return c >= 32 and c <= 126 + + +def is_int_chr(c): + return c > 47 and c < 58 + + +class Curser: + INVISIBLE = 0 + NORMAL = 1 + VERY_VISIBLE = 2 + + +def safe_curs_set(visibility): + """ + Args: + visibility(int): 0, 1, or 2, for invisible, normal, or very visible + + curses.curs_set fails on monochrome terminals so use this + to ignore errors + """ + try: + curses.curs_set(visibility) + except curses.error: + pass + + +class ReadState: + IGNORED = 0 + READ = 1 + CHANGED = 2 diff --git a/deluge/ui/console/utils/format_utils.py b/deluge/ui/console/utils/format_utils.py new file mode 100644 index 0000000..50ec191 --- /dev/null +++ b/deluge/ui/console/utils/format_utils.py @@ -0,0 +1,350 @@ +# +# Copyright (C) 2011 Nick Lanham +# +# 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 re +from collections import deque +from unicodedata import east_asian_width, normalize + +import deluge.common +from deluge.ui.common import FILE_PRIORITY + + +def format_size(size): + return deluge.common.fsize(size, shortform=True) + + +def format_speed(speed): + if speed > 0: + return deluge.common.fspeed(speed, shortform=True) + else: + return '-' + + +def format_time(time): + if time > 0: + return deluge.common.ftime(time) + elif time == 0: + return '-' + else: + return '∞' + + +def format_date_dash(time): + if time > 0: + return deluge.common.fdate(time, date_only=True) + else: + return '-' + + +def format_date_never(time): + if time > 0: + return deluge.common.fdate(time, date_only=True) + else: + return 'Never' + + +def format_float(x): + if x < 0: + return '-' + else: + return '%.3f' % x + + +def format_seeds_peers(num, total): + return '%d (%d)' % (num, total) + + +def format_progress(value): + return ('%.2f' % value).rstrip('0').rstrip('.') + '%' + + +def f_progressbar(progress, width): + """ + Returns a string of a progress bar. + + :param progress: float, a value between 0-100 + + :returns: str, a progress bar based on width + + """ + + w = width - 2 # we use a [] for the beginning and end + s = '[' + p = int(round((progress / 100) * w)) + s += '#' * p + s += '-' * (w - p) + s += ']' + return s + + +def f_seedrank_dash(seed_rank, seeding_time): + """Display value if seeding otherwise dash""" + + if seeding_time > 0: + if seed_rank >= 1000: + return '%ik' % (seed_rank // 1000) + else: + return str(seed_rank) + else: + return '-' + + +def ftotal_sized(first, second): + return '{} ({})'.format( + deluge.common.fsize(first, shortform=True), + deluge.common.fsize(second, shortform=True), + ) + + +def format_pieces(num, size): + return '%d (%s)' % (num, deluge.common.fsize(size, shortform=True)) + + +def format_priority(prio): + if prio == -2: + return '[Mixed]' + elif prio < 0: + return '-' + return FILE_PRIORITY[prio] + + +def format_queue(qnum): + if qnum < 0: + return '' + return '%d' % (qnum + 1) + + +def trim_string(string, w, have_dbls): + if w <= 0: + return '' + elif w == 1: + return ' ' + elif have_dbls: + # have to do this the slow way + chrs = [] + width = 4 + idx = 0 + while width < w: + chrs.append(string[idx]) + if east_asian_width(string[idx]) in 'WF': + width += 2 + else: + width += 1 + idx += 1 + if width != w: + chrs.pop() + chrs.append('.') + return '%s ' % (''.join(chrs)) + else: + return '%s ' % (string[0 : w - 1]) + + +def format_column(col, lim): + try: + # might have some double width chars + col = normalize('NFC', col) + dbls = sum(east_asian_width(c) in 'WF' for c in col) + except TypeError: + dbls = 0 + + size = len(col) + dbls + if size >= lim - 1: + return trim_string(col, lim, dbls > 0) + else: + return '{}{}'.format(col, ' ' * (lim - size)) + + +def format_row(row, column_widths): + return ''.join( + [format_column(row[i], column_widths[i]) for i in range(0, len(row))] + ) + + +_strip_re = re.compile(r'\{!.*?!\}') +_format_code = re.compile(r'\{\|(.*)\|\}') + + +def remove_formatting(string): + return re.sub(_strip_re, '', string) + + +def shorten_hash(tid, space_left, min_width=13, placeholder='...'): + """Shorten the supplied torrent infohash by removing chars from the middle. + + Use a placeholder to indicate shortened. + If unable to shorten will justify so entire tid is on the next line. + + """ + tid = tid.strip() + if space_left >= min_width: + mid = len(tid) // 2 + trim, remain = divmod(len(tid) + len(placeholder) - space_left, 2) + return tid[0 : mid - trim] + placeholder + tid[mid + trim + remain :] + else: + # Justity the tid so it is completely on the next line. + return tid.rjust(len(tid) + space_left) + + +def wrap_string(string, width, min_lines=0, strip_colors=True): + """ + Wrap a string to fit in a particular width. Returns a list of output lines. + + :param string: str, the string to wrap + :param width: int, the maximum width of a line of text + :param min_lines: int, extra lines will be added so the output tuple contains at least min_lines lines + :param strip_colors: boolean, if True, text in {!!} blocks will not be considered as adding to the + width of the line. They will still be present in the output. + """ + ret = [] + s1 = string.split('\n') + indent = '' + + def insert_clr(s, offset, mtchs, clrs): + end_pos = offset + len(s) + while mtchs and (mtchs[0] <= end_pos) and (mtchs[0] >= offset): + mtc = mtchs.popleft() - offset + clr = clrs.popleft() + end_pos += len(clr) + s = f'{s[:mtc]}{clr}{s[mtc:]}' + return s + + for s in s1: + offset = 0 + indent = '' + m = _format_code.search(remove_formatting(s)) + if m: + if m.group(1).startswith('indent:'): + indent = m.group(1)[len('indent:') :] + elif m.group(1).startswith('indent_pos:'): + begin = m.start(0) + indent = ' ' * begin + s = _format_code.sub('', s) + + if strip_colors: + mtchs = deque() + clrs = deque() + for m in _strip_re.finditer(s): + mtchs.append(m.start()) + clrs.append(m.group()) + cstr = _strip_re.sub('', s) + else: + cstr = s + + def append_indent(line, string, offset): + """Prepends indent to string if specified""" + if indent and offset != 0: + string = indent + string + line.append(string) + + while cstr: + # max with for a line. If indent is specified, we account for this + max_width = width - (len(indent) if offset != 0 else 0) + if len(cstr) < max_width: + break + sidx = cstr.rfind(' ', 0, max_width - 1) + sidx += 1 + if sidx > 0: + if strip_colors: + to_app = cstr[0:sidx] + to_app = insert_clr(to_app, offset, mtchs, clrs) + append_indent(ret, to_app, offset) + offset += len(to_app) + else: + append_indent(ret, cstr[0:sidx], offset) + cstr = cstr[sidx:] + if not cstr: + cstr = None + break + else: + # can't find a reasonable split, just split at width + if strip_colors: + to_app = cstr[0:width] + to_app = insert_clr(to_app, offset, mtchs, clrs) + append_indent(ret, to_app, offset) + offset += len(to_app) + else: + append_indent(ret, cstr[0:width], offset) + cstr = cstr[width:] + if not cstr: + cstr = None + break + if cstr is not None: + to_append = cstr + if strip_colors: + to_append = insert_clr(cstr, offset, mtchs, clrs) + append_indent(ret, to_append, offset) + + if min_lines > 0: + for i in range(len(ret), min_lines): + ret.append(' ') + + # Carry colors over to the next line + last_color_string = '' + for i, line in enumerate(ret): + if i != 0: + ret[i] = f'{last_color_string}{ret[i]}' + + colors = re.findall('\\{![^!]+!\\}', line) + if colors: + last_color_string = colors[-1] + + return ret + + +def strwidth(string): + """ + Measure width of a string considering asian double width characters + """ + return sum(1 + (east_asian_width(char) in ['W', 'F']) for char in string) + + +def pad_string(string, length, character=' ', side='right'): + """ + Pad string with specified character to desired length, considering double width characters. + """ + w = strwidth(string) + diff = length - w + if side == 'left': + return f'{character * diff}{string}' + elif side == 'right': + return f'{string}{character * diff}' + + +def delete_alt_backspace(input_text, input_cursor, sep_chars=' *?!._~-#$^;\'"/'): + """ + Remove text from input_text on ALT+backspace + Stop removing when countering any of the sep chars + """ + deleted = 0 + seg_start = input_text[:input_cursor] + seg_end = input_text[input_cursor:] + none_space_deleted = False # Track if any none-space characters have been deleted + + while seg_start and input_cursor > 0: + if (not seg_start) or (input_cursor == 0): + break + if deleted and seg_start[-1] in sep_chars: + if seg_start[-1] == ' ': + if seg_start[-2] == ' ' or none_space_deleted is False: + # Continue as long as: + # * next char is also a space + # * no none-space characters have been deleted + pass + else: + break + else: + break + + if not none_space_deleted: + none_space_deleted = seg_start[-1] != ' ' + seg_start = seg_start[:-1] + deleted += 1 + input_cursor -= 1 + + input_text = seg_start + seg_end + return input_text, input_cursor diff --git a/deluge/ui/console/widgets/__init__.py b/deluge/ui/console/widgets/__init__.py new file mode 100644 index 0000000..bc88a3b --- /dev/null +++ b/deluge/ui/console/widgets/__init__.py @@ -0,0 +1,5 @@ +from deluge.ui.console.widgets.inputpane import BaseInputPane +from deluge.ui.console.widgets.statusbars import StatusBars +from deluge.ui.console.widgets.window import BaseWindow + +__all__ = ['BaseInputPane', 'StatusBars', 'BaseWindow'] diff --git a/deluge/ui/console/widgets/fields.py b/deluge/ui/console/widgets/fields.py new file mode 100644 index 0000000..d8d892d --- /dev/null +++ b/deluge/ui/console/widgets/fields.py @@ -0,0 +1,1202 @@ +# +# Copyright (C) 2011 Nick Lanham +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# 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 deluge.decorators import overrides +from deluge.ui.console.modes.basemode import InputKeyHandler +from deluge.ui.console.utils import colors +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.utils.format_utils import ( + delete_alt_backspace, + remove_formatting, + wrap_string, +) + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) + + +class BaseField(InputKeyHandler): + def __init__(self, parent=None, name=None, selectable=True, **kwargs): + super().__init__() + self.name = name + self.parent = parent + self.fmt_keys = {} + self.set_fmt_key('font', 'ignore', kwargs) + self.set_fmt_key('color', 'white,black', kwargs) + self.set_fmt_key('color_end', 'white,black', kwargs) + self.set_fmt_key('color_active', 'black,white', kwargs) + self.set_fmt_key('color_unfocused', 'color', kwargs) + self.set_fmt_key('color_unfocused_active', 'black,whitegrey', kwargs) + self.set_fmt_key('font_active', 'font', kwargs) + self.set_fmt_key('font_unfocused', 'font', kwargs) + self.set_fmt_key('font_unfocused_active', 'font_active', kwargs) + self.default_col = kwargs.get('col', -1) + self._selectable = selectable + self.value = None + + def selectable(self): + return self.has_input() and not self.depend_skip() and self._selectable + + def set_fmt_key(self, key, default, kwargsdict=None): + value = self.fmt_keys.get(default, default) + if kwargsdict: + value = kwargsdict.get(key, value) + self.fmt_keys[key] = value + + def get_fmt_keys(self, focused, active, **kwargs): + color_key = kwargs.get('color_key', 'color') + font_key = 'font' + if not focused: + color_key += '_unfocused' + font_key += '_unfocused' + if active: + color_key += '_active' + font_key += '_active' + return color_key, font_key + + def build_fmt_string(self, focused, active, value_key='msg', **kwargs): + color_key, font_key = self.get_fmt_keys(focused, active, **kwargs) + return '{{!%({})s,%({})s!}}%({})s{{!%({})s!}}'.format( + color_key, + font_key, + value_key, + 'color_end', + ) + + def depend_skip(self): + return False + + def has_input(self): + return True + + @overrides(InputKeyHandler) + def handle_read(self, c): + return util.ReadState.IGNORED + + def render(self, screen, row, **kwargs): + return 0 + + @property + def height(self): + return 1 + + def set_value(self, value): + self.value = value + + def get_value(self): + return self.value + + +class NoInputField(BaseField): + @overrides(BaseField) + def has_input(self): + return False + + +class InputField(BaseField): + def __init__(self, parent, name, message, format_default=None, **kwargs): + BaseField.__init__(self, parent=parent, name=name, **kwargs) + self.format_default = format_default + self.message = None + self.set_message(message) + + depend = None + + @overrides(BaseField) + def handle_read(self, c): + if c in [curses.KEY_ENTER, util.KEY_ENTER2, util.KEY_BACKSPACE2, 113]: + return util.ReadState.READ + return util.ReadState.IGNORED + + def set_message(self, msg): + changed = self.message != msg + self.message = msg + return changed + + def set_depend(self, i, inverse=False): + if not isinstance(i, CheckedInput): + raise Exception('Can only depend on CheckedInputs') + self.depend = i + self.inverse = inverse + + def depend_skip(self): + if not self.depend: + return False + if self.inverse: + return self.depend.checked + else: + return not self.depend.checked + + +class Header(NoInputField): + def __init__(self, parent, header, space_above, space_below, **kwargs): + if 'name' not in kwargs: + kwargs['name'] = header + NoInputField.__init__(self, parent=parent, **kwargs) + self.header = '{!white,black,bold!}%s' % header + self.space_above = space_above + self.space_below = space_below + + @overrides(BaseField) + def render(self, screen, row, col=0, **kwargs): + rows = 1 + if self.space_above: + row += 1 + rows += 1 + self.parent.add_string(row, self.header, scr=screen, col=col, pad=False) + if self.space_below: + rows += 1 + return rows + + @property + def height(self): + return 1 + int(self.space_above) + int(self.space_below) + + +class InfoField(NoInputField): + def __init__(self, parent, name, label, value, **kwargs): + NoInputField.__init__(self, parent=parent, name=name, **kwargs) + self.label = label + self.value = value + self.txt = f'{label} {value}' + + @overrides(BaseField) + def render(self, screen, row, col=0, **kwargs): + self.parent.add_string(row, self.txt, scr=screen, col=col, pad=False) + return 1 + + @overrides(BaseField) + def set_value(self, v): + self.value = v + if isinstance(v, float): + self.txt = f'{self.label} {self.value:.2f}' + else: + self.txt = f'{self.label} {self.value}' + + +class CheckedInput(InputField): + def __init__( + self, + parent, + name, + message, + checked=False, + checked_char='X', + unchecked_char=' ', + checkbox_format='[%s] ', + **kwargs, + ): + InputField.__init__(self, parent, name, message, **kwargs) + self.set_value(checked) + self.fmt_keys.update( + { + 'msg': message, + 'checkbox_format': checkbox_format, + 'unchecked_char': unchecked_char, + 'checked_char': checked_char, + } + ) + self.set_fmt_key('font_checked', 'font', kwargs) + self.set_fmt_key('font_unfocused_checked', 'font_checked', kwargs) + self.set_fmt_key('font_active_checked', 'font_active', kwargs) + self.set_fmt_key('font_unfocused_active_checked', 'font_active_checked', kwargs) + self.set_fmt_key('color_checked', 'color', kwargs) + self.set_fmt_key('color_active_checked', 'color_active', kwargs) + self.set_fmt_key('color_unfocused_checked', 'color_checked', kwargs) + self.set_fmt_key( + 'color_unfocused_active_checked', 'color_unfocused_active', kwargs + ) + + @property + def checked(self): + return self.value + + @overrides(BaseField) + def get_fmt_keys(self, focused, active, **kwargs): + color_key, font_key = super().get_fmt_keys(focused, active, **kwargs) + if self.checked: + color_key += '_checked' + font_key += '_checked' + return color_key, font_key + + def build_msg_string(self, focused, active): + fmt_str = self.build_fmt_string(focused, active) + char = self.fmt_keys['checked_char' if self.checked else 'unchecked_char'] + chk_box = '' + try: + chk_box = self.fmt_keys['checkbox_format'] % char + except KeyError: + pass + msg = fmt_str % self.fmt_keys + return chk_box + msg + + @overrides(InputField) + def render(self, screen, row, col=0, **kwargs): + string = self.build_msg_string(kwargs.get('focused'), kwargs.get('active')) + + self.parent.add_string(row, string, scr=screen, col=col, pad=False) + return 1 + + @overrides(InputField) + def handle_read(self, c): + if c == util.KEY_SPACE: + self.set_value(not self.checked) + return util.ReadState.CHANGED + return util.ReadState.IGNORED + + @overrides(InputField) + def set_message(self, msg): + changed = InputField.set_message(self, msg) + if 'msg' in self.fmt_keys and self.fmt_keys['msg'] != msg: + changed = True + self.fmt_keys.update({'msg': msg}) + + return changed + + +class CheckedPlusInput(CheckedInput): + def __init__( + self, + parent, + name, + message, + child, + child_always_visible=False, + show_usage_hints=True, + msg_fmt='%s ', + **kwargs, + ): + CheckedInput.__init__(self, parent, name, message, **kwargs) + self.child = child + self.child_active = False + self.show_usage_hints = show_usage_hints + self.msg_fmt = msg_fmt + self.child_always_visible = child_always_visible + + @property + def height(self): + return max(2 if self.show_usage_hints else 1, self.child.height) + + @overrides(CheckedInput) + def render( + self, screen, row, width=None, active=False, focused=False, col=0, **kwargs + ): + isact = active and not self.child_active + CheckedInput.render( + self, screen, row, width=width, active=isact, focused=focused, col=col + ) + rows = 1 + if self.show_usage_hints and ( + self.child_always_visible or (active and self.checked) + ): + msg = '(esc to leave)' if self.child_active else '(right arrow to edit)' + self.parent.add_string(row + 1, msg, scr=screen, col=col, pad=False) + rows += 1 + + msglen = len( + self.msg_fmt % colors.strip_colors(self.build_msg_string(focused, active)) + ) + # show child + if self.checked or self.child_always_visible: + crows = self.child.render( + screen, + row, + width=width - msglen, + active=self.child_active and active, + col=col + msglen, + cursor_offset=msglen, + ) + rows = max(rows, crows) + else: + self.parent.add_string( + row, + '(enable to view/edit value)', + scr=screen, + col=col + msglen, + pad=False, + ) + return rows + + @overrides(CheckedInput) + def handle_read(self, c): + if self.child_active: + if c == util.KEY_ESC: # leave child on esc + self.child_active = False + return util.ReadState.READ + # pass keys through to child + return self.child.handle_read(c) + else: + if c == util.KEY_SPACE: + self.set_value(not self.checked) + return util.ReadState.CHANGED + if (self.checked or self.child_always_visible) and c == curses.KEY_RIGHT: + self.child_active = True + return util.ReadState.READ + return util.ReadState.IGNORED + + def get_child(self): + return self.child + + +class IntSpinInput(InputField): + def __init__( + self, + parent, + name, + message, + move_func, + value, + min_val=None, + max_val=None, + inc_amt=1, + incr_large=10, + strict_validation=False, + fmt='%d', + **kwargs, + ): + InputField.__init__(self, parent, name, message, **kwargs) + self.convert_func = int + self.fmt = fmt + self.valstr = str(value) + self.default_str = self.valstr + self.set_value(value) + self.default_value = self.value + self.last_valid_value = self.value + self.last_active = False + self.cursor = len(self.valstr) + self.cursoff = ( + colors.get_line_width(self.message) + 3 + ) # + 4 for the " [ " in the rendered string + self.move_func = move_func + self.strict_validation = strict_validation + self.min_val = min_val + self.max_val = max_val + self.inc_amt = inc_amt + self.incr_large = incr_large + + def validate_value(self, value, on_invalid=None): + if (self.min_val is not None) and value < self.min_val: + value = on_invalid if on_invalid else self.min_val + if (self.max_val is not None) and value > self.max_val: + value = on_invalid if on_invalid else self.max_val + return value + + @overrides(InputField) + def render( + self, screen, row, active=False, focused=True, col=0, cursor_offset=0, **kwargs + ): + if active: + self.last_active = True + elif self.last_active: + self.set_value( + self.valstr, validate=True, value_on_fail=self.last_valid_value + ) + self.last_active = False + + fmt_str = self.build_fmt_string(focused, active, value_key='value') + value_format = '%(msg)s {!input!}' + if not self.valstr: + value_format += '[ ]' + elif self.format_default and self.valstr == self.default_str: + value_format += '[ {!magenta,black!}%(value)s{!input!} ]' + else: + value_format += '[ ' + fmt_str + ' ]' + + self.parent.add_string( + row, + value_format + % dict({'msg': self.message, 'value': '%s' % self.valstr}, **self.fmt_keys), + scr=screen, + col=col, + pad=False, + ) + if active: + if focused: + util.safe_curs_set(util.Curser.NORMAL) + self.move_func(row, self.cursor + self.cursoff + cursor_offset) + else: + util.safe_curs_set(util.Curser.INVISIBLE) + return 1 + + @overrides(InputField) + def handle_read(self, c): + if c == util.KEY_SPACE: + return util.ReadState.READ + elif c == curses.KEY_PPAGE: + self.set_value(self.value + self.inc_amt, validate=True) + elif c == curses.KEY_NPAGE: + self.set_value(self.value - self.inc_amt, validate=True) + elif c == util.KEY_ALT_AND_KEY_PPAGE: + self.set_value(self.value + self.incr_large, validate=True) + elif c == util.KEY_ALT_AND_KEY_NPAGE: + self.set_value(self.value - self.incr_large, validate=True) + elif c == curses.KEY_LEFT: + self.cursor = max(0, self.cursor - 1) + elif c == curses.KEY_RIGHT: + self.cursor = min(len(self.valstr), self.cursor + 1) + elif c == curses.KEY_HOME: + self.cursor = 0 + elif c == curses.KEY_END: + self.cursor = len(self.valstr) + elif c == curses.KEY_BACKSPACE or c == util.KEY_BACKSPACE2: + if self.valstr and self.cursor > 0: + new_val = self.valstr[: self.cursor - 1] + self.valstr[self.cursor :] + self.set_value( + new_val, + validate=False, + cursor=self.cursor - 1, + cursor_on_fail=True, + value_on_fail=self.valstr if self.strict_validation else None, + ) + elif c == curses.KEY_DC: # Del + if self.valstr and self.cursor <= len(self.valstr): + if self.cursor == 0: + new_val = self.valstr[1:] + else: + new_val = ( + self.valstr[: self.cursor] + self.valstr[self.cursor + 1 :] + ) + self.set_value( + new_val, + validate=False, + cursor=False, + value_on_fail=self.valstr if self.strict_validation else None, + cursor_on_fail=True, + ) + elif c == ord('-'): # minus + self.set_value( + self.value - 1, + validate=True, + cursor=True, + cursor_on_fail=True, + value_on_fail=self.value, + on_invalid=self.value, + ) + elif c == ord('+'): # plus + self.set_value( + self.value + 1, + validate=True, + cursor=True, + cursor_on_fail=True, + value_on_fail=self.value, + on_invalid=self.value, + ) + elif util.is_int_chr(c): + if self.strict_validation: + new_val = ( + self.valstr[: self.cursor - 1] + + chr(c) + + self.valstr[self.cursor - 1 :] + ) + self.set_value( + new_val, + validate=True, + cursor=self.cursor + 1, + value_on_fail=self.valstr, + on_invalid=self.value, + ) + else: + minus_place = self.valstr.find('-') + if self.cursor > minus_place: + new_val = ( + self.valstr[: self.cursor] + chr(c) + self.valstr[self.cursor :] + ) + self.set_value( + new_val, + validate=True, + cursor=self.cursor + 1, + on_invalid=self.value, + ) + else: + return util.ReadState.IGNORED + return util.ReadState.READ + + @overrides(BaseField) + def set_value( + self, + val, + cursor=True, + validate=False, + cursor_on_fail=False, + value_on_fail=None, + on_invalid=None, + ): + value = None + try: + value = self.convert_func(val) + if validate: + validated = self.validate_value(value, on_invalid) + if validated != value: + # Value was not valid, so use validated value instead. + # Also set cursor according to validated value + cursor = True + value = validated + + new_valstr = self.fmt % value + if new_valstr == self.valstr: + # If string has not change, keep cursor + cursor = False + self.valstr = new_valstr + self.last_valid_value = self.value = value + except ValueError: + if value_on_fail is not None: + self.set_value( + value_on_fail, + cursor=cursor, + cursor_on_fail=cursor_on_fail, + validate=validate, + on_invalid=on_invalid, + ) + return + self.value = None + self.valstr = val + if cursor_on_fail: + self.cursor = cursor + except TypeError: + import traceback + + log.warning('TypeError: %s', ''.join(traceback.format_exc())) + else: + if cursor is True: + self.cursor = len(self.valstr) + elif cursor is not False: + self.cursor = cursor + + +class FloatSpinInput(IntSpinInput): + def __init__(self, parent, message, name, move_func, value, precision=1, **kwargs): + self.precision = precision + IntSpinInput.__init__(self, parent, message, name, move_func, value, **kwargs) + self.fmt = '%%.%df' % precision + self.convert_func = lambda valstr: round(float(valstr), self.precision) + self.set_value(value) + self.cursor = len(self.valstr) + + @overrides(IntSpinInput) + def handle_read(self, c): + if c == ord('.'): + minus_place = self.valstr.find('-') + if self.cursor <= minus_place: + return util.ReadState.READ + point_place = self.valstr.find('.') + if point_place >= 0: + return util.ReadState.READ + new_val = self.valstr[: self.cursor] + chr(c) + self.valstr[self.cursor :] + self.set_value(new_val, validate=True, cursor=self.cursor + 1) + else: + return IntSpinInput.handle_read(self, c) + + +class SelectInput(InputField): + def __init__( + self, + parent, + name, + message, + opts, + vals, + active_index, + active_default=False, + require_select_action=True, + **kwargs, + ): + InputField.__init__(self, parent, name, message, **kwargs) + self.opts = opts + self.vals = vals + self.active_index = active_index + self.selected_index = active_index + self.default_option = active_index if active_default else None + self.require_select_action = require_select_action + self.fmt_keys.update({'font_active': 'bold'}) + font_selected = kwargs.get('font_selected', 'bold,underline') + + self.set_fmt_key('font_selected', font_selected, kwargs) + self.set_fmt_key('font_active_selected', 'font_selected', kwargs) + self.set_fmt_key('font_unfocused_selected', 'font_selected', kwargs) + self.set_fmt_key( + 'font_unfocused_active_selected', 'font_active_selected', kwargs + ) + + self.set_fmt_key('color_selected', 'color', kwargs) + self.set_fmt_key('color_active_selected', 'color_active', kwargs) + self.set_fmt_key('color_unfocused_selected', 'color_selected', kwargs) + self.set_fmt_key( + 'color_unfocused_active_selected', 'color_unfocused_active', kwargs + ) + self.set_fmt_key('color_default_value', 'magenta,black', kwargs) + + self.set_fmt_key('color_default_value', 'magenta,black') + self.set_fmt_key('color_default_value_active', 'magentadark,white') + self.set_fmt_key('color_default_value_selected', 'color_default_value', kwargs) + self.set_fmt_key('color_default_value_unfocused', 'color_default_value', kwargs) + self.set_fmt_key( + 'color_default_value_unfocused_selected', + 'color_default_value_selected', + kwargs, + ) + self.set_fmt_key('color_default_value_active_selected', 'magentadark,white') + self.set_fmt_key( + 'color_default_value_unfocused_active_selected', + 'color_unfocused_active', + kwargs, + ) + + @property + def height(self): + return 1 + bool(self.message) + + @overrides(BaseField) + def get_fmt_keys(self, focused, active, selected=False, **kwargs): + color_key, font_key = super().get_fmt_keys(focused, active, **kwargs) + if selected: + color_key += '_selected' + font_key += '_selected' + return color_key, font_key + + @overrides(InputField) + def render(self, screen, row, active=False, focused=True, col=0, **kwargs): + if self.message: + self.parent.add_string(row, self.message, scr=screen, col=col, pad=False) + row += 1 + + off = col + 1 + for i, opt in enumerate(self.opts): + self.fmt_keys['msg'] = opt + fmt_args = {'selected': i == self.selected_index} + if i == self.default_option: + fmt_args['color_key'] = 'color_default_value' + fmt = self.build_fmt_string( + focused, (i == self.active_index) and active, **fmt_args + ) + string = '[%s]' % (fmt % self.fmt_keys) + self.parent.add_string(row, string, scr=screen, col=off, pad=False) + off += len(opt) + 3 + if self.message: + return 2 + else: + return 1 + + @overrides(InputField) + def handle_read(self, c): + if c == curses.KEY_LEFT: + self.active_index = max(0, self.active_index - 1) + if not self.require_select_action: + self.selected_index = self.active_index + elif c == curses.KEY_RIGHT: + self.active_index = min(len(self.opts) - 1, self.active_index + 1) + if not self.require_select_action: + self.selected_index = self.active_index + elif c == ord(' '): + if self.require_select_action: + self.selected_index = self.active_index + else: + return util.ReadState.IGNORED + return util.ReadState.READ + + @overrides(BaseField) + def get_value(self): + return self.vals[self.selected_index] + + @overrides(BaseField) + def set_value(self, value): + for i, val in enumerate(self.vals): + if value == val: + self.selected_index = i + return + raise Exception('Invalid value for SelectInput') + + +class TextInput(InputField): + def __init__( + self, + parent, + name, + message, + move_func, + width, + value, + complete=False, + activate_input=False, + **kwargs, + ): + InputField.__init__(self, parent, name, message, **kwargs) + self.move_func = move_func + self._width = width + self.value = value if value else '' + self.default_value = value + self.complete = complete + self.tab_count = 0 + self.cursor = len(self.value) + self.opts = None + self.opt_off = 0 + self.value_offset = 0 + self.activate_input = activate_input # Wether input must be activated + self.input_active = not self.activate_input + + @property + def width(self): + return self._width + + @property + def height(self): + return 1 + bool(self.message) + + def calculate_textfield_value(self, width, cursor_offset): + cursor_width = width + + if self.cursor > (cursor_width - 1): + c_pos_abs = self.cursor - cursor_width + if cursor_width <= (self.cursor - self.value_offset): + new_cur = c_pos_abs + 1 + self.value_offset = new_cur + else: + if self.cursor >= len(self.value): + c_pos_abs = len(self.value) - cursor_width + new_cur = c_pos_abs + 1 + self.value_offset = new_cur + vstr = self.value[self.value_offset :] + + if len(vstr) > cursor_width: + vstr = vstr[:cursor_width] + vstr = vstr.ljust(cursor_width) + else: + if len(self.value) <= cursor_width: + self.value_offset = 0 + vstr = self.value.ljust(cursor_width) + else: + self.value_offset = min(self.value_offset, self.cursor) + vstr = self.value[self.value_offset :] + if len(vstr) > cursor_width: + vstr = vstr[:cursor_width] + vstr = vstr.ljust(cursor_width) + + return vstr + + def calculate_cursor_pos(self, width, col): + cursor_width = width + x_pos = self.cursor + col + + if (self.cursor + col - self.value_offset) > cursor_width: + x_pos += self.value_offset + else: + x_pos -= self.value_offset + + return min(width - 1 + col, x_pos) + + @overrides(InputField) + def render( + self, + screen, + row, + width=None, + active=False, + focused=True, + col=0, + cursor_offset=0, + **kwargs, + ): + if not self.value and not active and len(self.default_value) != 0: + self.value = self.default_value + self.cursor = len(self.value) + + if self.message: + self.parent.add_string(row, self.message, scr=screen, col=col, pad=False) + row += 1 + + vstr = self.calculate_textfield_value(width, cursor_offset) + + if active: + if self.opts: + self.parent.add_string( + row + 1, self.opts[self.opt_off :], scr=screen, col=col, pad=False + ) + + if focused and self.input_active: + util.safe_curs_set( + util.Curser.NORMAL + ) # Make cursor visible when text field is focused + x_pos = self.calculate_cursor_pos(width, col) + self.move_func(row, x_pos) + + fmt = '{!black,white,bold!}%s' + if ( + self.format_default + and len(self.value) != 0 + and self.value == self.default_value + ): + fmt = '{!magenta,white!}%s' + if not active or not focused or self.input_active: + fmt = '{!white,grey,bold!}%s' + + self.parent.add_string( + row, fmt % vstr, scr=screen, col=col, pad=False, trim=False + ) + return self.height + + @overrides(BaseField) + def set_value(self, val): + self.value = val + self.cursor = len(self.value) + + @overrides(InputField) + def handle_read(self, c): + """ + Return False when key was swallowed, i.e. we recognised + the key and no further action by other components should + be performed. + """ + if self.activate_input: + if not self.input_active: + if c in [ + curses.KEY_LEFT, + curses.KEY_RIGHT, + curses.KEY_HOME, + curses.KEY_END, + curses.KEY_ENTER, + util.KEY_ENTER2, + ]: + self.input_active = True + return util.ReadState.READ + else: + return util.ReadState.IGNORED + elif c == util.KEY_ESC: + self.input_active = False + return util.ReadState.READ + + if c == util.KEY_TAB and self.complete: + # Keep track of tab hit count to know when it's double-hit + self.tab_count += 1 + if self.tab_count > 1: + second_hit = True + self.tab_count = 0 + else: + second_hit = False + + # We only call the tab completer function if we're at the end of + # the input string on the cursor is on a space + if self.cursor == len(self.value) or self.value[self.cursor] == ' ': + if self.opts: + prev = self.opt_off + self.opt_off += self.width - 3 + # now find previous double space, best guess at a split point + # in future could keep opts unjoined to get this really right + self.opt_off = self.opts.rfind(' ', 0, self.opt_off) + 2 + if ( + second_hit and self.opt_off == prev + ): # double tap and we're at the end + self.opt_off = 0 + else: + opts = self.do_complete(self.value) + if len(opts) == 1: # only one option, just complete it + self.value = opts[0] + self.cursor = len(opts[0]) + self.tab_count = 0 + elif len(opts) > 1: + prefix = os.path.commonprefix(opts) + if prefix: + self.value = prefix + self.cursor = len(prefix) + + if ( + len(opts) > 1 and second_hit + ): # display multiple options on second tab hit + sp = self.value.rfind(os.sep) + 1 + self.opts = ' '.join([o[sp:] for o in opts]) + + # Cursor movement + elif c == curses.KEY_LEFT: + self.cursor = max(0, self.cursor - 1) + elif c == curses.KEY_RIGHT: + self.cursor = min(len(self.value), self.cursor + 1) + elif c == curses.KEY_HOME: + self.cursor = 0 + elif c == curses.KEY_END: + self.cursor = len(self.value) + + # Delete a character in the input string based on cursor position + elif c == curses.KEY_BACKSPACE or c == util.KEY_BACKSPACE2: + if self.value and self.cursor > 0: + self.value = self.value[: self.cursor - 1] + self.value[self.cursor :] + self.cursor -= 1 + elif c == [util.KEY_ESC, util.KEY_BACKSPACE2] or c == [ + util.KEY_ESC, + curses.KEY_BACKSPACE, + ]: + self.value, self.cursor = delete_alt_backspace(self.value, self.cursor) + elif c == curses.KEY_DC: + if self.value and self.cursor < len(self.value): + self.value = self.value[: self.cursor] + self.value[self.cursor + 1 :] + elif c > 31 and c < 256: + # Emulate getwch + stroke = chr(c) + uchar = stroke + while not uchar: + try: + uchar = stroke.decode(self.parent.encoding) + except UnicodeDecodeError: + c = self.parent.parent.stdscr.getch() + stroke += chr(c) + if uchar: + if self.cursor == len(self.value): + self.value += uchar + else: + # Insert into string + self.value = ( + self.value[: self.cursor] + uchar + self.value[self.cursor :] + ) + # Move the cursor forward + self.cursor += 1 + + else: + self.opts = None + self.opt_off = 0 + self.tab_count = 0 + return util.ReadState.IGNORED + return util.ReadState.READ + + def do_complete(self, line): + line = os.path.abspath(os.path.expanduser(line)) + ret = [] + if os.path.exists(line): + # This is a correct path, check to see if it's a directory + if os.path.isdir(line): + # Directory, so we need to show contents of directory + for f in os.listdir(line): + # Skip hidden + if f.startswith('.'): + continue + f = os.path.join(line, f) + if os.path.isdir(f): + f += os.sep + ret.append(f) + else: + # This is a file, but we could be looking for another file that + # shares a common prefix. + for f in os.listdir(os.path.dirname(line)): + if f.startswith(os.path.split(line)[1]): + ret.append(os.path.join(os.path.dirname(line), f)) + else: + # This path does not exist, so lets do a listdir on it's parent + # and find any matches. + ret = [] + if os.path.isdir(os.path.dirname(line)): + for f in os.listdir(os.path.dirname(line)): + if f.startswith(os.path.split(line)[1]): + p = os.path.join(os.path.dirname(line), f) + + if os.path.isdir(p): + p += os.sep + ret.append(p) + return ret + + +class ComboInput(InputField): + def __init__( + self, parent, name, message, choices, default=None, searchable=True, **kwargs + ): + InputField.__init__(self, parent, name, message, **kwargs) + self.choices = choices + self.default = default + self.set_value(default) + max_width = 0 + for c in choices: + max_width = max(max_width, len(c[1])) + self.choices_width = max_width + self.searchable = searchable + + @overrides(BaseField) + def render(self, screen, row, col=0, **kwargs): + fmt_str = self.build_fmt_string(kwargs.get('focused'), kwargs.get('active')) + string = '%s: [%10s]' % (self.message, fmt_str % self.fmt_keys) + self.parent.add_string(row, string, scr=screen, col=col, pad=False) + return 1 + + def _lang_selected(self, selected, *args, **kwargs): + if selected is not None: + self.set_value(selected) + self.parent.pop_popup() + + @overrides(InputField) + def handle_read(self, c): + if c in [util.KEY_SPACE, curses.KEY_ENTER, util.KEY_ENTER2]: + + def search_handler(key): + """Handle keyboard input to seach the list""" + if not util.is_printable_chr(key): + return + selected = select_popup.current_selection() + + def select_in_range(begin, end): + for i in range(begin, end): + val = select_popup.inputs[i].get_value() + if val.lower().startswith(chr(key)): + select_popup.set_selection(i) + return True + return False + + # First search downwards + if not select_in_range(selected + 1, len(select_popup.inputs)): + # No match, so start at beginning + select_in_range(0, selected) + + from deluge.ui.console.widgets.popup import ( # Must import here + SelectablePopup, + ) + + select_popup = SelectablePopup( + self.parent, + ' %s ' % _('Select Language'), + self._lang_selected, + input_cb=search_handler if self.searchable else None, + border_off_west=1, + active_wrap=False, + width_req=self.choices_width + 12, + ) + for choice in self.choices: + args = {'data': choice[0]} + select_popup.add_line( + choice[0], + choice[1], + selectable=True, + selected=choice[0] == self.get_value(), + **args, + ) + self.parent.push_popup(select_popup) + return util.ReadState.CHANGED + return util.ReadState.IGNORED + + @overrides(BaseField) + def set_value(self, val): + self.value = val + msg = None + for c in self.choices: + if c[0] == val: + msg = c[1] + break + if msg is None: + log.warning( + 'Setting value "%s" found nothing in choices: %s', val, self.choices + ) + self.fmt_keys.update({'msg': msg}) + + +class TextField(BaseField): + def __init__(self, parent, name, value, selectable=True, value_fmt='%s', **kwargs): + BaseField.__init__( + self, parent=parent, name=name, selectable=selectable, **kwargs + ) + self.value = value + self.value_fmt = value_fmt + self.set_value(value) + + @overrides(BaseField) + def set_value(self, value): + self.value = value + self.txt = self.value_fmt % (value) + + @overrides(BaseField) + def has_input(self): + return True + + @overrides(BaseField) + def render(self, screen, row, active=False, focused=False, col=0, **kwargs): + util.safe_curs_set( + util.Curser.INVISIBLE + ) # Make cursor invisible when text field is active + fmt = self.build_fmt_string(focused, active) + self.fmt_keys['msg'] = self.txt + string = fmt % self.fmt_keys + self.parent.add_string(row, string, scr=screen, col=col, pad=False, trim=False) + return 1 + + +class TextArea(TextField): + def __init__(self, parent, name, value, value_fmt='%s', **kwargs): + TextField.__init__( + self, parent, name, value, selectable=False, value_fmt=value_fmt, **kwargs + ) + + @overrides(TextField) + def render(self, screen, row, col=0, **kwargs): + util.safe_curs_set( + util.Curser.INVISIBLE + ) # Make cursor invisible when text field is active + color = '{!white,black!}' + lines = wrap_string(self.txt, self.parent.width - 3, 3, True) + + for i, line in enumerate(lines): + self.parent.add_string( + row + i, + f'{color}{line}', + scr=screen, + col=col, + pad=False, + trim=False, + ) + return len(lines) + + @property + def height(self): + lines = wrap_string(self.txt, self.parent.width - 3, 3, True) + return len(lines) + + @overrides(TextField) + def has_input(self): + return False + + +class DividerField(NoInputField): + def __init__( + self, + parent, + name, + value, + selectable=False, + fill_width=True, + value_fmt='%s', + **kwargs, + ): + NoInputField.__init__( + self, parent=parent, name=name, selectable=selectable, **kwargs + ) + self.value = value + self.value_fmt = value_fmt + self.set_value(value) + self.fill_width = fill_width + + @overrides(BaseField) + def set_value(self, value): + self.value = value + self.txt = self.value_fmt % (value) + + @overrides(BaseField) + def render( + self, screen, row, active=False, focused=False, col=0, width=None, **kwargs + ): + util.safe_curs_set( + util.Curser.INVISIBLE + ) # Make cursor invisible when text field is active + fmt = self.build_fmt_string(focused, active) + self.fmt_keys['msg'] = self.txt + if self.fill_width: + self.fmt_keys['msg'] = '' + string_len = len(remove_formatting(fmt % self.fmt_keys)) + fill_len = width - string_len - (len(self.txt) - 1) + self.fmt_keys['msg'] = self.txt * fill_len + string = fmt % self.fmt_keys + self.parent.add_string(row, string, scr=screen, col=col, pad=False, trim=False) + return 1 diff --git a/deluge/ui/console/widgets/inputpane.py b/deluge/ui/console/widgets/inputpane.py new file mode 100644 index 0000000..d8d2175 --- /dev/null +++ b/deluge/ui/console/widgets/inputpane.py @@ -0,0 +1,394 @@ +# +# Copyright (C) 2011 Nick Lanham +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# 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.utils import curses_util as util +from deluge.ui.console.widgets.fields import ( + CheckedInput, + CheckedPlusInput, + ComboInput, + DividerField, + FloatSpinInput, + Header, + InfoField, + IntSpinInput, + NoInputField, + SelectInput, + TextArea, + TextField, + TextInput, +) + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) + + +class BaseInputPane(InputKeyHandler): + def __init__( + self, + mode, + allow_rearrange=False, + immediate_action=False, + set_first_input_active=True, + border_off_west=0, + border_off_north=0, + border_off_east=0, + border_off_south=0, + active_wrap=False, + **kwargs + ): + InputKeyHandler.__init__(self) + self.inputs = [] + self.mode = mode + self.active_input = 0 + self.set_first_input_active = set_first_input_active + self.allow_rearrange = allow_rearrange + self.immediate_action = immediate_action + self.move_active_many = 4 + self.active_wrap = active_wrap + self.lineoff = 0 + self.border_off_west = border_off_west + self.border_off_north = border_off_north + self.border_off_east = border_off_east + self.border_off_south = border_off_south + self.last_lineoff_move = 0 + + if not hasattr(self, 'visible_content_pane_height'): + log.error( + 'The class "%s" does not have the attribute "%s" required by super class "%s"', + self.__class__.__name__, + 'visible_content_pane_height', + BaseInputPane.__name__, + ) + raise AttributeError('visible_content_pane_height') + + @property + def visible_content_pane_width(self): + return self.mode.width + + def add_spaces(self, num): + string = '' + for i in range(num): + string += '\n' + + self.add_text_area('space %d' % len(self.inputs), string) + + def add_text(self, string): + self.add_text_area('', string) + + def move(self, r, c): + self._cursor_row = r + self._cursor_col = c + + def get_input(self, name): + for e in self.inputs: + if e.name == name: + return e + + def _add_input(self, input_element): + for e in self.inputs: + if isinstance(e, NoInputField): + continue + if e.name == input_element.name: + import traceback + + log.warning( + 'Input element with name "%s" already exists in input pane (%s):\n%s', + input_element.name, + e, + ''.join(traceback.format_stack(limit=5)), + ) + return + + self.inputs.append(input_element) + if self.set_first_input_active and input_element.selectable(): + self.active_input = len(self.inputs) - 1 + self.set_first_input_active = False + return input_element + + def add_header(self, header, space_above=False, space_below=False, **kwargs): + return self._add_input(Header(self, header, space_above, space_below, **kwargs)) + + def add_info_field(self, name, label, value): + return self._add_input(InfoField(self, name, label, value)) + + def add_text_field(self, name, message, selectable=True, col='+1', **kwargs): + return self._add_input( + TextField(self, name, message, selectable=selectable, col=col, **kwargs) + ) + + def add_text_area(self, name, message, **kwargs): + return self._add_input(TextArea(self, name, message, **kwargs)) + + def add_divider_field(self, name, message, **kwargs): + return self._add_input(DividerField(self, name, message, **kwargs)) + + def add_text_input(self, name, message, value='', col='+1', **kwargs): + """ + Add a text input field + + :param message: string to display above the input field + :param name: name of the field, for the return callback + :param value: initial value of the field + :param complete: should completion be run when tab is hit and this field is active + """ + return self._add_input( + TextInput( + self, + name, + message, + self.move, + self.visible_content_pane_width, + value, + col=col, + **kwargs + ) + ) + + def add_select_input(self, name, message, opts, vals, default_index=0, **kwargs): + return self._add_input( + SelectInput(self, name, message, opts, vals, default_index, **kwargs) + ) + + def add_checked_input(self, name, message, checked=False, col='+1', **kwargs): + return self._add_input( + CheckedInput(self, name, message, checked=checked, col=col, **kwargs) + ) + + def add_checkedplus_input( + self, name, message, child, checked=False, col='+1', **kwargs + ): + return self._add_input( + CheckedPlusInput( + self, name, message, child, checked=checked, col=col, **kwargs + ) + ) + + def add_float_spin_input(self, name, message, value=0.0, col='+1', **kwargs): + return self._add_input( + FloatSpinInput(self, name, message, self.move, value, col=col, **kwargs) + ) + + def add_int_spin_input(self, name, message, value=0, col='+1', **kwargs): + return self._add_input( + IntSpinInput(self, name, message, self.move, value, col=col, **kwargs) + ) + + def add_combo_input(self, name, message, choices, col='+1', **kwargs): + return self._add_input( + ComboInput(self, name, message, choices, col=col, **kwargs) + ) + + @overrides(InputKeyHandler) + def handle_read(self, c): + if not self.inputs: # no inputs added yet + return util.ReadState.IGNORED + ret = self.inputs[self.active_input].handle_read(c) + if ret != util.ReadState.IGNORED: + if self.immediate_action: + self.immediate_action_cb( + state_changed=False if ret == util.ReadState.READ else True + ) + return ret + + ret = util.ReadState.READ + + if c == curses.KEY_UP: + self.move_active_up(1) + elif c == curses.KEY_DOWN: + self.move_active_down(1) + elif c == curses.KEY_HOME: + self.move_active_up(len(self.inputs)) + elif c == curses.KEY_END: + self.move_active_down(len(self.inputs)) + elif c == curses.KEY_PPAGE: + self.move_active_up(self.move_active_many) + elif c == curses.KEY_NPAGE: + self.move_active_down(self.move_active_many) + elif c == util.KEY_ALT_AND_ARROW_UP: + self.lineoff = max(self.lineoff - 1, 0) + elif c == util.KEY_ALT_AND_ARROW_DOWN: + tot_height = self.get_content_height() + self.lineoff = min( + self.lineoff + 1, tot_height - self.visible_content_pane_height + ) + elif c == util.KEY_CTRL_AND_ARROW_UP: + if not self.allow_rearrange: + return ret + val = self.inputs.pop(self.active_input) + self.active_input -= 1 + self.inputs.insert(self.active_input, val) + if self.immediate_action: + self.immediate_action_cb(state_changed=True) + elif c == util.KEY_CTRL_AND_ARROW_DOWN: + if not self.allow_rearrange: + return ret + val = self.inputs.pop(self.active_input) + self.active_input += 1 + self.inputs.insert(self.active_input, val) + if self.immediate_action: + self.immediate_action_cb(state_changed=True) + else: + ret = util.ReadState.IGNORED + return ret + + def get_values(self): + vals = {} + for i, ipt in enumerate(self.inputs): + if not ipt.has_input(): + continue + vals[ipt.name] = { + 'value': ipt.get_value(), + 'order': i, + 'active': self.active_input == i, + } + return vals + + def immediate_action_cb(self, state_changed=True): + pass + + def move_active(self, direction, amount): + """ + direction == -1: Up + direction == 1: Down + + """ + self.last_lineoff_move = direction * amount + + if direction > 0: + if self.active_wrap: + limit = self.active_input - 1 + if limit < 0: + limit = len(self.inputs) + limit + else: + limit = len(self.inputs) - 1 + else: + limit = 0 + if self.active_wrap: + limit = self.active_input + 1 + + def next_move(nc, direction, limit): + next_index = nc + while next_index != limit: + next_index += direction + if direction > 0: + next_index %= len(self.inputs) + elif next_index < 0: + next_index = len(self.inputs) + next_index + + if self.inputs[next_index].selectable(): + return next_index + if next_index == limit: + return nc + return nc + + next_sel = self.active_input + for a in range(amount): + cur_sel = next_sel + next_sel = next_move(next_sel, direction, limit) + if cur_sel == next_sel: + tot_height = ( + self.get_content_height() + + self.border_off_north + + self.border_off_south + ) + if direction > 0: + self.lineoff = min( + self.lineoff + 1, tot_height - self.visible_content_pane_height + ) + else: + self.lineoff = max(self.lineoff - 1, 0) + + if next_sel is not None: + self.active_input = next_sel + + def move_active_up(self, amount): + self.move_active(-1, amount) + if self.immediate_action: + self.immediate_action_cb(state_changed=False) + + def move_active_down(self, amount): + self.move_active(1, amount) + if self.immediate_action: + self.immediate_action_cb(state_changed=False) + + def get_content_height(self): + height = 0 + for i, ipt in enumerate(self.inputs): + if ipt.depend_skip(): + continue + height += ipt.height + return height + + def ensure_active_visible(self): + start_row = 0 + end_row = self.border_off_north + for i, ipt in enumerate(self.inputs): + if ipt.depend_skip(): + continue + start_row = end_row + end_row += ipt.height + if i != self.active_input or not ipt.has_input(): + continue + height = self.visible_content_pane_height + if end_row > height + self.lineoff: + self.lineoff += end_row - ( + height + self.lineoff + ) # Correct result depends on paranthesis + elif start_row < self.lineoff: + self.lineoff -= self.lineoff - start_row + break + + def render_inputs(self, focused=False): + self._cursor_row = -1 + self._cursor_col = -1 + util.safe_curs_set(util.Curser.INVISIBLE) + + self.ensure_active_visible() + + crow = self.border_off_north + for i, ipt in enumerate(self.inputs): + if ipt.depend_skip(): + continue + col = self.border_off_west + field_width = self.width - self.border_off_east - self.border_off_west + cursor_offset = self.border_off_west + + if ipt.default_col != -1: + default_col = int(ipt.default_col) + if isinstance(ipt.default_col, ''.__class__) and ipt.default_col[0] in [ + '+', + '-', + ]: + col += default_col + cursor_offset += default_col + field_width -= default_col # Increase to col must be reflected here + else: + col = default_col + crow += ipt.render( + self.screen, + crow, + width=field_width, + active=i == self.active_input, + focused=focused, + col=col, + cursor_offset=cursor_offset, + ) + + if self._cursor_row >= 0: + util.safe_curs_set(util.Curser.VERY_VISIBLE) + move_cursor(self.screen, self._cursor_row, self._cursor_col) diff --git a/deluge/ui/console/widgets/popup.py b/deluge/ui/console/widgets/popup.py new file mode 100644 index 0000000..07d667d --- /dev/null +++ b/deluge/ui/console/widgets/popup.py @@ -0,0 +1,398 @@ +# +# Copyright (C) 2011 Nick Lanham +# +# 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 +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.utils import format_utils +from deluge.ui.console.widgets import BaseInputPane, BaseWindow + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) + + +class ALIGN: + TOP_LEFT = 1 + TOP_CENTER = 2 + TOP_RIGHT = 3 + MIDDLE_LEFT = 4 + MIDDLE_CENTER = 5 + MIDDLE_RIGHT = 6 + BOTTOM_LEFT = 7 + BOTTOM_CENTER = 8 + BOTTOM_RIGHT = 9 + DEFAULT = MIDDLE_CENTER + + +class PopupsHandler: + def __init__(self): + self._popups = [] + + @property + def popup(self): + if self._popups: + return self._popups[-1] + return None + + def push_popup(self, pu, clear=False): + if clear: + self._popups = [] + self._popups.append(pu) + + def pop_popup(self): + if self.popup: + return self._popups.pop() + + def report_message(self, title, message): + self.push_popup(MessagePopup(self, title, message)) + + +class Popup(BaseWindow, InputKeyHandler): + def __init__( + self, + parent_mode, + title, + width_req=0, + height_req=0, + align=ALIGN.DEFAULT, + close_cb=None, + encoding=None, + base_popup=None, + **kwargs + ): + """ + Init a new popup. The default constructor will handle sizing and borders and the like. + + Args: + parent_mode (basemode subclass): The mode which the popup will be drawn over + title (str): the title of the popup window + width_req (int or float): An integer value will be used as the width of the popup in character. + A float value will indicate the requested ratio in relation to the + parents screen width. + height_req (int or float): An integer value will be used as the height of the popup in character. + A float value will indicate the requested ratio in relation to the + parents screen height. + align (ALIGN): The alignment controlling the position of the popup on the screen. + close_cb (func): Function to be called when the popup is closed + encoding (str): The terminal encoding + base_popup (Popup): A popup used to inherit width_req and height_req if not explicitly specified. + + Note: The parent mode is responsible for calling refresh on any popups it wants to show. + This should be called as the last thing in the parents refresh method. + + The parent *must* also call read_input on the popup instead of/in addition to + running its own read_input code if it wants to have the popup handle user input. + + Popups have two methods that must be implemented: + + refresh(self) - draw the popup window to screen. this default mode simply draws a bordered window + with the supplied title to the screen + + read_input(self) - handle user input to the popup. + + """ + InputKeyHandler.__init__(self) + self.parent = parent_mode + self.close_cb = close_cb + self.height_req = height_req + self.width_req = width_req + self.align = align + if base_popup: + if not self.width_req: + self.width_req = base_popup.width_req + if not self.height_req: + self.height_req = base_popup.height_req + + hr, wr, posy, posx = self.calculate_size() + BaseWindow.__init__(self, title, wr, hr, encoding=None) + self.move_window(posy, posx) + self._closed = False + + @overrides(BaseWindow) + def refresh(self): + self.screen.erase() + height = self.get_content_height() + self.ensure_content_pane_height( + height + self.border_off_north + self.border_off_south + ) + BaseInputPane.render_inputs(self, focused=True) + BaseWindow.refresh(self) + + def calculate_size(self): + if isinstance(self.height_req, float) and 0.0 < self.height_req <= 1.0: + height = int((self.parent.rows - 2) * self.height_req) + else: + height = self.height_req + + if isinstance(self.width_req, float) and 0.0 < self.width_req <= 1.0: + width = int((self.parent.cols - 2) * self.width_req) + else: + width = self.width_req + + # Height + if height == 0: + height = int(self.parent.rows / 2) + elif height == -1: + height = self.parent.rows - 2 + elif height > self.parent.rows - 2: + height = self.parent.rows - 2 + + # Width + if width == 0: + width = int(self.parent.cols / 2) + elif width == -1: + width = self.parent.cols + elif width >= self.parent.cols: + width = self.parent.cols + + if self.align in [ALIGN.TOP_CENTER, ALIGN.TOP_LEFT, ALIGN.TOP_RIGHT]: + begin_y = 1 + elif self.align in [ALIGN.MIDDLE_CENTER, ALIGN.MIDDLE_LEFT, ALIGN.MIDDLE_RIGHT]: + begin_y = (self.parent.rows / 2) - (height / 2) + elif self.align in [ALIGN.BOTTOM_CENTER, ALIGN.BOTTOM_LEFT, ALIGN.BOTTOM_RIGHT]: + begin_y = self.parent.rows - height - 1 + + if self.align in [ALIGN.TOP_LEFT, ALIGN.MIDDLE_LEFT, ALIGN.BOTTOM_LEFT]: + begin_x = 0 + elif self.align in [ALIGN.TOP_CENTER, ALIGN.MIDDLE_CENTER, ALIGN.BOTTOM_CENTER]: + begin_x = (self.parent.cols / 2) - (width / 2) + elif self.align in [ALIGN.TOP_RIGHT, ALIGN.MIDDLE_RIGHT, ALIGN.BOTTOM_RIGHT]: + begin_x = self.parent.cols - width + + return height, width, begin_y, begin_x + + def handle_resize(self): + height, width, begin_y, begin_x = self.calculate_size() + self.resize_window(height, width) + self.move_window(begin_y, begin_x) + + def closed(self): + return self._closed + + def close(self, *args, **kwargs): + self._closed = True + if kwargs.get('call_cb', True): + self._call_close_cb(*args) + self.panel.hide() + + def _call_close_cb(self, *args, **kwargs): + if self.close_cb: + self.close_cb(*args, base_popup=self, **kwargs) + + @overrides(InputKeyHandler) + def handle_read(self, c): + if c == util.KEY_ESC: # close on esc, no action + self.close(None) + return util.ReadState.READ + return util.ReadState.IGNORED + + +class SelectablePopup(BaseInputPane, Popup): + """ + A popup which will let the user select from some of the lines that are added. + """ + + def __init__( + self, + parent_mode, + title, + selection_cb, + close_cb=None, + input_cb=None, + allow_rearrange=False, + immediate_action=False, + **kwargs + ): + """ + Args: + parent_mode (basemode subclass): The mode which the popup will be drawn over + title (str): the title of the popup window + selection_cb (func): Function to be called on selection + close_cb (func, optional): Function to be called when the popup is closed + input_cb (func, optional): Function to be called on every keyboard input + allow_rearrange (bool): Allow rearranging the selectable value + immediate_action (bool): If immediate_action_cb should be called for every action + kwargs (dict): Arguments passed to Popup + + """ + Popup.__init__(self, parent_mode, title, close_cb=close_cb, **kwargs) + kwargs.update( + {'allow_rearrange': allow_rearrange, 'immediate_action': immediate_action} + ) + BaseInputPane.__init__(self, self, **kwargs) + self.selection_cb = selection_cb + self.input_cb = input_cb + self.hotkeys = {} + self.cb_arg = {} + self.cb_args = kwargs.get('cb_args', {}) + if 'base_popup' not in self.cb_args: + self.cb_args['base_popup'] = self + + @property + @overrides(BaseWindow) + def visible_content_pane_height(self): + """We want to use the Popup property""" + return Popup.visible_content_pane_height.fget(self) + + def current_selection(self): + """Returns a tuple of (selected index, selected data).""" + return self.active_input + + def set_selection(self, index): + """Set a selected index""" + self.active_input = min(index, len(self.inputs) - 1) + + def add_line( + self, + name, + string, + use_underline=True, + cb_arg=None, + foreground=None, + selectable=True, + selected=False, + **kwargs + ): + hotkey = None + self.cb_arg[name] = cb_arg + if use_underline: + udx = string.find('_') + if udx >= 0: + hotkey = string[udx].lower() + string = ( + string[:udx] + + '{!+underline!}' + + string[udx + 1] + + '{!-underline!}' + + string[udx + 2 :] + ) + + kwargs['selectable'] = selectable + if foreground: + kwargs['color_active'] = '%s,white' % foreground + kwargs['color'] = '%s,black' % foreground + + field = self.add_text_field(name, string, **kwargs) + if hotkey: + self.hotkeys[hotkey] = field + + if selected: + self.set_selection(len(self.inputs) - 1) + + @overrides(Popup, BaseInputPane) + def handle_read(self, c): + if c in [curses.KEY_ENTER, util.KEY_ENTER2]: + for k, v in self.get_values().items(): + if v['active']: + if self.selection_cb(k, **dict(self.cb_args, data=self.cb_arg)): + self.close(None) + return util.ReadState.READ + else: + ret = BaseInputPane.handle_read(self, c) + if ret != util.ReadState.IGNORED: + return ret + ret = Popup.handle_read(self, c) + if ret != util.ReadState.IGNORED: + if self.selection_cb(None): + self.close(None) + return ret + + if self.input_cb: + self.input_cb(c) + + self.refresh() + return util.ReadState.IGNORED + + def add_divider(self, message=None, char='-', fill_width=True, color='white'): + if message is not None: + fill_width = False + else: + message = char + self.add_divider_field('', message, selectable=False, fill_width=fill_width) + + +class MessagePopup(Popup, BaseInputPane): + """ + Popup that just displays a message + """ + + def __init__( + self, + parent_mode, + title, + message, + align=ALIGN.DEFAULT, + height_req=0.75, + width_req=0.5, + **kwargs + ): + self.message = message + Popup.__init__( + self, + parent_mode, + title, + align=align, + height_req=height_req, + width_req=width_req, + ) + BaseInputPane.__init__(self, self, immediate_action=True, **kwargs) + lns = format_utils.wrap_string(self.message, self.width - 3, 3, True) + + if isinstance(self.height_req, float): + self.height_req = min(len(lns) + 2, int(parent_mode.rows * self.height_req)) + + self.handle_resize() + self.no_refresh = False + self.add_text_area('TextMessage', message) + + @overrides(Popup, BaseInputPane) + def handle_read(self, c): + ret = BaseInputPane.handle_read(self, c) + if ret != util.ReadState.IGNORED: + return ret + return Popup.handle_read(self, c) + + +class InputPopup(Popup, BaseInputPane): + def __init__(self, parent_mode, title, **kwargs): + Popup.__init__(self, parent_mode, title, **kwargs) + BaseInputPane.__init__(self, self, **kwargs) + # We need to replicate some things in order to wrap our inputs + self.encoding = parent_mode.encoding + + def _handle_callback(self, state_changed=True, close=True): + self._call_close_cb(self.get_values(), state_changed=state_changed, close=close) + + @overrides(BaseInputPane) + def immediate_action_cb(self, state_changed=True): + self._handle_callback(state_changed=state_changed, close=False) + + @overrides(Popup, BaseInputPane) + def handle_read(self, c): + ret = BaseInputPane.handle_read(self, c) + if ret != util.ReadState.IGNORED: + return ret + + if c in [curses.KEY_ENTER, util.KEY_ENTER2]: + if self.close_cb: + self._handle_callback(state_changed=False, close=False) + util.safe_curs_set(util.Curser.INVISIBLE) + return util.ReadState.READ + elif c == util.KEY_ESC: # close on esc, no action + self._handle_callback(state_changed=False, close=True) + self.close(None) + return util.ReadState.READ + + self.refresh() + return util.ReadState.READ diff --git a/deluge/ui/console/widgets/sidebar.py b/deluge/ui/console/widgets/sidebar.py new file mode 100644 index 0000000..4015a13 --- /dev/null +++ b/deluge/ui/console/widgets/sidebar.py @@ -0,0 +1,79 @@ +# +# Copyright (C) 2016 bendikro +# +# 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.decorators import overrides +from deluge.ui.console.modes.basemode import add_string +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.widgets import BaseInputPane, BaseWindow + +log = logging.getLogger(__name__) + + +class Sidebar(BaseInputPane, BaseWindow): + """Base sidebar widget that handles choosing a selected widget + with Up/Down arrows. + + Shows the different states of the torrents and allows to filter the + torrents based on state. + + """ + + def __init__( + self, torrentlist, width, height, title=None, allow_resize=False, **kwargs + ): + BaseWindow.__init__(self, title, width, height, posy=1) + BaseInputPane.__init__(self, self, immediate_action=True, **kwargs) + self.parent = torrentlist + self.focused = False + self.allow_resize = allow_resize + + def set_focused(self, focused): + self.focused = focused + + def has_focus(self): + return self.focused and not self.hidden() + + @overrides(BaseInputPane) + def handle_read(self, c): + if c == curses.KEY_UP: + self.move_active_up(1) + elif c == curses.KEY_DOWN: + self.move_active_down(1) + elif self.allow_resize and c in [ord('+'), ord('-')]: + width = self.visible_content_pane_width + (1 if c == ord('+') else -1) + self.on_resize(width) + else: + return BaseInputPane.handle_read(self, c) + return util.ReadState.READ + + def on_resize(self, width): + self.resize_window(self.height, width) + + @overrides(BaseWindow) + def refresh(self): + height = self.get_content_height() + self.ensure_content_pane_height( + height + self.border_off_north + self.border_off_south + ) + BaseInputPane.render_inputs(self, focused=self.has_focus()) + BaseWindow.refresh(self) + + def _refresh(self): + self.screen.erase() + height = self.get_content_height() + self.ensure_content_pane_height( + height + self.border_off_north + self.border_off_south + ) + BaseInputPane.render_inputs(self, focused=True) + BaseWindow.refresh(self) + + def add_string(self, row, string, scr=None, **kwargs): + add_string(row, string, self.screen, self.parent.encoding, **kwargs) diff --git a/deluge/ui/console/widgets/statusbars.py b/deluge/ui/console/widgets/statusbars.py new file mode 100644 index 0000000..1b91737 --- /dev/null +++ b/deluge/ui/console/widgets/statusbars.py @@ -0,0 +1,124 @@ +# +# Copyright (C) 2009 Andrew Resch +# +# 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 deluge.common +import deluge.component as component +from deluge.ui.client import client + +DEFAULT_DAEMON_PORT = 58846 + + +class StatusBars(component.Component): + def __init__(self): + component.Component.__init__(self, 'StatusBars', 2, depend=['CoreConfig']) + self.config = component.get('CoreConfig') + + # Hold some values we get from the core + self.connections = 0 + self.download = '' + self.upload = '' + self.dht = 0 + self.external_ip = '' + + # Default values + self.topbar = '{!status!}Deluge %s Console - ' % deluge.common.get_version() + self.bottombar = '{!status!}C: %s' % self.connections + + def start(self): + self.update() + + def update(self): + def on_get_session_status(status): + self.upload = deluge.common.fsize(status['payload_upload_rate']) + self.download = deluge.common.fsize(status['payload_download_rate']) + self.connections = status['peer.num_peers_connected'] + if 'dht_nodes' in status: + self.dht = status['dht.dht_nodes'] + + self.update_statusbars() + + def on_get_external_ip(external_ip): + self.external_ip = external_ip + + keys = [ + 'peer.num_peers_connected', + 'payload_upload_rate', + 'payload_download_rate', + ] + + if self.config['dht']: + keys.append('dht.dht_nodes') + + client.core.get_session_status(keys).addCallback(on_get_session_status) + client.core.get_external_ip().addCallback(on_get_external_ip) + + def update_statusbars(self): + # Update the topbar string + self.topbar = '{!status!}Deluge %s Console - ' % deluge.common.get_version() + + if client.connected(): + info = client.connection_info() + connection_info = '' + + # Client name + if info[2] == 'localclient': + connection_info += '{!white,blue!}%s' + else: + connection_info += '{!green,blue,bold!}%s' + + # Hostname + if info[0] == '127.0.0.1': + connection_info += '{!white,blue,bold!}@{!white,blue!}%s' + else: + connection_info += '{!white,blue,bold!}@{!red,blue,bold!}%s' + + # Port + if info[1] == DEFAULT_DAEMON_PORT: + connection_info += '{!white,blue!}:%s' + else: + connection_info += '{!status!}:%s' + + # Change color back to normal, just in case + connection_info += '{!status!}' + + self.topbar += connection_info % (info[2], info[0], info[1]) + else: + self.topbar += 'Not Connected' + + # Update the bottombar string + self.bottombar = '{!status!}C: {!white,blue!}%s{!status!}' % self.connections + + if self.config['max_connections_global'] > -1: + self.bottombar += ' (%s)' % self.config['max_connections_global'] + + if self.download != '0.0 KiB': + self.bottombar += ' D: {!magenta,blue,bold!}%s{!status!}' % self.download + else: + self.bottombar += ' D: {!white,blue!}%s{!status!}' % self.download + + if self.config['max_download_speed'] > -1: + self.bottombar += ( + ' (%s ' % self.config['max_download_speed'] + _('KiB/s') + ')' + ) + + if self.upload != '0.0 KiB': + self.bottombar += ' U: {!green,blue,bold!}%s{!status!}' % self.upload + else: + self.bottombar += ' U: {!white,blue!}%s{!status!}' % self.upload + + if self.config['max_upload_speed'] > -1: + self.bottombar += ( + ' (%s ' % self.config['max_upload_speed'] + _('KiB/s') + ')' + ) + + if self.config['dht']: + self.bottombar += ' ' + _('DHT') + ': {!white,blue!}%s{!status!}' % self.dht + + self.bottombar += ' ' + _('IP {!white,blue!}%s{!status!}') % ( + self.external_ip if self.external_ip else _('n/a') + ) diff --git a/deluge/ui/console/widgets/window.py b/deluge/ui/console/widgets/window.py new file mode 100644 index 0000000..77aff88 --- /dev/null +++ b/deluge/ui/console/widgets/window.py @@ -0,0 +1,182 @@ +# +# Copyright (C) 2011 Nick Lanham +# Copyright (C) 2008-2009 Ido Abramovich +# Copyright (C) 2009 Andrew Resch +# +# 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.ui.console.modes.basemode import add_string, mkpad, mkpanel +from deluge.ui.console.utils.colors import get_color_pair + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) + + +class BaseWindow: + """ + BaseWindow creates a curses screen to be used for showing panels and popup dialogs + """ + + def __init__(self, title, width, height, posy=0, posx=0, encoding=None): + """ + Args: + title (str): The title of the panel + width (int): Width of the panel + height (int): Height of the panel + posy (int): Position of the panel's first row relative to the terminal screen + posx (int): Position of the panel's first column relative to the terminal screen + encoding (str): Terminal encoding + """ + self.title = title + self.posy, self.posx = posy, posx + if encoding is None: + from deluge import component + + encoding = component.get('ConsoleUI').encoding + self.encoding = encoding + + self.panel = mkpanel(curses.COLOR_GREEN, height, width, posy, posx) + self.outer_screen = self.panel.window() + self.outer_screen.bkgdset(0, curses.COLOR_RED) + by, bx = self.outer_screen.getbegyx() + self.screen = mkpad(get_color_pair('white', 'black'), height - 1, width - 2) + self._height, self._width = self.outer_screen.getmaxyx() + + @property + def height(self): + return self._height + + @property + def width(self): + return self._width + + def add_string(self, row, string, scr=None, **kwargs): + scr = scr if scr else self.screen + add_string(row, string, scr, self.encoding, **kwargs) + + def hide(self): + self.panel.hide() + + def show(self): + self.panel.show() + + def hidden(self): + return self.panel.hidden() + + def set_title(self, title): + self.title = title + + @property + def visible_content_pane_size(self): + y, x = self.outer_screen.getmaxyx() + return (y - 2, x - 2) + + @property + def visible_content_pane_height(self): + y, x = self.visible_content_pane_size + return y + + @property + def visible_content_pane_width(self): + y, x = self.visible_content_pane_size + return x + + def getmaxyx(self): + return self.screen.getmaxyx() + + def resize_window(self, rows, cols): + self.outer_screen.resize(rows, cols) + self.screen.resize(rows - 2, cols - 2) + self._height, self._width = rows, cols + + def move_window(self, posy, posx): + posy = int(posy) + posx = int(posx) + self.outer_screen.mvwin(posy, posx) + self.posy = posy + self.posx = posx + self._height, self._width = self.screen.getmaxyx() + + def ensure_content_pane_height(self, height): + max_y, max_x = self.screen.getmaxyx() + if max_y < height: + self.screen.resize(height, max_x) + + def draw_scroll_indicator(self, screen): + content_height = self.get_content_height() + if content_height <= self.visible_content_pane_height: + return + + percent_scroll = float(self.lineoff) / ( + content_height - self.visible_content_pane_height + ) + indicator_row = int(self.visible_content_pane_height * percent_scroll) + 1 + + # Never greater than height + indicator_row = min(indicator_row, self.visible_content_pane_height) + indicator_col = self.width + 1 + + add_string( + indicator_row, + '{!red,black,bold!}#', + screen, + self.encoding, + col=indicator_col, + pad=False, + trim=False, + ) + + def refresh(self): + height, width = self.visible_content_pane_size + self.outer_screen.erase() + self.outer_screen.border(0, 0, 0, 0) + + if self.title: + toff = max(1, (self.width // 2) - (len(self.title) // 2)) + self.add_string( + 0, + '{!white,black,bold!}%s' % self.title, + scr=self.outer_screen, + col=toff, + pad=False, + ) + + self.draw_scroll_indicator(self.outer_screen) + self.outer_screen.noutrefresh() + + try: + # pminrow, pmincol, sminrow, smincol, smaxrow, smaxcol + # the p arguments refer to the upper left corner of the pad region to be displayed and + # the s arguments define a clipping box on the screen within which the pad region is to be displayed. + pminrow = self.lineoff + pmincol = 0 + sminrow = self.posy + 1 + smincol = self.posx + 1 + smaxrow = height + self.posy + smaxcol = width + self.posx + self.screen.noutrefresh( + pminrow, pmincol, sminrow, smincol, smaxrow, smaxcol + ) + except curses.error as ex: + import traceback + + log.warning( + 'Error on screen.noutrefresh(%s, %s, %s, %s, %s, %s) Error: %s\nStack: %s', + pminrow, + pmincol, + sminrow, + smincol, + smaxrow, + smaxcol, + ex, + ''.join(traceback.format_stack()), + ) -- cgit v1.2.3