diff --git a/deluge/tests/test_ui_console_fields.py b/deluge/tests/test_ui_console_fields.py new file mode 100644 index 000000000..72680ca87 --- /dev/null +++ b/deluge/tests/test_ui_console_fields.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- + +from twisted.trial import unittest + +from deluge.ui.console.widgets.fields import TextInput + + +class Parent(object): + + def __init__(self): + self.border_off_x = 1 + self.pane_width = 20 + + +class UICommonTestCase(unittest.TestCase): + + def setUp(self): # NOQA + self.parent = Parent() + + def tearDown(self): # NOQA + pass + + def test_text_input(self): + def move_func(self, r, c): + self._cursor_row = r + self._cursor_col = c + + t = TextInput(self.parent, "name", "message", move_func, 20, "/text/field/file/path", complete=False) + self.assertTrue(t) # Shut flake8 up (unused variable) diff --git a/deluge/tests/test_ui_entry.py b/deluge/tests/test_ui_entry.py index 66930dfe9..9b5535c85 100644 --- a/deluge/tests/test_ui_entry.py +++ b/deluge/tests/test_ui_entry.py @@ -21,7 +21,7 @@ from twisted.internet import defer import deluge import deluge.component as component import deluge.ui.console -import deluge.ui.console.commands.quit +import deluge.ui.console.cmdline.commands.quit import deluge.ui.console.main import deluge.ui.web.server from deluge.ui import ui_entry @@ -332,7 +332,7 @@ class ConsoleUIWithDaemonBaseTestCase(UIWithDaemonBaseTestCase): def set_up(self): # Avoid calling reactor.shutdown after commands are executed by main.exec_args() - self.patch(deluge.ui.console.commands.quit, "reactor", common.ReactorOverride()) + self.patch(deluge.ui.console.cmdline.commands.quit, "reactor", common.ReactorOverride()) return UIWithDaemonBaseTestCase.set_up(self) @defer.inlineCallbacks diff --git a/deluge/ui/common.py b/deluge/ui/common.py index 461fa2dc2..81bdd37bb 100644 --- a/deluge/ui/common.py +++ b/deluge/ui/common.py @@ -44,6 +44,52 @@ STATE_TRANSLATION = { "Error": _("Error"), } +TORRENT_DATA_FIELD = { + "queue": {"name": "#", "status": ["queue"]}, + "name": {"name": _("Name"), "status": ["state", "name"]}, + "progress_state": {"name": _("Progress"), "status": ["progress", "state"]}, + "state": {"name": _("State"), "status": ["state"]}, + "progress": {"name": _("Progress"), "status": ["progress"]}, + "size": {"name": _("Size"), "status": ["total_wanted"]}, + "downloaded": {"name": _("Downloaded"), "status": ["all_time_download"]}, + "uploaded": {"name": _("Uploaded"), "status": ["total_uploaded"]}, + "remaining": {"name": _("Remaining"), "status": ["total_remaining"]}, + "ratio": {"name": _("Ratio"), "status": ["ratio"]}, + "download_speed": {"name": _("Down Speed"), "status": ["download_payload_rate"]}, + "upload_speed": {"name": _("Up Speed"), "status": ["upload_payload_rate"]}, + "max_download_speed": {"name": _("Down Limit"), "status": ["max_download_speed"]}, + "max_upload_speed": {"name": _("Up Limit"), "status": ["max_upload_speed"]}, + "max_connections": {"name": _("Max Connections"), "status": ["max_connections"]}, + "max_upload_slots": {"name": _("Max Upload Slots"), "status": ["max_upload_slots"]}, + "peers": {"name": _("Peers"), "status": ["num_peers", "total_peers"]}, + "seeds": {"name": _("Seeds"), "status": ["num_seeds", "total_seeds"]}, + "avail": {"name": _("Avail"), "status": ["distributed_copies"]}, + "seeds_peers_ratio": {"name": _("Seeds:Peers"), "status": ["seeds_peers_ratio"]}, + "time_added": {"name": _("Added"), "status": ["time_added"]}, + "tracker": {"name": _("Tracker"), "status": ["tracker_host"]}, + "download_location": {"name": _("Download Folder"), "status": ["download_location"]}, + "seeding_time": {"name": _("Seeding Time"), "status": ["seeding_time"]}, + "active_time": {"name": _("Active Time"), "status": ["active_time"]}, + "finished_time": {"name": _("Finished Time"), "status": ["finished_time"]}, + "last_seen_complete": {"name": _("Complete Seen"), "status": ["last_seen_complete"]}, + "completed_time": {"name": _("Completed"), "status": ["completed_time"]}, + "eta": {"name": _("ETA"), "status": ["eta"]}, + "shared": {"name": _("Shared"), "status": ["shared"]}, + "prioritize_first_last": {"name": _("Prioritize First/Last"), "status": ["prioritize_first_last"]}, + "sequential_download": {"name": _("Sequential Download"), "status": ["sequential_download"]}, + "is_auto_managed": {"name": _("Auto Managed"), "status": ["is_auto_managed"]}, + "auto_managed": {"name": _("Auto Managed"), "status": ["auto_managed"]}, + "stop_at_ratio": {"name": _("Stop At Ratio"), "status": ["stop_at_ratio"]}, + "stop_ratio": {"name": _("Stop Ratio"), "status": ["stop_ratio"]}, + "remove_at_ratio": {"name": _("Remove At Ratio"), "status": ["remove_at_ratio"]}, + "move_completed": {"name": _("Move On Completed"), "status": ["move_completed"]}, + "move_completed_path": {"name": _("Move Completed Path"), "status": ["move_completed_path"]}, + "move_on_completed": {"name": _("Move On Completed"), "status": ["move_on_completed"]}, + "move_on_completed_path": {"name": _("Move On Completed Path"), "status": ["move_on_completed_path"]}, + "owner": {"name": _("Owner"), "status": ["owner"]} +} + + TRACKER_STATUS_TRANSLATION = { "Error": _("Error"), "Warning": _("Warning"), @@ -73,9 +119,13 @@ class TorrentInfo(object): log.debug("Attempting to open %s.", filename) with open(filename, "rb") as _file: self.__m_filedata = _file.read() + except IOError as ex: + log.warning("Unable to open %s: %s", filename, ex) + raise ex + try: self.__m_metadata = bencode.bdecode(self.__m_filedata) except bencode.BTFailure as ex: - log.warning("Unable to open %s: %s", filename, ex) + log.warning("Failed to decode %s: %s", filename, ex) raise ex self.__m_info_hash = sha(bencode.bencode(self.__m_metadata["info"])).hexdigest() diff --git a/deluge/ui/console/commands/__init__.py b/deluge/ui/console/cmdline/__init__.py similarity index 100% rename from deluge/ui/console/commands/__init__.py rename to deluge/ui/console/cmdline/__init__.py diff --git a/deluge/ui/console/commander.py b/deluge/ui/console/cmdline/command.py similarity index 59% rename from deluge/ui/console/commander.py rename to deluge/ui/console/cmdline/command.py index 2d23687fc..2dfe87e84 100644 --- a/deluge/ui/console/commander.py +++ b/deluge/ui/console/cmdline/command.py @@ -9,15 +9,17 @@ # See LICENSE for more details. # - from __future__ import print_function import logging +import shlex from twisted.internet import defer +from deluge.common import windows_check from deluge.ui.client import client -from deluge.ui.console.colors import strip_colors +from deluge.ui.console.parser import OptionParser, OptionParserError +from deluge.ui.console.utils.colors import strip_colors log = logging.getLogger(__name__) @@ -32,13 +34,13 @@ class Commander(object): print(strip_colors(line)) def do_command(self, cmd_line): - """Run a console command + """Run a console command. Args: - cmd_line (str): Console command + cmd_line (str): Console command. Returns: - Deferred: A deferred that fires when command has been executed + Deferred: A deferred that fires when the command has been executed. """ options = self.parse_command(cmd_line) @@ -46,14 +48,19 @@ class Commander(object): 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 + """Parse a console command and process with argparse. Args: - cmd_line (str): Console command + cmd_line (str): Console command. Returns: - argparse.Namespace: The parsed command + argparse.Namespace: The parsed command. """ if not cmd_line: @@ -100,7 +107,9 @@ class Commander(object): import traceback self.write("%s" % traceback.format_exc()) return - except Exception as ex: + except OptionParserError as ex: + import traceback + log.warn("Error parsing command '%s': %s", args, ex) self.write("{!error!} %s" % ex) parser.print_help() return @@ -110,19 +119,18 @@ class Commander(object): return options def exec_command(self, options, *args): - """ - Execute a console command. + """Execute a console command. Args: - options (argparse.Namespace): The command to execute + options (argparse.Namespace): The command to execute. Returns: - Deferred: A deferred that fires when command has been executed + Deferred: A deferred that fires when command has been executed. """ try: ret = self._commands[options.command].handle(options) - except Exception as ex: + except Exception as ex: # pylint: disable=broad-except self.write("{!error!} %s" % ex) log.exception(ex) import traceback @@ -130,3 +138,59 @@ class Commander(object): return defer.succeed(True) else: return ret + + +class BaseCommand(object): + + 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): + if windows_check(): + 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 + parser = subparsers.add_parser(self.name, **opts) + 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 000000000..9c003700e --- /dev/null +++ b/deluge/ui/console/cmdline/commands/__init__.py @@ -0,0 +1 @@ +from deluge.ui.console.cmdline.command import BaseCommand # NOQA diff --git a/deluge/ui/console/commands/add.py b/deluge/ui/console/cmdline/commands/add.py similarity index 93% rename from deluge/ui/console/commands/add.py rename to deluge/ui/console/cmdline/commands/add.py index 6f34aaec6..c34eaa492 100644 --- a/deluge/ui/console/commands/add.py +++ b/deluge/ui/console/cmdline/commands/add.py @@ -18,16 +18,17 @@ from twisted.internet import defer import deluge.common import deluge.component as component from deluge.ui.client import client -from deluge.ui.console.main import BaseCommand + +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("-p", "--path", dest="path", help=_("download folder for torrent")) parser.add_argument("torrents", metavar="", nargs="+", - help="One or more torrent files, URLs or magnet URIs") + help=_("One or more torrent files, URLs or magnet URIs")) def handle(self, options): self.console = component.get("ConsoleUI") diff --git a/deluge/ui/console/commands/cache.py b/deluge/ui/console/cmdline/commands/cache.py similarity index 94% rename from deluge/ui/console/commands/cache.py rename to deluge/ui/console/cmdline/commands/cache.py index 9d8efb340..158cfca5d 100644 --- a/deluge/ui/console/commands/cache.py +++ b/deluge/ui/console/cmdline/commands/cache.py @@ -9,7 +9,8 @@ import deluge.component as component from deluge.ui.client import client -from deluge.ui.console.main import BaseCommand + +from . import BaseCommand class Command(BaseCommand): diff --git a/deluge/ui/console/commands/config.py b/deluge/ui/console/cmdline/commands/config.py similarity index 94% rename from deluge/ui/console/commands/config.py rename to deluge/ui/console/cmdline/commands/config.py index 102a0272c..48333575e 100644 --- a/deluge/ui/console/commands/config.py +++ b/deluge/ui/console/cmdline/commands/config.py @@ -13,9 +13,10 @@ import logging import tokenize import deluge.component as component -import deluge.ui.console.colors as colors +import deluge.ui.console.utils.colors as colors from deluge.ui.client import client -from deluge.ui.console.main import BaseCommand + +from . import BaseCommand log = logging.getLogger(__name__) @@ -65,14 +66,14 @@ def simple_eval(source): class Command(BaseCommand): """Show and set configuration values""" - usage = "config [--set ] [ [...] ]" + 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") + 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") + get_group.add_argument("keys", metavar="", nargs="*", help=_("one or more keys separated by space")) def handle(self, options): self.console = component.get("ConsoleUI") diff --git a/deluge/ui/console/commands/connect.py b/deluge/ui/console/cmdline/commands/connect.py similarity index 83% rename from deluge/ui/console/commands/connect.py rename to deluge/ui/console/cmdline/commands/connect.py index b67ba31a2..77fbf0bbc 100644 --- a/deluge/ui/console/commands/connect.py +++ b/deluge/ui/console/cmdline/commands/connect.py @@ -12,7 +12,8 @@ import logging import deluge.component as component from deluge.ui.client import client -from deluge.ui.console.main import BaseCommand + +from . import BaseCommand log = logging.getLogger(__name__) @@ -20,12 +21,12 @@ log = logging.getLogger(__name__) class Command(BaseCommand): """Connect to a new deluge server.""" - usage = "Usage: connect [] []" + usage = _("Usage: connect [] []") def add_arguments(self, parser): - parser.add_argument("host", help="host and port", metavar="") - parser.add_argument("username", help="Username", metavar="", nargs="?", default="") - parser.add_argument("password", help="Password", metavar="", nargs="?", default="") + 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") diff --git a/deluge/ui/console/commands/debug.py b/deluge/ui/console/cmdline/commands/debug.py similarity index 92% rename from deluge/ui/console/commands/debug.py rename to deluge/ui/console/cmdline/commands/debug.py index 6ca11f42b..04a3f47ad 100644 --- a/deluge/ui/console/commands/debug.py +++ b/deluge/ui/console/cmdline/commands/debug.py @@ -12,14 +12,15 @@ from twisted.internet import defer import deluge.component as component import deluge.log -from deluge.ui.console.main import BaseCommand + +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") + parser.add_argument("state", metavar="", choices=["on", "off"], help=_("The new state")) def handle(self, options): if options.state == "on": diff --git a/deluge/ui/console/commands/gui.py b/deluge/ui/console/cmdline/commands/gui.py similarity index 65% rename from deluge/ui/console/commands/gui.py rename to deluge/ui/console/cmdline/commands/gui.py index 2d3ab8a2e..d4726732a 100644 --- a/deluge/ui/console/commands/gui.py +++ b/deluge/ui/console/cmdline/commands/gui.py @@ -10,8 +10,8 @@ import logging import deluge.component as component -from deluge.ui.console.main import BaseCommand -from deluge.ui.console.modes.alltorrents import AllTorrents + +from . import BaseCommand log = logging.getLogger(__name__) @@ -22,11 +22,6 @@ class Command(BaseCommand): def handle(self, options): console = component.get("ConsoleUI") - try: - at = component.get("AllTorrents") - except KeyError: - at = AllTorrents(console.stdscr, console.encoding) - - console.set_mode(at) - at._go_top = True + at = console.set_mode("TorrentList") + at.go_top = True at.resume() diff --git a/deluge/ui/console/commands/halt.py b/deluge/ui/console/cmdline/commands/halt.py similarity index 95% rename from deluge/ui/console/commands/halt.py rename to deluge/ui/console/cmdline/commands/halt.py index 51ff14aa2..75bade966 100644 --- a/deluge/ui/console/commands/halt.py +++ b/deluge/ui/console/cmdline/commands/halt.py @@ -10,7 +10,8 @@ import deluge.component as component from deluge.ui.client import client -from deluge.ui.console.main import BaseCommand + +from . import BaseCommand class Command(BaseCommand): diff --git a/deluge/ui/console/commands/help.py b/deluge/ui/console/cmdline/commands/help.py similarity index 96% rename from deluge/ui/console/commands/help.py rename to deluge/ui/console/cmdline/commands/help.py index c5f56b2bf..94690ff68 100644 --- a/deluge/ui/console/commands/help.py +++ b/deluge/ui/console/cmdline/commands/help.py @@ -13,7 +13,8 @@ import logging from twisted.internet import defer import deluge.component as component -from deluge.ui.console.main import BaseCommand + +from . import BaseCommand log = logging.getLogger(__name__) @@ -22,7 +23,7 @@ class Command(BaseCommand): """displays help on other commands""" def add_arguments(self, parser): - parser.add_argument("commands", metavar="", nargs="*", help="One or more commands") + parser.add_argument("commands", metavar="", nargs="*", help=_("One or more commands")) def handle(self, options): self.console = component.get("ConsoleUI") diff --git a/deluge/ui/console/commands/info.py b/deluge/ui/console/cmdline/commands/info.py similarity index 96% rename from deluge/ui/console/commands/info.py rename to deluge/ui/console/cmdline/commands/info.py index 6e66463ba..b71f7d2e8 100644 --- a/deluge/ui/console/commands/info.py +++ b/deluge/ui/console/cmdline/commands/info.py @@ -14,10 +14,11 @@ from os.path import sep as dirsep import deluge.common as common import deluge.component as component -import deluge.ui.console.colors as colors +import deluge.ui.console.utils.colors as colors from deluge.ui.client import client -from deluge.ui.console.main import BaseCommand -from deluge.ui.console.modes import format_utils +from deluge.ui.console.utils import format_utils + +from . import BaseCommand strwidth = format_utils.strwidth @@ -103,16 +104,16 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument("-v", "--verbose", action="store_true", default=False, dest="verbose", - help="Show more information per torrent.") + 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.") + 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))) + 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.") + 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") + 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__, diff --git a/deluge/ui/console/commands/manage.py b/deluge/ui/console/cmdline/commands/manage.py similarity index 74% rename from deluge/ui/console/commands/manage.py rename to deluge/ui/console/cmdline/commands/manage.py index 59a2edc3f..bc5a203ea 100644 --- a/deluge/ui/console/commands/manage.py +++ b/deluge/ui/console/cmdline/commands/manage.py @@ -8,11 +8,16 @@ # 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.main import BaseCommand + +from . import BaseCommand + +log = logging.getLogger(__name__) torrent_options = { "max_download_speed": float, @@ -25,24 +30,24 @@ torrent_options = { "stop_at_ratio": bool, "stop_ratio": float, "remove_at_ratio": bool, - "move_on_completed": bool, - "move_on_completed_path": str + "move_completed": bool, + "move_completed_path": str } class Command(BaseCommand): """Show and manage per-torrent options""" - usage = "manage [--set ] [ [...] ]" + usage = _("Usage: manage [--set ] [ [...] ]") def add_arguments(self, parser): parser.add_argument("torrent", metavar="", - help="an expression matched against torrent ids and torrent names") + 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") + 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") + get_group.add_argument("keys", metavar="", nargs="*", help=_("one or more keys separated by space")) def handle(self, options): self.console = component.get("ConsoleUI") @@ -99,21 +104,7 @@ class Command(BaseCommand): deferred.callback(True) self.console.write("Setting %s to %s for torrents %s.." % (key, val, torrent_ids)) - - for tid in torrent_ids: - if key == "move_on_completed_path": - client.core.set_torrent_move_completed_path(tid, val).addCallback(on_set_config) - elif key == "move_on_completed": - client.core.set_torrent_move_completed(tid, val).addCallback(on_set_config) - elif key == "is_auto_managed": - client.core.set_torrent_auto_managed(tid, val).addCallback(on_set_config) - elif key == "remove_at_ratio": - client.core.set_torrent_remove_at_ratio(tid, val).addCallback(on_set_config) - elif key == "prioritize_first_last": - client.core.set_torrent_prioritize_first_last(tid, val).addCallback(on_set_config) - else: - client.core.set_torrent_options(torrent_ids, {key: val}).addCallback(on_set_config) - break + client.core.set_torrent_options(torrent_ids, {key: val}).addCallback(on_set_config) return deferred def complete(self, line): diff --git a/deluge/ui/console/commands/move.py b/deluge/ui/console/cmdline/commands/move.py similarity index 93% rename from deluge/ui/console/commands/move.py rename to deluge/ui/console/cmdline/commands/move.py index 21d6174b9..5e1b1715c 100644 --- a/deluge/ui/console/commands/move.py +++ b/deluge/ui/console/cmdline/commands/move.py @@ -12,7 +12,8 @@ import os.path import deluge.component as component from deluge.ui.client import client -from deluge.ui.console.main import BaseCommand + +from . import BaseCommand log = logging.getLogger(__name__) @@ -21,8 +22,8 @@ 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") + 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") diff --git a/deluge/ui/console/commands/pause.py b/deluge/ui/console/cmdline/commands/pause.py similarity index 90% rename from deluge/ui/console/commands/pause.py rename to deluge/ui/console/cmdline/commands/pause.py index 13d5affd8..34d040792 100644 --- a/deluge/ui/console/commands/pause.py +++ b/deluge/ui/console/cmdline/commands/pause.py @@ -10,7 +10,8 @@ import deluge.component as component from deluge.ui.client import client -from deluge.ui.console.main import BaseCommand + +from . import BaseCommand class Command(BaseCommand): @@ -19,7 +20,7 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument("torrent_ids", metavar="", nargs="+", - help="One or more torrent ids. '*' pauses all torrents") + help=_("One or more torrent ids. '*' pauses all torrents")) def handle(self, options): self.console = component.get("ConsoleUI") diff --git a/deluge/ui/console/commands/plugin.py b/deluge/ui/console/cmdline/commands/plugin.py similarity index 90% rename from deluge/ui/console/commands/plugin.py rename to deluge/ui/console/cmdline/commands/plugin.py index 56da89d8c..4b6e5ad1a 100644 --- a/deluge/ui/console/commands/plugin.py +++ b/deluge/ui/console/cmdline/commands/plugin.py @@ -10,7 +10,8 @@ import deluge.component as component import deluge.configmanager from deluge.ui.client import client -from deluge.ui.console.main import BaseCommand + +from . import BaseCommand class Command(BaseCommand): @@ -18,14 +19,14 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument("-l", "--list", action="store_true", default=False, dest="list", - help="Lists available plugins") + 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") + 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") + 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") diff --git a/deluge/ui/console/commands/quit.py b/deluge/ui/console/cmdline/commands/quit.py similarity index 95% rename from deluge/ui/console/commands/quit.py rename to deluge/ui/console/cmdline/commands/quit.py index 7c9be5f47..d0f577330 100644 --- a/deluge/ui/console/commands/quit.py +++ b/deluge/ui/console/cmdline/commands/quit.py @@ -11,7 +11,8 @@ from twisted.internet import error, reactor from deluge.ui.client import client -from deluge.ui.console.main import BaseCommand + +from . import BaseCommand class Command(BaseCommand): diff --git a/deluge/ui/console/commands/recheck.py b/deluge/ui/console/cmdline/commands/recheck.py similarity index 92% rename from deluge/ui/console/commands/recheck.py rename to deluge/ui/console/cmdline/commands/recheck.py index ed4b1d9f4..3aaef4a03 100644 --- a/deluge/ui/console/commands/recheck.py +++ b/deluge/ui/console/cmdline/commands/recheck.py @@ -9,7 +9,8 @@ import deluge.component as component from deluge.ui.client import client -from deluge.ui.console.main import BaseCommand + +from . import BaseCommand class Command(BaseCommand): @@ -17,7 +18,7 @@ class Command(BaseCommand): usage = "recheck [ * | [ ...] ]" def add_arguments(self, parser): - parser.add_argument("torrent_ids", metavar="", nargs="+", help="One or more torrent ids") + parser.add_argument("torrent_ids", metavar="", nargs="+", help=_("One or more torrent ids")) def handle(self, options): self.console = component.get("ConsoleUI") diff --git a/deluge/ui/console/commands/resume.py b/deluge/ui/console/cmdline/commands/resume.py similarity index 85% rename from deluge/ui/console/commands/resume.py rename to deluge/ui/console/cmdline/commands/resume.py index a72be6063..6fda7e2bb 100644 --- a/deluge/ui/console/commands/resume.py +++ b/deluge/ui/console/cmdline/commands/resume.py @@ -10,16 +10,17 @@ import deluge.component as component from deluge.ui.client import client -from deluge.ui.console.main import BaseCommand + +from . import BaseCommand class Command(BaseCommand): """Resume torrents""" - usage = "resume [ * | [ ...] ]" + usage = _("Usage: resume [ * | [ ...] ]") def add_arguments(self, parser): parser.add_argument("torrent_ids", metavar="", nargs="+", - help="One or more torrent ids. '*' resumes all torrents") + help=_("One or more torrent ids. '*' resumes all torrents")) def handle(self, options): self.console = component.get("ConsoleUI") diff --git a/deluge/ui/console/commands/rm.py b/deluge/ui/console/cmdline/commands/rm.py similarity index 55% rename from deluge/ui/console/commands/rm.py rename to deluge/ui/console/cmdline/commands/rm.py index 324c0132d..62acb51bf 100644 --- a/deluge/ui/console/commands/rm.py +++ b/deluge/ui/console/cmdline/commands/rm.py @@ -8,9 +8,14 @@ # See LICENSE for more details. # +import logging + import deluge.component as component from deluge.ui.client import client -from deluge.ui.console.main import BaseCommand + +from . import BaseCommand + +log = logging.getLogger(__name__) class Command(BaseCommand): @@ -18,14 +23,24 @@ class Command(BaseCommand): aliases = ["del"] def add_arguments(self, parser): - parser.add_argument("--remove_data", action="store_true", default=False, help="remove the torrent's data") - parser.add_argument("torrent_ids", metavar="", nargs="+", help="One or more torrent ids") + parser.add_argument("--remove_data", action="store_true", default=False, help=_("remove the torrent's 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 = [] - for arg in options.torrent_ids: - torrent_ids.extend(self.console.match_torrent(arg)) + 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: @@ -33,6 +48,7 @@ class Command(BaseCommand): for t_id, e_msg in errors: self.console.write("Error removing torrent %s : %s" % (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) diff --git a/deluge/ui/console/commands/status.py b/deluge/ui/console/cmdline/commands/status.py similarity index 90% rename from deluge/ui/console/commands/status.py rename to deluge/ui/console/cmdline/commands/status.py index 704d1f267..915054950 100644 --- a/deluge/ui/console/commands/status.py +++ b/deluge/ui/console/cmdline/commands/status.py @@ -14,7 +14,8 @@ from twisted.internet import defer import deluge.component as component from deluge.common import TORRENT_STATE, fspeed from deluge.ui.client import client -from deluge.ui.console.main import BaseCommand + +from . import BaseCommand log = logging.getLogger(__name__) @@ -24,10 +25,10 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument("-r", "--raw", action="store_true", default=False, dest="raw", - help="Don't format upload/download rates in KiB/s \ - (useful for scripts that want to do their own parsing)") + help=_("Don't format upload/download rates in KiB/s " + "(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="Don't show torrent status (this will make the command a bit faster)") + help=_("Don't show torrent status (this will make the command a bit faster)")) def handle(self, options): self.console = component.get("ConsoleUI") diff --git a/deluge/ui/console/commands/update_tracker.py b/deluge/ui/console/cmdline/commands/update_tracker.py similarity index 96% rename from deluge/ui/console/commands/update_tracker.py rename to deluge/ui/console/cmdline/commands/update_tracker.py index 296f8cae1..756075e6e 100644 --- a/deluge/ui/console/commands/update_tracker.py +++ b/deluge/ui/console/cmdline/commands/update_tracker.py @@ -10,7 +10,8 @@ import deluge.component as component from deluge.ui.client import client -from deluge.ui.console.main import BaseCommand + +from . import BaseCommand class Command(BaseCommand): diff --git a/deluge/ui/console/console.py b/deluge/ui/console/console.py index 7d81e7696..c16baa98a 100644 --- a/deluge/ui/console/console.py +++ b/deluge/ui/console/console.py @@ -30,7 +30,7 @@ log = logging.getLogger(__name__) def load_commands(command_dir): def get_command(name): - return getattr(__import__('deluge.ui.console.commands.%s' % name, {}, {}, ['Command']), 'Command')() + return getattr(__import__('deluge.ui.console.cmdline.commands.%s' % name, {}, {}, ['Command']), 'Command')() try: commands = [] @@ -40,10 +40,10 @@ def load_commands(command_dir): if not (filename.endswith('.py') or filename.endswith('.pyc')): continue cmd = get_command(filename.split('.')[len(filename.split('.')) - 2]) - cmd._name = filename.split('.')[len(filename.split('.')) - 2] - names = [cmd._name] - names.extend(cmd.aliases) - for a in names: + aliases = [filename.split('.')[len(filename.split('.')) - 2]] + cmd._name = aliases[0] + aliases.extend(cmd.aliases) + for a in aliases: commands.append((a, cmd)) return dict(commands) except OSError: @@ -67,8 +67,9 @@ class Console(UI): def __init__(self, *args, **kwargs): super(Console, self).__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 = 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", dest="daemon_addr", required=False, default="127.0.0.1") group.add_argument("-p", "--port", dest="daemon_port", type=int, required=False, default="58846") group.add_argument("-U", "--username", dest="daemon_user", required=False) @@ -76,17 +77,17 @@ class Console(UI): # 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.main import ConsoleCommandParser # import here because (see top) + from deluge.ui.console.parser import ConsoleCommandParser # import here because (see top) 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", dest="command", - description="The following console commands are available:", - metavar="command") - self.console_cmds = load_commands(os.path.join(UI_PATH, "commands")) + subparsers = self.console_parser.add_subparsers(title=_("Console commands"), help=_("Description"), + description=_("The following console commands are available:"), + metavar=_("Command"), dest="command") + self.console_cmds = load_commands(os.path.join(UI_PATH, "cmdline", "commands")) for c in sorted(self.console_cmds): self.console_cmds[c].add_subparser(subparsers) diff --git a/deluge/ui/console/eventlog.py b/deluge/ui/console/eventlog.py deleted file mode 100644 index 4230cc2f1..000000000 --- a/deluge/ui/console/eventlog.py +++ /dev/null @@ -1,127 +0,0 @@ -# -*- coding: utf-8 -*- -# -# 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 time - -import deluge.component as component -from deluge.common import windows_check -from deluge.ui.client import client -from deluge.ui.console import colors - -log = logging.getLogger(__name__) - - -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:" - - client.register_event_handler("TorrentAddedEvent", self.on_torrent_added_event) - client.register_event_handler("PreTorrentRemovedEvent", self.on_torrent_removed_event) - client.register_event_handler("TorrentStateChangedEvent", self.on_torrent_state_changed_event) - client.register_event_handler("TorrentFinishedEvent", self.on_torrent_finished_event) - client.register_event_handler("NewVersionAvailableEvent", self.on_new_version_available_event) - client.register_event_handler("SessionPausedEvent", self.on_session_paused_event) - client.register_event_handler("SessionResumedEvent", self.on_session_resumed_event) - client.register_event_handler("ConfigValueChangedEvent", self.on_config_value_changed_event) - client.register_event_handler("PluginEnabledEvent", self.on_plugin_enabled_event) - client.register_event_handler("PluginDisabledEvent", self.on_plugin_disabled_event) - - self.previous_time = time.localtime(0) - - def on_torrent_added_event(self, torrent_id, from_state): - if from_state: - return - - def on_torrent_status(status): - 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_event(torrent_id, status["state"]) - - client.core.get_torrent_status(torrent_id, ["name", "state"]).addCallback(on_torrent_status) - - def on_torrent_removed_event(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_event(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("%s: {!info!}%s ({!cyan!}%s{!info!})" % - (state, t_name, torrent_id)) - - def on_torrent_finished_event(self, torrent_id): - if not windows_check() and component.get("AllTorrents").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_event(self, version): - self.write("{!input!}New Deluge version available: {!info!}%s" % - (version)) - - def on_session_paused_event(self): - self.write("{!input!}Session Paused") - - def on_session_resumed_event(self): - self.write("{!green!}Session Resumed") - - def on_config_value_changed_event(self, key, value): - color = "{!white,black,bold!}" - try: - color = colors.type_color[type(value)] - except KeyError: - pass - - self.write("ConfigValueChanged: {!input!}%s: %s%s" % (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_event(self, name): - self.write("PluginEnabled: {!info!}%s" % name) - - def on_plugin_disabled_event(self, name): - self.write("PluginDisabled: {!info!}%s" % name) diff --git a/deluge/ui/console/main.py b/deluge/ui/console/main.py index 7284a362c..aeeab5c2a 100644 --- a/deluge/ui/console/main.py +++ b/deluge/ui/console/main.py @@ -10,226 +10,83 @@ from __future__ import print_function -import argparse import locale import logging import os -import shlex import sys +import time from twisted.internet import defer, reactor import deluge.common import deluge.component as component +from deluge.configmanager import ConfigManager +from deluge.decorators import overrides from deluge.error import DelugeError from deluge.ui.client import client -from deluge.ui.console import colors -from deluge.ui.console.colors import ConsoleColorFormatter -from deluge.ui.console.commander import Commander -from deluge.ui.console.eventlog import EventLog -from deluge.ui.console.statusbars import StatusBars +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.widgets import StatusBars from deluge.ui.coreconfig import CoreConfig from deluge.ui.sessionproxy import SessionProxy log = logging.getLogger(__name__) - -class ConsoleBaseParser(argparse.ArgumentParser): - - def format_help(self): - """ - Differs from ArgumentParser.format_help by adding the raw epilog - as formatted in the string. Default bahavior mangles the formatting. - - """ - # Handle epilog manually to keep the text formatting - epilog = self.epilog - self.epilog = "" - help_str = super(ConsoleBaseParser, self).format_help() - if epilog is not None: - help_str += epilog - self.epilog = epilog - return help_str +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 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(ConsoleCommandParser, self).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(ConsoleCommandParser, self).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(OptionParser, self).__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 Exception(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(OptionParser, self).format_help() - return self.formatter.format_colors(help_str) - - -class BaseCommand(object): - - 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): - if deluge.common.windows_check(): - 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 - parser = subparsers.add_parser(self.name, **opts) - self.add_arguments(parser) - - def add_arguments(self, parser): - pass - - -class ConsoleUI(component.Component): +class ConsoleUI(component.Component, TermResizeHandler): def __init__(self, options, cmds, log_stream): - component.Component.__init__(self, "ConsoleUI", 2) + 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 Exception: + except locale.Error: self.encoding = sys.getdefaultencoding() log.debug("Using encoding: %s", self.encoding) @@ -284,6 +141,8 @@ Please use commands from the command line, e.g.:\n def flush(self): pass + # We don't ever want log output to terminal when running in + # interactive mode, so insert a dummy here self.log_stream.out = ConsoleLog() # Set Esc key delay to 0 to avoid a very annoying delay @@ -297,6 +156,8 @@ Please use commands from the command line, e.g.:\n curses.wrapper(self.run) def exec_args(self, options): + """Execute console commands from command line.""" + from deluge.ui.console.cmdline.command import Commander commander = Commander(self._commands) def on_connect(result): @@ -319,7 +180,7 @@ Please use commands from the command line, e.g.:\n # any of the commands. self.started_deferred.addCallback(on_started) return self.started_deferred - d = component.start() + d = self.start_console() d.addCallback(on_components_started) return d @@ -343,47 +204,161 @@ Please use commands from the command line, e.g.:\n return d def run(self, stdscr): - """ - This method is called by the curses.wrapper to start the mainloop and - screen. + """This method is called by the curses.wrapper to start the mainloop and screen. - :param stdscr: curses screen passed in from curses.wrapper + 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, self._migrate_config_1_to_2) + self.statusbars = StatusBars() from deluge.ui.console.modes.connectionmanager import ConnectionManager - self.stdscr = stdscr - self.screen = ConnectionManager(stdscr, self.encoding) + 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.screen.topbar = "{!status!}Deluge " + deluge.common.get_version() + " Console" - self.screen.bottombar = "{!status!}" - self.screen.refresh() - - # 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.screen) - + 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() - def start(self): + @overrides(TermResizeHandler) + def on_terminal_size(self, *args): + rows, cols = super(ConsoleUI, self).on_terminal_size(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 + + def start_components(self): + def on_started(result): + component.pause(["TorrentList", "EventView", "AddTorrents", "TorrentDetail", "Preferences"]) + + if self.interactive: + d = component.start().addCallback(on_started) + else: + d = component.start(["SessionProxy", "ConsoleUI", "CoreConfig"]) + return d + + def start_console(self): # Maintain a list of (torrent_id, name) for use in tab completion - self.torrents = [] - if not self.interactive: - self.started_deferred = defer.Deferred() + self.started_deferred = defer.Deferred() - def on_session_state(result): - def on_torrents_status(torrents): - for torrent_id, status in torrents.items(): - self.torrents.append((torrent_id, status["name"])) - self.started_deferred.callback(True) + if not self.initialized: + self.initialized = True + d = self.start_components() + else: + def on_stopped(result): + return component.start(["SessionProxy"]) + d = component.stop(["SessionProxy"]).addCallback(on_stopped) + return d - client.core.get_torrents_status({"id": result}, ["name"]).addCallback(on_torrents_status) - client.core.get_session_state().addCallback(on_session_state) + def start(self): + def on_session_state(result): + self.torrents = [] + self.events = [] + + def on_torrents_status(torrents): + for torrent_id, status in torrents.items(): + self.torrents.append((torrent_id, status["name"])) + self.started_deferred.callback(True) + + client.core.get_torrents_status({"id": result}, ["name"]).addCallback(on_torrents_status) + + d = client.core.get_session_state().addCallback(on_session_state) + + # Register event handlers to keep the torrent list up-to-date + client.register_event_handler("TorrentAddedEvent", self.on_torrent_added_event) + client.register_event_handler("TorrentRemovedEvent", self.on_torrent_removed_event) + return d + + def on_torrent_added_event(self, event, from_state=False): + def on_torrent_status(status): + self.torrents.append((event, status["name"])) + client.core.get_torrent_status(event, ["name"]).addCallback(on_torrent_status) + + def on_torrent_removed_event(self, event): + for index, (tid, name) in enumerate(self.torrents): + if event == tid: + del self.torrents[index] + + def match_torrents(self, strings): + torrent_ids = [] + for s in strings: + torrent_ids.extend(self.match_torrent(s)) + return list(set(torrent_ids)) def match_torrent(self, string): """ @@ -396,67 +371,235 @@ Please use commands from the command line, e.g.:\n no matches are found. """ - if self.interactive and isinstance(self.screen, deluge.ui.console.modes.legacy.Legacy): - return self.screen.match_torrent(string) + if not isinstance(string, unicode): + string = unicode(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 = [] - - string = string.decode(self.encoding) for tid, name in self.torrents: - if tid.startswith(string) or name.startswith(string): + if not isinstance(name, unicode): + name = unicode(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): - if self.interactive and hasattr(self.screen, "get_torrent_name"): - return self.screen.get_torrent_name(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.screen, deluge.ui.console.modes.legacy.Legacy): - return self.screen.set_batch_write(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.screen, deluge.ui.console.modes.legacy.Legacy): - return self.screen.tab_complete_torrent(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.screen, deluge.ui.console.modes.legacy.Legacy): - return self.screen.tab_complete_path(line, path_type=path_type, ext=ext, sort=sort, dirs_first=dirs_first) - - def set_mode(self, mode): - reactor.removeReader(self.screen) - self.screen = mode - self.statusbars.screen = self.screen - reactor.addReader(self.screen) - self.stdscr.clear() - mode.refresh() + 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.screen, deluge.ui.console.modes.legacy.Legacy): - self.screen.write(s) + if isinstance(self.active_mode, deluge.ui.console.modes.cmdline.CmdLine): + self.active_mode.write(s) else: - component.get("LegacyUI").add_line(s, False) + component.get("CmdLine").add_line(s, False) self.events.append(s) else: print(colors.strip_colors(deluge.common.utf8_encoded(s))) def write_event(self, s): if self.interactive: - if isinstance(self.screen, deluge.ui.console.modes.legacy.Legacy): + if isinstance(self.active_mode, deluge.ui.console.modes.cmdline.CmdLine): self.events.append(s) - self.screen.write(s) + self.active_mode.write(s) else: - component.get("LegacyUI").add_line(s, False) + component.get("CmdLine").add_line(s, False) self.events.append(s) else: print(colors.strip_colors(deluge.common.utf8_encoded(s))) + + def _migrate_config_1_to_2(self, 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"] = DEFAULT_CONSOLE_PREFS["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 + + +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:" + + client.register_event_handler("TorrentAddedEvent", self.on_torrent_added_event) + client.register_event_handler("PreTorrentRemovedEvent", self.on_torrent_removed_event) + client.register_event_handler("TorrentStateChangedEvent", self.on_torrent_state_changed_event) + client.register_event_handler("TorrentFinishedEvent", self.on_torrent_finished_event) + client.register_event_handler("NewVersionAvailableEvent", self.on_new_version_available_event) + client.register_event_handler("SessionPausedEvent", self.on_session_paused_event) + client.register_event_handler("SessionResumedEvent", self.on_session_resumed_event) + client.register_event_handler("ConfigValueChangedEvent", self.on_config_value_changed_event) + client.register_event_handler("PluginEnabledEvent", self.on_plugin_enabled_event) + client.register_event_handler("PluginDisabledEvent", self.on_plugin_disabled_event) + + self.previous_time = time.localtime(0) + + def on_torrent_added_event(self, torrent_id, from_state): + if from_state: + return + + def on_torrent_status(status): + 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_event(torrent_id, status["state"]) + + client.core.get_torrent_status(torrent_id, ["name", "state"]).addCallback(on_torrent_status) + + def on_torrent_removed_event(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_event(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("%s: {!info!}%s ({!cyan!}%s{!info!})" % + (state, t_name, torrent_id)) + + def on_torrent_finished_event(self, torrent_id): + if not deluge.common.windows_check() and 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_event(self, version): + self.write("{!input!}New Deluge version available: {!info!}%s" % + (version)) + + def on_session_paused_event(self): + self.write("{!input!}Session Paused") + + def on_session_resumed_event(self): + self.write("{!green!}Session Resumed") + + def on_config_value_changed_event(self, key, value): + color = "{!white,black,bold!}" + try: + color = colors.type_color[type(value)] + except KeyError: + pass + + self.write("ConfigValueChanged: {!input!}%s: %s%s" % (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_event(self, name): + self.write("PluginEnabled: {!info!}%s" % name) + + def on_plugin_disabled_event(self, name): + self.write("PluginDisabled: {!info!}%s" % name) diff --git a/deluge/ui/console/modes/addtorrents.py b/deluge/ui/console/modes/addtorrents.py index 26a045519..2b6767b6b 100644 --- a/deluge/ui/console/modes/addtorrents.py +++ b/deluge/ui/console/modes/addtorrents.py @@ -13,11 +13,13 @@ import os import deluge.common import deluge.component as component +from deluge.decorators import overrides from deluge.ui.client import client -from deluge.ui.console.modes import format_utils from deluge.ui.console.modes.basemode import BaseMode -from deluge.ui.console.modes.input_popup import InputPopup -from deluge.ui.console.modes.popup import MessagePopup +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 @@ -55,21 +57,18 @@ if a file is highlighted """ -class AddTorrents(BaseMode, component.Component): - def __init__(self, alltorrentmode, stdscr, console_config, encoding=None): +class AddTorrents(BaseMode): + def __init__(self, parent_mode, stdscr, console_config, encoding=None): self.console_config = console_config - - self.alltorrentmode = alltorrentmode - + 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"]) + path = os.path.expanduser(self.console_config["addtorrents"]["last_path"]) self.path_stack = ["/"] + path.strip("/").split("/") self.path_stack_pos = len(self.path_stack) @@ -81,26 +80,22 @@ class AddTorrents(BaseMode, component.Component): 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"] + 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() - component.Component.__init__(self, "AddTorrents", 1, depend=["SessionProxy"]) - - component.start(["AddTorrents"]) - - curses.curs_set(0) + util.safe_curs_set(util.Curser.INVISIBLE) self.stdscr.notimeout(0) - # component start/update + @overrides(component.Component) def start(self): pass + @overrides(component.Component) def update(self): pass @@ -119,12 +114,12 @@ class AddTorrents(BaseMode, component.Component): for f in listing: if os.path.isdir(os.path.join(path, f)): - if self.console_config["addtorrents_show_hidden_folders"]: + 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"]: + if self.console_config["addtorrents"]["show_misc_files"]: self.listing_files.append(f) elif f.endswith(".torrent"): self.listing_files.append(f) @@ -167,8 +162,8 @@ class AddTorrents(BaseMode, component.Component): 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["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()) @@ -178,7 +173,6 @@ class AddTorrents(BaseMode, component.Component): 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): @@ -224,37 +218,21 @@ class AddTorrents(BaseMode, component.Component): self.popup = pu self.refresh() - def on_resize(self, *args): - BaseMode.on_resize_norefresh(self, *args) - - # Always refresh Legacy(it will also refresh AllTorrents), otherwise it will bug deluge out - legacy = component.get("LegacyUI") - legacy.on_resize(*args) - + @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.add_string(0, self.statusbars.topbar) - - # This will quite likely fail when switching modes - try: - rf = format_utils.remove_formatting - string = self.statusbars.bottombar - hstr = "Press {!magenta,blue,bold!}[h]{!status!} for help" - - string += " " * (self.cols - len(rf(string)) - len(rf(hstr))) + hstr - - self.add_string(self.rows - 1, string) - except Exception as ex: - log.debug("Exception caught: %s", ex) + self.draw_statusbars() off = 1 @@ -331,7 +309,7 @@ class AddTorrents(BaseMode, component.Component): if off > self.rows - 2: break - if component.get("ConsoleUI").screen != self: + if not component.get("ConsoleUI").is_active_mode(self): return self.stdscr.noutrefresh() @@ -342,12 +320,8 @@ class AddTorrents(BaseMode, component.Component): curses.doupdate() def back_to_overview(self): - component.stop(["AddTorrents"]) - component.deregister(self) - self.stdscr.erase() - component.get("ConsoleUI").set_mode(self.alltorrentmode) - self.alltorrentmode._go_top = False - self.alltorrentmode.resume() + 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): @@ -387,7 +361,7 @@ class AddTorrents(BaseMode, component.Component): def _show_add_dialog(self): - def _do_add(result): + def _do_add(result, **kwargs): ress = {"succ": 0, "fail": 0, "total": len(self.marked), "fmsg": []} def fail_cb(msg, t_file, ress): @@ -395,14 +369,14 @@ class AddTorrents(BaseMode, component.Component): ress["fail"] += 1 ress["fmsg"].append("{!input!} * %s: {!error!}%s" % (t_file, msg)) if (ress["succ"] + ress["fail"]) >= ress["total"]: - self.alltorrentmode._report_add_status(ress["succ"], ress["fail"], ress["fmsg"]) + 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"]: - self.alltorrentmode._report_add_status(ress["succ"], ress["fail"], ress["fmsg"]) + report_add_status(component.get("TorrentList"), ress["succ"], ress["fail"], ress["fmsg"]) else: fail_cb("Already in session (probably)", t_file, ress) @@ -413,21 +387,20 @@ class AddTorrents(BaseMode, component.Component): with open(path) as _file: filedump = base64.encodestring(_file.read()) t_options = {} - if result["location"]: - t_options["download_location"] = result["location"] - t_options["add_paused"] = result["add_paused"] + 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(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["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 - dl = config["download_location"] if config["add_paused"]: ap = 0 else: @@ -445,8 +418,8 @@ class AddTorrents(BaseMode, component.Component): self.popup.add_text(msg) self.popup.add_spaces(1) - self.popup.add_text_input("Download Folder:", "location", dl) - self.popup.add_select_input("Add Paused:", "add_paused", ["Yes", "No"], [True, False], ap) + 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 @@ -469,7 +442,7 @@ class AddTorrents(BaseMode, component.Component): self.refresh() return - if c > 31 and c < 256: + if util.is_printable_char(c): if chr(c) == "Q": from twisted.internet import reactor if client.connected(): @@ -487,14 +460,12 @@ class AddTorrents(BaseMode, component.Component): if c == curses.KEY_UP: self.scroll_list_up(1) elif c == curses.KEY_PPAGE: - # self.scroll_list_up(self._listing_space-2) 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._listing_space-2) self.scroll_list_down(self.rows // 2) elif c == curses.KEY_END: self.scroll_list_down(len(self.formatted_rows)) @@ -503,14 +474,12 @@ class AddTorrents(BaseMode, component.Component): self._enter_dir() elif c == curses.KEY_LEFT: self._go_up() - # Enter Key - elif c == curses.KEY_ENTER or c == 10: + elif c in [curses.KEY_ENTER, util.KEY_ENTER2]: self._perform_action() - # Escape - elif c == 27: + elif c == util.KEY_ESC: self.back_to_overview() else: - if c > 31 and c < 256: + if util.is_printable_char(c): if chr(c) == "h": self.popup = MessagePopup(self, "Help", HELP_STR, width_req=0.75) elif chr(c) == ">": diff --git a/deluge/ui/console/modes/alltorrents.py b/deluge/ui/console/modes/alltorrents.py deleted file mode 100644 index 7345d31ad..000000000 --- a/deluge/ui/console/modes/alltorrents.py +++ /dev/null @@ -1,1305 +0,0 @@ -# -*- coding: utf-8 -*- -# -# 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 __future__ import division - -import logging -from collections import deque - -import deluge.common -import deluge.component as component -import deluge.ui.console.modes.column -from deluge.configmanager import ConfigManager -from deluge.ui.client import client -from deluge.ui.console.modes import format_utils -from deluge.ui.console.modes.addtorrents import AddTorrents -from deluge.ui.console.modes.basemode import BaseMode -from deluge.ui.console.modes.eventview import EventView -from deluge.ui.console.modes.input_popup import InputPopup -from deluge.ui.console.modes.legacy import Legacy -from deluge.ui.console.modes.popup import MessagePopup, Popup, SelectablePopup -from deluge.ui.console.modes.preferences import Preferences -from deluge.ui.console.modes.torrent_actions import ACTION, torrent_actions_popup -from deluge.ui.console.modes.torrentdetail import TorrentDetail - -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!} - Show this help - -{!info!}'p'{!normal!} - View/Set preferences -{!info!}'l'{!normal!} - Enter Legacy mode(command line mode) -{!info!}'e'{!normal!} - Show the event log view ({!info!}'q'{!normal!} to go back to overview) - -{!info!}'a'{!normal!} - Add a torrent -{!info!}Delete{!normal!} - Delete a torrent - -{!info!}'/'{!normal!} - 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!} - Show only torrents in a certain state - (Will open a popup where you can select the state you want to see) - -{!info!}'i'{!normal!} - Show more detailed information about the currently selected torrent - -{!info!}Enter{!normal!} - 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!} - 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!} - quit deluge-console - -{!info!}'m'{!normal!} - Mark a torrent -{!info!}'M'{!normal!} - Mark all torrents between currently selected torrent and last marked torrent -{!info!}'c'{!normal!} - Clear selection - -{!info!}'v'{!normal!} - Show a dialog which allows you to choose columns to display -{!info!}'<'/'>'{!normal!} - Change column by which to sort torrents - -{!info!}Right Arrow{!normal!} - 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!} - Close a popup (Note that Esc can take a moment to register \ - as having been pressed and 'q' does not work for dialogs where you\ - input something -""" - -STATE_FILTER = { - "all": 0, - "active": 1, - "downloading": 2, - "seeding": 3, - "paused": 4, - "checking": 5, - "error": 6, - "queued": 7, - "allocating": 8, - "moving": 9 -} - -DEFAULT_PREFS = { - "show_queue": True, - "show_size": True, - "show_state": False, - "show_progress": True, - "show_seeds": False, - "show_peers": False, - "show_downspeed": True, - "show_upspeed": True, - "show_eta": True, - "show_ratio": False, - "show_avail": False, - "show_added": False, - "show_tracker": False, - "show_savepath": False, - "show_downloaded": False, - "show_uploaded": False, - "show_remaining": False, - "show_owner": False, - "show_downloading_time": False, - "show_seeding_time": False, - "show_completed": False, - "show_seeds_peers_ratio": False, - "show_complete_seen": False, - "show_down_limit": False, - "show_up_limit": False, - "show_shared": False, - "queue_width": 4, - "name_width": -1, - "size_width": 8, - "state_width": 13, - "progress_width": 7, - "seeds_width": 10, - "peers_width": 10, - "downspeed_width": 7, - "upspeed_width": 7, - "eta_width": 8, - "ratio_width": 10, - "avail_width": 10, - "added_width": 15, - "tracker_width": 15, - "savepath_width": 15, - "downloaded_width": 13, - "uploaded_width": 13, - "remaining_width": 13, - "owner_width": 10, - "downloading_time_width": 10, - "seeding_time_width": 10, - "completed_width": 15, - "seeds_peers_ratio_width": 10, - "complete_seen_width": 15, - "down_limit_width": 7, - "up_limit_width": 7, - "shared_width": 10, - "ignore_duplicate_lines": False, - "move_selection": True, - "third_tab_lists_all": False, - "torrents_per_tab_press": 15, - "sort_primary": "queue", - "sort_secondary": "name", - "separate_complete": True, - "ring_bell": False, - "save_legacy_history": True, - "first_run": True, - "addtorrents_show_misc_files": False, # TODO: Showing/hiding this - "addtorrents_show_hidden_folders": False, # TODO: Showing/hiding this - "addtorrents_sort_column": "date", - "addtorrents_reverse_sort": True, - "addtorrents_last_path": "~" -} - -column_pref_names = [ - "queue", "name", "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", -] - -prefs_to_names = { - "queue": "#", - "name": "Name", - "size": "Size", - "state": "State", - "progress": "Progress", - "seeds": "Seeds", - "peers": "Peers", - "downspeed": "Down Speed", - "upspeed": "Up Speed", - "eta": "ETA", - "ratio": "Ratio", - "avail": "Avail", - "added": "Added", - "tracker": "Tracker", - "savepath": "Download Folder", - "downloaded": "Downloaded", - "uploaded": "Uploaded", - "remaining": "Remaining", - "owner": "Owner", - "seeding_time": "Seeding Time", - "downloading_time": "Active Time", - "complete_seen": "Complete Seen", - "completed": "Completed", - "seeds_peers_ratio": "Seeds:Peers", - "down_limit": "Down Limit", - "up_limit": "Up Limit", - "shared": "Shared" -} - -column_names_to_state_keys = { - "size": "total_wanted", - "downspeed": "download_payload_rate", - "upspeed": "upload_payload_rate", - "seeds": "num_seeds", - "peers": "num_peers", - "avail": "distributed_copies", - "added": "time_added", - "tracker": "tracker_host", - "savepath": "download_location", - "uploaded": "total_uploaded", - "downloaded": "all_time_download", - "remaining": "total_remaining", - "seeding_time": "seeding_time", - "downloading_time": "active_time", - "complete_seen": "last_seen_complete", - "completed": "completed_time", - "seeds_peers_ratio": "seeds_peers_ratio", - "down_limit": "max_download_speed", - "up_limit": "max_upload_speed", - "shared": "shared" -} - -reverse_sort_fields = [ - "total_wanted", - "download_payload_rate", - "upload_payload_rate", - "num_seeds", - "num_peers", - "distributed_copies", - "time_added", - "total_uploaded", - "all_time_download", - "total_remaining", - "progress", - "ratio", - "seeding_time", - "active_time" -] - -SEARCH_EMPTY = 0 -SEARCH_FAILING = 1 -SEARCH_SUCCESS = 2 -SEARCH_START_REACHED = 3 -SEARCH_END_REACHED = 4 - - -class AllTorrents(BaseMode, component.Component): - def __init__(self, stdscr, encoding=None): - self.torrent_names = None - self.numtorrents = -1 - self._cached_rows = {} - self.cursel = 1 - self.curoff = 1 # TODO: this should really be 0 indexed - self.column_string = "" - self.popup = None - self.messages = deque() - self.marked = [] - self.last_mark = -1 - self._sorted_ids = None - self._go_top = False - - self._curr_filter = None - self.entering_search = False - self.search_string = None - self.search_state = SEARCH_EMPTY - - self.coreconfig = component.get("ConsoleUI").coreconfig - - self.legacy_mode = None - - self.__status_dict = {} - self.__torrent_info_id = None - - BaseMode.__init__(self, stdscr, encoding) - component.Component.__init__(self, "AllTorrents", 1, depend=["SessionProxy"]) - curses.curs_set(0) - self.stdscr.notimeout(0) - - self.update_config() - - component.start(["AllTorrents"]) - - self._info_fields = [ - ("Name", None, ("name",)), - ("State", None, ("state",)), - ("Down Speed", format_utils.format_speed, ("download_payload_rate",)), - ("Up Speed", format_utils.format_speed, ("upload_payload_rate",)), - ("Progress", format_utils.format_progress, ("progress",)), - ("ETA", deluge.common.ftime, ("eta",)), - ("Download Folder", None, ("download_location",)), - ("Downloaded", deluge.common.fsize, ("all_time_download",)), - ("Uploaded", deluge.common.fsize, ("total_uploaded",)), - ("Share Ratio", format_utils.format_float, ("ratio",)), - ("Seeds", format_utils.format_seeds_peers, ("num_seeds", "total_seeds")), - ("Peers", format_utils.format_seeds_peers, ("num_peers", "total_peers")), - ("Active Time", deluge.common.ftime, ("active_time",)), - ("Seeding Time", deluge.common.ftime, ("seeding_time",)), - ("Complete Seen", format_utils.format_date_never, ("last_seen_complete",)), - ("Date Added", format_utils.format_time, ("time_added",)), - ("Completed", format_utils.format_date, ("completed_time",)), - ("Availability", format_utils.format_float, ("distributed_copies",)), - ("Pieces", format_utils.format_pieces, ("num_pieces", "piece_length")), - ("Seed Rank", str, ("seed_rank",)), - ] - - self.__status_keys = [ - "name", "state", "download_payload_rate", "upload_payload_rate", - "progress", "eta", "download_location", "all_time_download", "total_uploaded", - "ratio", "num_seeds", "total_seeds", "num_peers", "total_peers", - "active_time", "seeding_time", "last_seen_complete", "time_added", - "completed_time", "distributed_copies", "num_pieces", "piece_length", - "seed_rank" - ] - - self.legacy_mode = Legacy(self.stdscr, self.encoding) - - if self.config["first_run"]: - self.popup = MessagePopup(self, "Welcome to Deluge", HELP_STR, width_req=0.75) - self.config["first_run"] = False - self.config.save() - - # component start/update - def start(self): - component.get("SessionProxy").get_torrents_status( - self.__status_dict, self.__status_fields).addCallback(self.set_state, False) - - def update(self): - component.get("SessionProxy").get_torrents_status( - self.__status_dict, self.__status_fields).addCallback(self.set_state, True) - if self.__torrent_info_id: - component.get("SessionProxy").get_torrent_status( - self.__torrent_info_id, self.__status_keys).addCallback(self._on_torrent_status) - - def update_config(self): - self.config = ConfigManager("console.conf", DEFAULT_PREFS) - s_primary = self.config["sort_primary"] - s_secondary = self.config["sort_secondary"] - self.__cols_to_show = [pref for pref in column_pref_names - if ("show_%s" % pref) not in self.config or self.config["show_%s" % pref]] - - self.__columns = [prefs_to_names[col] for col in self.__cols_to_show] - self.__status_fields = deluge.ui.console.modes.column.get_required_fields(self.__columns) - - # 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 resume(self): - component.start(["AllTorrents"]) - self.refresh() - - def __update_columns(self): - self.column_widths = [self.config["%s_width" % c] for c in self.__cols_to_show] - requested_width = sum([width for width in self.column_widths if width >= 0]) - if requested_width > self.cols: # can't satisfy requests, just spread out evenly - cw = self.cols // len(self.__columns) - for i in range(0, len(self.column_widths)): - self.column_widths[i] = cw - else: - rem = self.cols - requested_width - var_cols = len([width for width in self.column_widths if width < 0]) - if var_cols > 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 = "{!header!}" - - try: - primary_sort_col_name = prefs_to_names[self.config["sort_primary"]] - except KeyError: - primary_sort_col_name = "" - - for i, column in enumerate(self.__columns): - ccol = column - 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.__columns) - 1: - ccol = "{!black,green,bold!}%s{!header!}" % ccol - else: - ccol = ("{!black,green,bold!}%s" % ccol)[:-1] - - self.column_string += ccol - - def set_state(self, state, refresh): - self.curstate = state # cache in case we change sort order - newnames = [] - self._cached_rows = {} - self._sorted_ids = self._sort_torrents(self.curstate) - for torrent_id in self._sorted_ids: - ts = self.curstate[torrent_id] - newnames.append(ts["name"]) - - self.numtorrents = len(state) - self.torrent_names = newnames - if refresh: - self.refresh() - - def get_torrent_name(self, torrent_id): - for p, i in enumerate(self._sorted_ids): - if torrent_id == i: - return self.torrent_names[p] - return None - - def _scroll_up(self, by): - prevoff = self.curoff - self.cursel = max(self.cursel - by, 1) - if (self.cursel - 1) < self.curoff: - self.curoff = max(self.cursel - 1, 1) - return prevoff != self.curoff - - def _scroll_down(self, by): - prevoff = self.curoff - self.cursel = min(self.cursel + by, self.numtorrents) - if (self.curoff + self.rows - 5) < self.cursel: - self.curoff = self.cursel - self.rows + 5 - return prevoff != self.curoff - - def current_torrent_id(self): - if self._sorted_ids: - return self._sorted_ids[self.cursel - 1] - else: - return None - - def _selected_torrent_ids(self): - ret = [] - for i in self.marked: - ret.append(self._sorted_ids[i - 1]) - return ret - - def _on_torrent_status(self, state): - if self.popup: - self.popup.clear() - name = state["name"] - self.popup.set_title(name) - for i, f in enumerate(self._info_fields): - if f[1] is not None: - args = [] - try: - for key in f[2]: - args.append(state[key]) - except Exception as ex: - log.debug("Could not get info field: %s", ex) - continue - info = f[1](*args) - else: - info = state[f[2][0]] - - nl = len(f[0]) + 4 - if (nl + len(info)) > self.popup.width: - self.popup.add_line("{!info!}%s: {!input!}%s" % (f[0], info[:(self.popup.width - nl)])) - info = info[(self.popup.width - nl):] - n = self.popup.width - 3 - chunks = [info[i:i + n] for i in xrange(0, len(info), n)] - for c in chunks: - self.popup.add_line(" %s" % c) - else: - self.popup.add_line("{!info!}%s: {!input!}%s" % (f[0], info)) - self.refresh() - else: - self.__torrent_info_id = None - - def on_resize(self, *args): - BaseMode.on_resize_norefresh(self, *args) - if self.popup: - self.popup.handle_resize() - - self.update() - self.__update_columns() - - self.refresh([]) - - def _queue_sort(self, v1, v2): - if v1 == v2: - return 0 - if v2 < 0: - return -1 - if v1 < 0: - return 1 - if v1 > v2: - return 1 - if v2 > v1: - return -1 - - def _sort_torrents(self, state): - "sorts by primary and secondary sort fields" - - if not state: - return {} - - s_primary = self.config["sort_primary"] - s_secondary = self.config["sort_secondary"] - - result = state - - # Sort first by secondary sort field and then primary sort field - # so it all works out - - cmp_func = self._queue_sort - - sg = state.get - - def sort_by_field(state, result, field): - if field in column_names_to_state_keys: - field = column_names_to_state_keys[field] - - 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[state.keys()[0]] - if field in first_element: - is_string = isinstance(first_element[field], basestring) - - def sort_key(s): - return sg(s)[field] - - def sort_key2(s): - return sg(s)[field].lower() - - # If it's a string, sort case-insensitively but preserve A>a order - if is_string: - result = sorted(result, cmp_func, sort_key, reverse) - result = sorted(result, cmp_func, sort_key2, reverse) - else: - result = sorted(result, cmp_func, sort_key, reverse) - - if field == "eta": - result = sorted(result, key=lambda s: state.get(s)["eta"] == 0) - - return result - - # 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["separate_complete"]: - result = sorted(result, cmp_func, lambda s: state.get(s)["progress"] == 100.0) - - return result - - def _format_queue(self, qnum): - if qnum >= 0: - return "%d" % (qnum + 1) - else: - return "" - - def show_addtorrents_screen(self): - def dodeets(arg): - if arg and True in arg[0]: - self.stdscr.erase() - component.get("ConsoleUI").set_mode(AddTorrents(self, self.stdscr, self.config, self.encoding)) - else: - self.messages.append(("Error", "An error occurred trying to display add torrents screen")) - component.stop(["AllTorrents"]).addCallback(dodeets) - - def show_torrent_details(self, tid): - def dodeets(arg): - if arg and True in arg[0]: - self.stdscr.erase() - component.get("ConsoleUI").set_mode(TorrentDetail(self, tid, self.stdscr, self.config, self.encoding)) - else: - self.messages.append(("Error", "An error occurred trying to display torrent details")) - component.stop(["AllTorrents"]).addCallback(dodeets) - - def show_preferences(self): - def _on_get_config(config): - client.core.get_listen_port().addCallback(_on_get_listen_port, config) - - def _on_get_listen_port(port, config): - client.core.get_cache_status().addCallback(_on_get_cache_status, port, config) - - def _on_get_cache_status(status, port, config): - def doprefs(arg): - if arg and True in arg[0]: - self.stdscr.erase() - component.get("ConsoleUI").set_mode(Preferences(self, config, self.config, port, - status, self.stdscr, self.encoding)) - else: - self.messages.append(("Error", "An error occurred trying to display preferences")) - component.stop(["AllTorrents"]).addCallback(doprefs) - - client.core.get_config().addCallback(_on_get_config) - - def __show_events(self): - def doevents(arg): - if arg and True in arg[0]: - self.stdscr.erase() - component.get("ConsoleUI").set_mode(EventView(self, self.stdscr, self.encoding)) - else: - self.messages.append(("Error", "An error occurred trying to display events")) - component.stop(["AllTorrents"]).addCallback(doevents) - - def __legacy_mode(self): - def dolegacy(arg): - if arg and True in arg[0]: - self.stdscr.erase() - component.get("ConsoleUI").set_mode(self.legacy_mode) - self.legacy_mode.refresh() - curses.curs_set(2) - else: - self.messages.append(("Error", "An error occurred trying to switch to legacy mode")) - component.stop(["AllTorrents"]).addCallback(dolegacy) - - def _torrent_filter(self, idx, data): - if data == STATE_FILTER["all"]: - self.__status_dict = {} - self._curr_filter = None - elif data == STATE_FILTER["active"]: - self.__status_dict = {"state": "Active"} - self._curr_filter = "Active" - elif data == STATE_FILTER["downloading"]: - self.__status_dict = {"state": "Downloading"} - self._curr_filter = "Downloading" - elif data == STATE_FILTER["seeding"]: - self.__status_dict = {"state": "Seeding"} - self._curr_filter = "Seeding" - elif data == STATE_FILTER["paused"]: - self.__status_dict = {"state": "Paused"} - self._curr_filter = "Paused" - elif data == STATE_FILTER["checking"]: - self.__status_dict = {"state": "Checking"} - self._curr_filter = "Checking" - elif data == STATE_FILTER["error"]: - self.__status_dict = {"state": "Error"} - self._curr_filter = "Error" - elif data == STATE_FILTER["queued"]: - self.__status_dict = {"state": "Queued"} - self._curr_filter = "Queued" - elif data == STATE_FILTER["allocating"]: - self.__status_dict = {"state": "Allocating"} - self._curr_filter = "Allocating" - elif data == STATE_FILTER["moving"]: - self.__status_dict = {"state": "Moving"} - self._curr_filter = "Moving" - - self._go_top = True - return True - - def _show_torrent_filter_popup(self): - self.popup = SelectablePopup(self, "Filter Torrents", self._torrent_filter) - self.popup.add_line("_All", data=STATE_FILTER["all"]) - self.popup.add_line("Ac_tive", data=STATE_FILTER["active"]) - self.popup.add_line("_Downloading", data=STATE_FILTER["downloading"], foreground="green") - self.popup.add_line("_Seeding", data=STATE_FILTER["seeding"], foreground="cyan") - self.popup.add_line("_Paused", data=STATE_FILTER["paused"]) - self.popup.add_line("_Error", data=STATE_FILTER["error"], foreground="red") - self.popup.add_line("_Checking", data=STATE_FILTER["checking"], foreground="blue") - self.popup.add_line("Q_ueued", data=STATE_FILTER["queued"], foreground="yellow") - self.popup.add_line("A_llocating", data=STATE_FILTER["allocating"], foreground="yellow") - self.popup.add_line("_Moving", data=STATE_FILTER["moving"], foreground="green") - - def _report_add_status(self, succ_cnt, fail_cnt, fail_msgs): - if fail_cnt == 0: - self.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 - self.report_message("Torrent Add Report", msg) - - def _show_torrent_add_popup(self): - - def do_add_from_url(result): - def fail_cb(msg, url): - log.debug("failed to add torrent: %s: %s", url, msg) - error_msg = "{!input!} * %s: {!error!}%s" % (url, msg) - self._report_add_status(0, 1, [error_msg]) - - def success_cb(tid, url): - if tid: - log.debug("added torrent: %s (%s)", url, tid) - self._report_add_status(1, 0, []) - else: - fail_cb("Already in session (probably)", url) - - url = result["url"] - - if not url: - return - - t_options = { - "download_location": result["path"], - "add_paused": result["add_paused"] - } - - 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: - self.messages.append(("Error", "{!error!}Invalid URL or magnet link: %s" % url)) - return - - log.debug("Adding Torrent(s): %s (dl path: %s) (paused: %d)", url, result["path"], result["add_paused"]) - - def show_add_url_popup(): - try: - dl = self.coreconfig["download_location"] - except KeyError: - dl = "" - - ap = 1 - - try: - if self.coreconfig["add_paused"]: - ap = 0 - except KeyError: - pass - - self.popup = InputPopup(self, "Add Torrent (Esc to cancel)", close_cb=do_add_from_url) - self.popup.add_text_input("Enter torrent URL or Magnet link:", "url") - self.popup.add_text_input("Enter save path:", "path", dl) - self.popup.add_select_input("Add Paused:", "add_paused", ["Yes", "No"], [True, False], ap) - - def option_chosen(index, data): - self.popup = None - - if not data: - return - if data == 1: - self.show_addtorrents_screen() - elif data == 2: - show_add_url_popup() - - self.popup = SelectablePopup(self, "Add torrent", option_chosen) - self.popup.add_line("From _File(s)", data=1) - self.popup.add_line("From _URL or Magnet", data=2) - self.popup.add_line("_Cancel", data=0) - - def _do_set_column_visibility(self, data): - for key, value in data.items(): - self.config[key] = value - self.config.save() - self.update_config() - self.__update_columns() - self.refresh([]) - - def _show_visible_columns_popup(self): - title = "Visible columns (Enter to exit)" - self.popup = InputPopup(self, title, close_cb=self._do_set_column_visibility, - immediate_action=True, height_req=len(column_pref_names) + 1, - width_req=max([len(col) for col in column_pref_names + [title]]) + 8) - - for col in column_pref_names: - name = prefs_to_names[col] - prop = "show_%s" % col - if prop not in self.config: - continue - state = self.config[prop] - - self.popup.add_checked_input(name, prop, state) - - def report_message(self, title, message): - self.messages.append((title, message)) - - def clear_marks(self): - self.marked = [] - self.last_mark = -1 - - def set_popup(self, pu): - self.popup = pu - self.refresh() - - def refresh(self, lines=None): - # Something has requested we scroll to the top of the list - if self._go_top: - self.cursel = 1 - self.curoff = 1 - self._go_top = False - - # show a message popup if there's anything queued - if self.popup is None and self.messages: - title, msg = self.messages.popleft() - self.popup = MessagePopup(self, title, msg, width_req=1.0) - - if not lines: - if component.get("ConsoleUI").screen != self: - return - self.stdscr.erase() - - # Update the status bars - if self._curr_filter is None: - self.add_string(0, self.statusbars.topbar) - else: - self.add_string(0, "%s {!filterstatus!}Current filter: %s" % (self.statusbars.topbar, self._curr_filter)) - self.add_string(1, self.column_string) - - if self.entering_search: - string = { - 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)" - }[self.search_state] % self.search_string - - self.add_string(self.rows - 1, string) - else: - # This will quite likely fail when switching modes - try: - rf = format_utils.remove_formatting - string = self.statusbars.bottombar - hstr = "Press {!magenta,blue,bold!}[h]{!status!} for help" - - string += " " * (self.cols - len(rf(string)) - len(rf(hstr))) + hstr - - self.add_string(self.rows - 1, string) - except Exception: - pass - - # add all the torrents - if self.numtorrents == 0: - msg = "No torrents match filter".center(self.cols) - self.add_string(3, "{!info!}%s" % msg) - elif self.numtorrents > 0: - tidx = self.curoff - currow = 2 - - # Because dots are slow - sorted_ids = self._sorted_ids - curstate = self.curstate - gcv = deluge.ui.console.modes.column.get_column_value - fr = format_utils.format_row - cols = self.__columns - colw = self.column_widths - cr = self._cached_rows - - def draw_row(index): - if index not in cr: - ts = curstate[sorted_ids[index]] - cr[index] = (fr([gcv(name, ts) for name in cols], colw), ts["state"]) - return cr[index] - - if lines: - todraw = [] - for l in lines: - if l < tidx - 1: - continue - if l >= tidx - 1 + self.rows - 3: - break - if l >= self.numtorrents: - break - todraw.append(draw_row(l)) - lines.reverse() - else: - todraw = [] - for i in range(tidx - 1, tidx - 1 + self.rows - 3): - if i >= self.numtorrents: - break - todraw += [draw_row(i)] - - for row in todraw: - # default style - fg = "white" - bg = "black" - attr = None - if lines: - tidx = lines.pop() + 1 - currow = tidx - self.curoff + 2 - - if tidx in self.marked: - bg = "blue" - attr = "bold" - - if tidx == self.cursel: - bg = "white" - attr = "bold" - if tidx in self.marked: - fg = "blue" - else: - fg = "black" - - if row[1] == "Downloading": - fg = "green" - elif row[1] == "Seeding": - fg = "cyan" - elif row[1] == "Error": - fg = "red" - elif row[1] == "Queued": - fg = "yellow" - elif row[1] == "Checking": - fg = "blue" - elif row[1] == "Moving": - fg = "green" - - if self.entering_search and len(self.search_string) > 1: - lcase_name = self.torrent_names[tidx - 1].lower() - sstring_lower = self.search_string.lower() - if lcase_name.find(sstring_lower) != -1: - if tidx == self.cursel: - pass - elif tidx in self.marked: - bg = "magenta" - else: - bg = "green" - if fg == "green": - fg = "black" - attr = "bold" - - if attr: - colorstr = "{!%s,%s,%s!}" % (fg, bg, attr) - else: - colorstr = "{!%s,%s!}" % (fg, bg) - - try: - self.add_string(currow, "%s%s" % (colorstr, row[0]), trim=False) - except Exception: - # XXX: Yeah, this should be fixed in some better way - pass - tidx += 1 - currow += 1 - if currow > (self.rows - 2): - break - else: - self.add_string(1, "Waiting for torrents from core...") - - # self.stdscr.redrawwin() - if self.entering_search: - curses.curs_set(2) - self.stdscr.move(self.rows - 1, len(self.search_string) + 17) - else: - curses.curs_set(0) - - if component.get("ConsoleUI").screen != self: - return - - self.stdscr.noutrefresh() - - if self.popup: - self.popup.refresh() - - curses.doupdate() - - 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 __search_match_count(self): - match_count = 0 - - search_string = self.search_string.lower() - - for n in self.torrent_names: - n = n.lower() - if n.find(search_string) != -1: - match_count += 1 - - return match_count - - def __do_search(self, direction="first", skip=0): - """ - Performs a search on visible torrent and sets cursor to the match - - :param string: direction, the direction of search, can be first, last, next or previous - - :returns: Nothing - """ - - if direction == "first": - search_space = enumerate(self.torrent_names) - elif direction == "last": - search_space = enumerate(self.torrent_names) - search_space = list(search_space) - search_space = reversed(search_space) - elif direction == "next": - search_space = enumerate(self.torrent_names) - search_space = list(search_space) - search_space = search_space[self.cursel:] - elif direction == "previous": - search_space = enumerate(self.torrent_names) - search_space = list(search_space)[:self.cursel - 1] - search_space = reversed(search_space) - - search_string = self.search_string.lower() - for i, n in search_space: - n = n.lower() - if n.find(search_string) != -1: - if skip > 0: - skip -= 1 - continue - self.cursel = (i + 1) - if (self.curoff + self.rows - 5) < self.cursel: - self.curoff = self.cursel - self.rows + 5 - elif (self.curoff + 1) > self.cursel: - self.curoff = max(1, self.cursel - 1) - 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 - - def __update_search(self, c): - cname = self.torrent_names[self.cursel - 1] - if c == curses.KEY_BACKSPACE or c == 127: - 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.entering_search = False - self.search_state = SEARCH_EMPTY - - self.refresh([]) - - elif c == curses.KEY_DC: - self.search_string = "" - self.search_state = SEARCH_SUCCESS - self.refresh([]) - - elif c == curses.KEY_UP: - self.__do_search("previous") - self.refresh([]) - - elif c == curses.KEY_DOWN: - self.__do_search("next") - self.refresh([]) - - elif c == curses.KEY_LEFT: - self.entering_search = False - self.search_state = SEARCH_EMPTY - self.refresh([]) - - elif c == ord("/"): - self.entering_search = False - self.search_state = SEARCH_EMPTY - self.refresh([]) - - elif c == curses.KEY_RIGHT: - tid = self.current_torrent_id() - self.show_torrent_details(tid) - - elif c == curses.KEY_HOME: - self.__do_search("first") - self.refresh([]) - - elif c == curses.KEY_END: - self.__do_search("last") - self.refresh([]) - - elif c in [10, curses.KEY_ENTER]: - self.last_mark = -1 - tid = self.current_torrent_id() - torrent_actions_popup(self, [tid], details=True) - - elif c == 27: - self.search_string = "" - self.search_state = SEARCH_EMPTY - self.refresh([]) - - elif c > 31 and c < 256: - old_search_string = self.search_string - stroke = chr(c) - uchar = "" - while not uchar: - try: - uchar = stroke.decode(self.encoding) - except UnicodeDecodeError: - c = self.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 - self.refresh([]) - - if not self.search_string: - self.search_state = SEARCH_EMPTY - self.refresh([]) - - def read_input(self): - # Read the character - effected_lines = None - - c = self.stdscr.getch() - - if self.popup: - if self.popup.handle_read(c): - self.popup = None - self.refresh() - return - - if c > 31 and c < 256: - if chr(c) == "Q": - from twisted.internet import reactor - if client.connected(): - def on_disconnect(result): - reactor.stop() - client.disconnect().addCallback(on_disconnect) - else: - reactor.stop() - return - - if self.numtorrents < 0: - return - - elif self.entering_search: - self.__update_search(c) - return - - if c == curses.KEY_UP: - if self.cursel == 1: - return - if not self._scroll_up(1): - effected_lines = [self.cursel - 1, self.cursel] - elif c == curses.KEY_PPAGE: - self._scroll_up(self.rows // 2) - elif c == curses.KEY_DOWN: - if self.cursel >= self.numtorrents: - return - if not self._scroll_down(1): - effected_lines = [self.cursel - 2, self.cursel - 1] - elif c == curses.KEY_NPAGE: - self._scroll_down(self.rows // 2) - elif c == curses.KEY_HOME: - self._scroll_up(self.cursel) - elif c == curses.KEY_END: - self._scroll_down(self.numtorrents - self.cursel) - elif c == curses.KEY_DC: - if self.cursel not in self.marked: - self.marked.append(self.cursel) - self.last_mark = self.cursel - torrent_actions_popup(self, self._selected_torrent_ids(), action=ACTION.REMOVE) - - elif c == curses.KEY_RIGHT: - # We enter a new mode for the selected torrent here - tid = self.current_torrent_id() - if tid: - self.show_torrent_details(tid) - return - - # Enter Key - elif (c == curses.KEY_ENTER or c == 10) and self.numtorrents: - if self.cursel not in self.marked: - self.marked.append(self.cursel) - self.last_mark = self.cursel - torrent_actions_popup(self, self._selected_torrent_ids(), details=True) - return - else: - if c > 31 and c < 256: - if chr(c) == "/": - self.search_string = "" - self.entering_search = True - elif chr(c) == "n" and self.search_string: - self.__do_search("next") - elif chr(c) == "j": - if not self._scroll_up(1): - effected_lines = [self.cursel - 1, self.cursel] - elif chr(c) == "k": - if not self._scroll_down(1): - effected_lines = [self.cursel - 2, self.cursel - 1] - elif chr(c) == "i": - cid = self.current_torrent_id() - if cid: - def cb(): - self.__torrent_info_id = None - self.popup = Popup(self, "Info", close_cb=cb, height_req=20) - self.popup.add_line("Getting torrent info...") - self.__torrent_info_id = cid - elif chr(c) == "m": - self._mark_unmark(self.cursel) - effected_lines = [self.cursel - 1] - elif chr(c) == "M": - if self.last_mark >= 0: - if (self.cursel + 1) > self.last_mark: - mrange = range(self.last_mark, self.cursel + 1) - else: - mrange = range(self.cursel - 1, self.last_mark) - self.marked.extend(mrange[1:]) - effected_lines = mrange - else: - self._mark_unmark(self.cursel) - effected_lines = [self.cursel - 1] - elif chr(c) == "c": - self.marked = [] - self.last_mark = -1 - elif chr(c) == "a": - self._show_torrent_add_popup() - elif chr(c) == "v": - self._show_visible_columns_popup() - elif chr(c) == "o": - if not self.marked: - self.marked = [self.cursel] - self.last_mark = self.cursel - else: - self.last_mark = -1 - torrent_actions_popup(self, self._selected_torrent_ids(), action=ACTION.TORRENT_OPTIONS) - - elif chr(c) == "<": - i = len(self.__cols_to_show) - try: - i = self.__cols_to_show.index(self.config["sort_primary"]) - 1 - except KeyError: - pass - - i = max(0, i) - i = min(len(self.__cols_to_show) - 1, i) - - self.config["sort_primary"] = self.__cols_to_show[i] - self.config.save() - self.update_config() - self.__update_columns() - self.refresh([]) - - elif chr(c) == ">": - i = 0 - try: - i = self.__cols_to_show.index(self.config["sort_primary"]) + 1 - except KeyError: - pass - - i = min(len(self.__cols_to_show) - 1, i) - i = max(0, i) - - self.config["sort_primary"] = self.__cols_to_show[i] - self.config.save() - self.update_config() - self.__update_columns() - self.refresh([]) - - elif chr(c) == "f": - self._show_torrent_filter_popup() - elif chr(c) == "h": - self.popup = MessagePopup(self, "Help", HELP_STR, width_req=0.75) - elif chr(c) == "p": - self.show_preferences() - return - elif chr(c) == "e": - self.__show_events() - return - elif chr(c) == "l": - self.__legacy_mode() - return - - self.refresh(effected_lines) diff --git a/deluge/ui/console/modes/basemode.py b/deluge/ui/console/modes/basemode.py index 4400a7651..416554d80 100644 --- a/deluge/ui/console/modes/basemode.py +++ b/deluge/ui/console/modes/basemode.py @@ -11,13 +11,14 @@ import logging import sys -from twisted.internet import reactor - import deluge.component as component -import deluge.ui.console.colors as colors +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 @@ -33,9 +34,53 @@ except ImportError: log = logging.getLogger(__name__) +class InputKeyHandler(object): + + 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(object): + + def __init__(self): + try: + signal.signal(signal.SIGWINCH, self.on_terminal_size) + except ValueError as ex: + log.debug("Unable to catch SIGWINCH signal: %s", ex) + + def on_terminal_size(self, *args): + # Get the new rows and cols value + rows, cols = struct.unpack("hhhh", ioctl(0, termios.TIOCGWINSZ, "\000" * 8))[0:2] + curses.resizeterm(rows, cols) + return rows, cols + + class CursesStdIO(object): - """fake fd to be registered as a reader with the twisted reactor. - Curses classes needing input should extend this""" + """ + 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 """ @@ -49,8 +94,9 @@ class CursesStdIO(object): return "CursesClient" -class BaseMode(CursesStdIO): - def __init__(self, stdscr, encoding=None, do_refresh=True): +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 @@ -71,111 +117,67 @@ class BaseMode(CursesStdIO): self.topbar - top statusbar self.bottombar - bottom statusbar """ - log.debug("BaseMode init!") + 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() - try: - signal.signal(signal.SIGWINCH, self.on_resize) - except Exception: - log.debug("Unable to catch SIGWINCH signal!") if not encoding: self.encoding = sys.getdefaultencoding() else: self.encoding = encoding - colors.init_colors() - # Do a refresh right away to draw the screen if do_refresh: self.refresh() - def on_resize_norefresh(self, *args): - log.debug("on_resize_from_signal") - # Get the new rows and cols value - self.rows, self.cols = struct.unpack("hhhh", ioctl(0, termios.TIOCGWINSZ, "\000" * 8))[0:2] - curses.resizeterm(self.rows, self.cols) - - def on_resize(self, *args): - self.on_resize_norefresh(args) - self.refresh() + def on_resize(self, rows, cols): + self.rows, self.cols = rows, cols def connectionLost(self, reason): # NOQA self.close() - def add_string(self, row, string, scr=None, col=0, pad=True, trim=True): - """ - Adds a string to the desired `:param:row`. - - :param row: int, the row number to write the string - :param string: string, the string of text to add - :param scr: curses.window, optional window to add string to instead of self.stdscr - :param col: int, optional starting column offset - :param pad: bool, optional bool if the string should be padded out to the width of the screen - :param 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!" - - - """ + def add_string(self, row, string, scr=None, **kwargs): if scr: screen = scr else: screen = self.stdscr - try: - parsed = colors.parse_color_string(string, self.encoding) - except colors.BadColorString as ex: - log.error("Cannot add bad color string %s: %s", string, ex) - return - for index, (color, s) in enumerate(parsed): - if index + 1 == len(parsed) and pad: - # This is the last string so lets append some " " to it - s += " " * (self.cols - (col + len(s)) - 1) - if trim: - dummy, x = screen.getmaxyx() - if (col + len(s)) > x: - s = "%s..." % s[0:x - 4 - col] - screen.addstr(row, col, s, color) - col += len(s) + return add_string(row, string, screen, self.encoding, **kwargs) - def draw_statusbars(self): - self.add_string(0, self.statusbars.topbar) - self.add_string(self.rows - 1, self.statusbars.bottombar) - - # This mode doesn't report errors - def report_message(self): - pass + 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 - # This mode doesn't support marking - def clear_marks(self): - 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): """ @@ -199,9 +201,8 @@ class BaseMode(CursesStdIO): # We wrap this function to catch exceptions and shutdown the mainloop try: self.read_input() - except Exception as ex: + except Exception as ex: # pylint: disable=broad-except log.exception(ex) - reactor.stop() def read_input(self): # Read the character @@ -216,3 +217,121 @@ class BaseMode(CursesStdIO): self.stdscr.keypad(0) curses.echo() curses.endwin() + + +def add_string(row, string, 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 + string(str): the 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(string, encoding) + except colors.BadColorString as ex: + log.error("Cannot add bad color string %s: %s", string, ex) + return + + if leaveok: + screen.leaveok(leaveok) + + max_y, max_x = screen.getmaxyx() + for index, (color, s) in enumerate(parsed): + if index + 1 == len(parsed) and pad: + # This is the last string so lets append some " " to it + s += pad_char * (max_x - (col + len(s))) + + # Sometimes the parsed string gives empty elements which may not be printed on max_x + if col == max_x: + break + + if (col + len(s)) > max_x: + if trim: + s = "%s%s" % (s[0:max_x - len(trim) - col], trim) + else: + s = s[0:max_x - col] + + if col + len(s) >= max_x and row == max_y - 1: + # Bug in curses when writing to the lower right corner: https://bugs.python.org/issue8243 + # Use insstr instead which avoids scrolling which is the root cause apparently + screen.insstr(row, col, s, color) + else: + try: + screen.addstr(row, col, s, color) + except curses.error as ex: + import traceback + log.warn("FAILED on call screen.addstr(%s, %s, '%s', %s) - max_y: %s, max_x: %s, " + "curses.LINES: %s, curses.COLS: %s, Error: '%s', trace:\n%s", + row, col, s, color, max_y, max_x, curses.LINES, curses.COLS, ex, + "".join(traceback.format_stack(limit=5))) + + col += len(s) + + 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.warn("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/legacy.py b/deluge/ui/console/modes/cmdline.py similarity index 81% rename from deluge/ui/console/modes/legacy.py rename to deluge/ui/console/modes/cmdline.py index 00e4c217d..2f9a0c3d5 100644 --- a/deluge/ui/console/modes/legacy.py +++ b/deluge/ui/console/modes/cmdline.py @@ -13,22 +13,18 @@ try: except ImportError: pass - import logging import os import re -from twisted.internet import defer - import deluge.component as component import deluge.configmanager -import deluge.ui.console.colors as colors -from deluge.ui.client import client -from deluge.ui.console.commander import Commander -from deluge.ui.console.modes import format_utils -from deluge.ui.console.modes.basemode import BaseMode - -strwidth = format_utils.strwidth +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 curses_util as util +from deluge.ui.console.utils import colors +from deluge.ui.console.utils.format_utils import delete_alt_backspace, remove_formatting, strwidth log = logging.getLogger(__name__) @@ -51,7 +47,7 @@ def complete_line(line, possible_matches): matches2 = [] for match in possible_matches: - match = format_utils.remove_formatting(match) + match = remove_formatting(match) match = match.replace(r"\ ", " ") m1, m2 = "", "" for i, c in enumerate(line): @@ -95,11 +91,9 @@ def commonprefix(m): return s2 -class Legacy(BaseMode, Commander, component.Component): +class CmdLine(BaseMode, Commander): + def __init__(self, stdscr, encoding=None): - - component.Component.__init__(self, "LegacyUI", 1, depend=["SessionProxy"]) - # Get a handle to the main console self.console = component.get("ConsoleUI") Commander.__init__(self, self.console._commands, interactive=True) @@ -112,11 +106,8 @@ class Legacy(BaseMode, Commander, component.Component): self.display_lines_offset = 0 # Holds the user input and is cleared on 'enter' - self.input = "" - self.input_incomplete = "" - self._old_char = 0 - self._last_char = 0 - self._last_del_char = "" + self.input = u"" + self.input_incomplete = u"" # Keep track of where the cursor is self.input_cursor = 0 @@ -127,7 +118,7 @@ class Legacy(BaseMode, Commander, component.Component): # Keep track of double- and multi-tabs self.tab_count = 0 - self.console_config = component.get("AllTorrents").config + 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 @@ -135,12 +126,11 @@ class Legacy(BaseMode, Commander, component.Component): # that we swap around based on length config_dir = deluge.configmanager.get_config_dir() self.history_file = [ - os.path.join(config_dir, "legacy.hist1"), - os.path.join(config_dir, "legacy.hist2") + 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["save_legacy_history"]: + if self.console_config["cmdline"]["save_command_history"]: try: with open(self.history_file[0], "r") as _file: lines1 = _file.read().splitlines() @@ -172,10 +162,10 @@ class Legacy(BaseMode, Commander, component.Component): # if not isinstance(line, unicode): # line = line.encode(self.encoding) # self.lines[i] = line - line = format_utils.remove_formatting(line) + line = remove_formatting(line) if line.startswith(">>> "): console_input = line[4:] - if self.console_config["ignore_duplicate_lines"]: + 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) @@ -184,64 +174,47 @@ class Legacy(BaseMode, Commander, component.Component): self.input_history_index = len(self.input_history) - component.start("LegacyUI") - # show the cursor - curses.curs_set(2) - - BaseMode.__init__(self, stdscr, encoding) - - # This gets fired once we have received the torrents list from the core - self.started_deferred = defer.Deferred() - - # Maintain a list of (torrent_id, name) for use in tab completion - self.torrents = [] - - def on_session_state(result): - def on_torrents_status(torrents): - for torrent_id, status in torrents.items(): - self.torrents.append((torrent_id, status["name"])) - self.started_deferred.callback(True) - - client.core.get_torrents_status({"id": result}, ["name"]).addCallback(on_torrents_status) - client.core.get_session_state().addCallback(on_session_state) - - # Register some event handlers to keep the torrent list up-to-date - client.register_event_handler("TorrentAddedEvent", self.on_torrent_added_event) - client.register_event_handler("TorrentRemovedEvent", self.on_torrent_removed_event) + util.safe_curs_set(util.Curser.VERY_VISIBLE) + BaseMode.__init__(self, stdscr, encoding, depend=["SessionProxy"]) + @overrides(component.Component) def update(self): - if component.get("ConsoleUI").screen != self: + if not component.get("ConsoleUI").is_active_mode(self): return - # Update just the status bars - self.add_string(0, self.statusbars.topbar) - self.add_string(self.rows - 2, self.statusbars.bottombar) + 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() - # An ugly, ugly, UGLY UGLY way to handle alt+backspace - # deleting more characters than it should, but without a more - # complex input handling system, a more elegant solution - # is not viable - if self._old_char == 27 and self._last_char == 127: - self.input += self._last_del_char - self.input_cursor += 1 - self._old_char = self._last_char - self._last_char = c + # 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 != 9: + 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 == curses.KEY_ENTER or c == 10: + if c in [curses.KEY_ENTER, util.KEY_ENTER2]: if self.input: - self.input = self.input.encode(self.encoding) - if self.input.endswith("\\"): self.input = self.input[:-1] self.input_cursor -= 1 @@ -251,7 +224,7 @@ class Legacy(BaseMode, Commander, component.Component): # Remove the oldest input history if the max history size # is reached. del self.input_history[0] - if self.console_config["ignore_duplicate_lines"]: + 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) @@ -260,13 +233,13 @@ class Legacy(BaseMode, Commander, component.Component): else: self.input_history.append(self.input) self.input_history_index = len(self.input_history) - self.input = "" - self.input_incomplete = "" + self.input = u"" + self.input_incomplete = u"" self.input_cursor = 0 self.stdscr.refresh() # Run the tab completer function - elif c == 9: + elif c == util.KEY_TAB: # Keep track of tab hit count to know when it's double-hit self.tab_count += 1 @@ -326,31 +299,13 @@ class Legacy(BaseMode, Commander, component.Component): self.refresh() # Delete a character in the input string based on cursor position - elif c == curses.KEY_BACKSPACE or c == 127: + elif c in [curses.KEY_BACKSPACE, util.KEY_BACKSPACE2]: if self.input and self.input_cursor > 0: - self._last_del_char = self.input[self.input_cursor - 1] 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 == 27: - sep_chars = " *?!._~-#$^;'\"/" - deleted = 0 - seg_start = self.input[:self.input_cursor] - seg_end = self.input[self.input_cursor:] - - while seg_start and self.input_cursor > 0: - if (not seg_start) or (self.input_cursor == 0): - break - if deleted and seg_start[-1] in sep_chars: - break - - seg_start = seg_start[:-1] - deleted += 1 - self.input_cursor -= 1 - - self.input = seg_start + seg_end - + 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:] @@ -378,30 +333,23 @@ class Legacy(BaseMode, Commander, component.Component): # Move the cursor forward self.input_cursor += 1 - # Update the input string on the screen - self.add_string(self.rows - 1, self.input) - try: - self.stdscr.move(self.rows - 1, self.input_cursor) - except curses.error: - pass - self.stdscr.refresh() + self.refresh() - def on_resize(self, *args): - BaseMode.on_resize_norefresh(self, *args) - - # We need to also refresh AllTorrents because otherwise it will - # be only us that get properly resized - all_torrents = component.get("AllTorrents") - all_torrents.on_resize(*args) + @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 @@ -427,16 +375,9 @@ class Legacy(BaseMode, Commander, component.Component): self.add_string(index + 1, line) # Add the input string - self.add_string(self.rows - 1, self.input) + self.add_string(self.rows - 1, self.input, pad=False, trim=False) - if component.get("ConsoleUI").screen != self: - return - - # Move the cursor - try: - self.stdscr.move(self.rows - 1, self.input_cursor) - except curses.error: - pass + move_cursor(self.stdscr, self.rows - 1, min(self.input_cursor, curses.COLS - 1)) self.stdscr.redrawwin() self.stdscr.refresh() @@ -467,7 +408,7 @@ class Legacy(BaseMode, Commander, component.Component): """ - if self.console_config["save_legacy_history"]: + 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: @@ -563,7 +504,7 @@ class Legacy(BaseMode, Commander, component.Component): if refresh: self.refresh() - def add_string(self, row, string): + def _add_string(self, row, string): """ Adds a string to the desired `:param:row`. @@ -663,7 +604,7 @@ class Legacy(BaseMode, Commander, component.Component): if not new_line.endswith("/") and not new_line.endswith(r"\\"): new_line += " " # We only want to print eventual colors or other control characters, not return them - new_line = format_utils.remove_formatting(new_line) + new_line = remove_formatting(new_line) return (new_line, len(new_line)) else: if hits == 1: @@ -676,11 +617,11 @@ class Legacy(BaseMode, Commander, component.Component): new_line = " ".join([p, complete_line(l_arg, possible_matches)]).lstrip() - if len(format_utils.remove_formatting(new_line)) > len(line): + if len(remove_formatting(new_line)) > len(line): line = new_line cursor = len(line) elif hits >= 2: - max_list = self.console_config["torrents_per_tab_press"] + 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 @@ -691,7 +632,7 @@ class Legacy(BaseMode, Commander, component.Component): 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["third_tab_lists_all"]: + 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] @@ -716,7 +657,7 @@ class Legacy(BaseMode, Commander, component.Component): 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 = format_utils.remove_formatting(line) + line = remove_formatting(line) cursor = len(line) return (line, cursor) @@ -819,7 +760,7 @@ class Legacy(BaseMode, Commander, component.Component): match_count = 0 match_count2 = 0 - for torrent_id, torrent_name in self.torrents: + for torrent_id, torrent_name in self.console.torrents: if torrent_id.startswith(line): match_count += 1 if torrent_name.startswith(line): @@ -828,7 +769,7 @@ class Legacy(BaseMode, Commander, component.Component): match_count2 += 1 # Find all possible matches - for torrent_id, torrent_name in self.torrents: + 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(" ", "\\ ") @@ -862,52 +803,3 @@ class Legacy(BaseMode, Commander, component.Component): possible_matches2.append(text) return possible_matches + possible_matches2 - - def get_torrent_name(self, torrent_id): - """ - Gets a torrent name from the torrents list. - - :param torrent_id: str, the torrent_id - - :returns: the name of the torrent or None - """ - - for tid, name in self.torrents: - if torrent_id == tid: - return name - - return None - - 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. - - """ - - if not isinstance(string, unicode): - string = unicode(string, self.encoding) - - ret = [] - for tid, name in self.torrents: - if not isinstance(name, unicode): - name = unicode(name, self.encoding) - if tid.startswith(string) or name.startswith(string): - ret.append(tid) - - return ret - - def on_torrent_added_event(self, event, from_state=False): - def on_torrent_status(status): - self.torrents.append((event, status["name"])) - client.core.get_torrent_status(event, ["name"]).addCallback(on_torrent_status) - - def on_torrent_removed_event(self, event): - for index, (tid, name) in enumerate(self.torrents): - if event == tid: - del self.torrents[index] diff --git a/deluge/ui/console/modes/column.py b/deluge/ui/console/modes/column.py deleted file mode 100644 index 459088306..000000000 --- a/deluge/ui/console/modes/column.py +++ /dev/null @@ -1,78 +0,0 @@ -# -*- coding: utf-8 -*- -# -# 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.console.modes import format_utils - -log = logging.getLogger(__name__) - - -def format_queue(qnum): - if qnum >= 0: - return "%d" % (qnum + 1) - else: - return "" - -columns = { - "#": (("queue",), format_queue), - "Name": (("name",), None), - "Size": (("total_wanted",), deluge.common.fsize), - "State": (("state",), None), - "Progress": (("progress",), format_utils.format_progress), - "Seeds": (("num_seeds", "total_seeds"), format_utils.format_seeds_peers), - "Peers": (("num_peers", "total_peers"), format_utils.format_seeds_peers), - "Down Speed": (("download_payload_rate",), format_utils.format_speed), - "Up Speed": (("upload_payload_rate",), format_utils.format_speed), - "ETA": (("eta",), format_utils.format_time), - "Ratio": (("ratio",), format_utils.format_float), - "Avail": (("distributed_copies",), format_utils.format_float), - "Added": (("time_added",), deluge.common.fdate), - "Tracker": (("tracker_host",), None), - "Download Folder": (("download_location",), None), - "Downloaded": (("all_time_download",), deluge.common.fsize), - "Uploaded": (("total_uploaded",), deluge.common.fsize), - "Remaining": (("total_remaining",), deluge.common.fsize), - "Owner": (("owner",), None), - "Shared": (("shared",), str), - "Active Time": (("active_time",), deluge.common.ftime), - "Seeding Time": (("seeding_time",), deluge.common.ftime), - "Complete Seen": (("last_seen_complete",), format_utils.format_date_never), - "Completed": (("completed_time",), format_utils.format_date), - "Seeds:Peers": (("seeds_peers_ratio",), format_utils.format_float), - "Down Limit": (("max_download_speed",), format_utils.format_speed), - "Up Limit": (("max_upload_speed",), format_utils.format_speed), -} - - -def get_column_value(name, state): - try: - col = columns[name] - except KeyError: - return "Please Wait" - - if col[1]: - try: - args = [state[key] for key in col[0]] - except KeyError: - return "Please Wait" - return col[1](*args) - else: - try: - return state[col[0][0]] - except KeyError: - return "Please Wait" - - -def get_required_fields(cols): - fields = [] - for col in cols: - fields.extend(columns.get(col)[0]) - return fields diff --git a/deluge/ui/console/modes/connectionmanager.py b/deluge/ui/console/modes/connectionmanager.py index 6c1a064b1..90a7f0665 100644 --- a/deluge/ui/console/modes/connectionmanager.py +++ b/deluge/ui/console/modes/connectionmanager.py @@ -7,21 +7,17 @@ # See LICENSE for more details. # -""" A mode that show's a popup to select which host to connect to """ - import hashlib import logging import time -from collections import deque import deluge.component as component from deluge.configmanager import ConfigManager +from deluge.decorators import overrides from deluge.ui import common as uicommon from deluge.ui.client import Client, client -from deluge.ui.console.modes.alltorrents import AllTorrents from deluge.ui.console.modes.basemode import BaseMode -from deluge.ui.console.modes.input_popup import InputPopup -from deluge.ui.console.modes.popup import MessagePopup, SelectablePopup +from deluge.ui.console.widgets.popup import InputPopup, PopupsHandler, SelectablePopup try: import curses @@ -31,36 +27,48 @@ except ImportError: log = logging.getLogger(__name__) -class ConnectionManager(BaseMode): +class ConnectionManager(BaseMode, PopupsHandler): def __init__(self, stdscr, encoding=None): - self.popup = None + PopupsHandler.__init__(self) self.statuses = {} - self.messages = deque() + self.all_torrents = None self.config = ConfigManager("hostlist.conf.1.2", uicommon.DEFAULT_HOSTS) - BaseMode.__init__(self, stdscr, encoding) - self.__update_statuses() - self.__update_popup() + self.update_hosts_status() + BaseMode.__init__(self, stdscr, encoding=encoding) + self.update_select_host_popup() + + def update_select_host_popup(self): + selected_index = None + if self.popup: + selected_index = self.popup.current_selection() + + 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 new host"), _("delete host")), + space_below=True) + self.push_popup(popup, clear=True) - def __update_popup(self): - self.popup = SelectablePopup(self, "Select Host", self.__host_selected) - self.popup.add_line("{!white,black,bold!}'Q'=quit, 'r'=refresh, 'a'=add new host, 'D'=delete host", - selectable=False) for host in self.config["hosts"]: + args = {"data": host[0], "foreground": "red"} + state = "Offline" if host[0] in self.statuses: - self.popup.add_line("%s:%d [Online] (%s)" % (host[1], host[2], self.statuses[host[0]]), - data=host[0], foreground="green") - else: - self.popup.add_line("%s:%d [Offline]" % (host[1], host[2]), data=host[0], foreground="red") + state = "Online" + args.update({"data": self.statuses[host[0]], "foreground": "green"}) + host_str = "%s:%d [%s]" % (host[1], host[2], state) + self.popup.add_line(host[0], host_str, selectable=True, use_underline=True, **args) + + if selected_index is not None: + self.popup.set_selection(selected_index) self.inlist = True self.refresh() - def __update_statuses(self): + def update_hosts_status(self): """Updates the host status""" def on_connect(result, c, host_id): def on_info(info, c): self.statuses[host_id] = info - self.__update_popup() + self.update_select_host_popup() c.disconnect() def on_info_fail(reason, c): @@ -75,6 +83,7 @@ class ConnectionManager(BaseMode): def on_connect_failed(reason, host_id): if host_id in self.statuses: del self.statuses[host_id] + self.update_select_host_popup() for host in self.config["hosts"]: c = Client() @@ -87,28 +96,42 @@ class ConnectionManager(BaseMode): d.addCallback(on_connect, c, host[0]) d.addErrback(on_connect_failed, host[0]) - def __on_connected(self, result): - component.start() - self.stdscr.erase() - at = AllTorrents(self.stdscr, self.encoding) - component.get("ConsoleUI").set_mode(at) - at.resume() + def _on_connected(self, result): + d = component.get("ConsoleUI").start_console() - def __host_selected(self, idx, data): + def on_console_start(result): + component.get("ConsoleUI").set_mode("TorrentList") + d.addCallback(on_console_start) + + def _on_connect_fail(self, result): + self.report_message("Failed to connect!", result) + self.refresh() + if hasattr(result, "getTraceback"): + log.exception(result) + + def _host_selected(self, selected_host, *args, **kwargs): + if selected_host not in self.statuses: + return for host in self.config["hosts"]: - if host[0] == data and host[0] in self.statuses: - client.connect(host[1], host[2], host[3], host[4]).addCallback(self.__on_connected) + if host[0] == selected_host: + d = client.connect(host[1], host[2], host[3], host[4]) + d.addCallback(self._on_connected) + d.addErrback(self._on_connect_fail) + return return False - def __do_add(self, result): - hostname = result["hostname"] + def _do_add(self, result, **kwargs): + if not result or kwargs.get("close", False): + self.pop_popup() + return + hostname = result["hostname"]["value"] try: - port = int(result["port"]) + port = int(result["port"]["value"]) except ValueError: self.report_message("Can't add host", "Invalid port. Must be an integer") return - username = result["username"] - password = result["password"] + username = result["username"]["value"] + password = result["password"]["value"] for host in self.config["hosts"]: if (host[1], host[2], host[3]) == (hostname, port, username): self.report_message("Can't add host", "Host already in list") @@ -116,18 +139,21 @@ class ConnectionManager(BaseMode): newid = hashlib.sha1(str(time.time())).hexdigest() self.config["hosts"].append((newid, hostname, port, username, password)) self.config.save() - self.__update_popup() + self.update_select_host_popup() - def __add_popup(self): + def add_popup(self): self.inlist = False - self.popup = InputPopup(self, "Add Host (up & down arrows to navigate, esc to cancel)", close_cb=self.__do_add) - self.popup.add_text_input("Hostname:", "hostname") - self.popup.add_text_input("Port:", "port") - self.popup.add_text_input("Username:", "username") - self.popup.add_text_input("Password:", "password") + 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", "%s:" % _("Hostname")) + popup.add_text_input("port", "%s:" % _("Port")) + popup.add_text_input("username", "%s:" % _("Username")) + popup.add_text_input("password", "%s:" % _("Password")) + self.push_popup(popup, clear=True) self.refresh() - def __delete_current_host(self): + def delete_current_host(self): idx, data = self.popup.current_selection() log.debug("deleting host: %s", data) for host in self.config["hosts"]: @@ -136,26 +162,42 @@ class ConnectionManager(BaseMode): break self.config.save() - def report_message(self, title, message): - self.messages.append((title, message)) + @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 self.popup is None and self.messages: - title, msg = self.messages.popleft() - self.popup = MessagePopup(self, title, msg) - if not self.popup: - self.__update_popup() + self.update_select_host_popup() self.popup.refresh() curses.doupdate() - def on_resize(self, *args): - BaseMode.on_resize_norefresh(self, *args) + @overrides(BaseMode) + def on_resize(self, rows, cols): + BaseMode.on_resize(self, rows, cols) if self.popup: self.popup.handle_resize() @@ -163,8 +205,8 @@ class ConnectionManager(BaseMode): self.stdscr.erase() self.refresh() + @overrides(BaseMode) def read_input(self): - # Read the character c = self.stdscr.getch() if c > 31 and c < 256: @@ -180,17 +222,15 @@ class ConnectionManager(BaseMode): reactor.stop() return if chr(c) == "D" and self.inlist: - self.__delete_current_host() - self.__update_popup() + self.delete_current_host() + self.update_select_host_popup() return - if chr(c) == "r" and self.inlist: - self.__update_statuses() if chr(c) == "a" and self.inlist: - self.__add_popup() + self.add_popup() return if self.popup: - if self.popup.handle_read(c): - self.popup = None + if self.popup.handle_read(c) and self.popup.closed(): + self.pop_popup() self.refresh() return diff --git a/deluge/ui/console/modes/eventview.py b/deluge/ui/console/modes/eventview.py index 4ed575188..dc7601667 100644 --- a/deluge/ui/console/modes/eventview.py +++ b/deluge/ui/console/modes/eventview.py @@ -10,9 +10,9 @@ import logging import deluge.component as component -from deluge.ui.client import client -from deluge.ui.console.modes import format_utils +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 @@ -23,30 +23,28 @@ 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 - BaseMode.__init__(self, stdscr, encoding) + 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" + """ + This method just shows each line of the event log + """ events = component.get("ConsoleUI").events self.stdscr.erase() - - self.add_string(0, self.statusbars.topbar) - hstr = "%sPress [h] for help" % (" " * (self.cols - len(self.statusbars.bottombar) - 10)) - # This will quite likely fail when switching modes - try: - rf = format_utils.remove_formatting - string = self.statusbars.bottombar - hstr = "Press {!magenta,blue,bold!}[h]{!status!} for help" - - string += " " * (self.cols - len(rf(string)) - len(rf(hstr))) + hstr - - self.add_string(self.rows - 1, string) - except Exception as ex: - log.debug("Exception caught: %s", ex) + self.draw_statusbars() if events: for i, event in enumerate(events): @@ -65,44 +63,22 @@ class EventView(BaseMode): else: self.add_string(1, "{!white,black,bold!}No events to show yet") - if component.get("ConsoleUI").screen != self: + if not component.get("ConsoleUI").is_active_mode(self): return self.stdscr.noutrefresh() curses.doupdate() - def on_resize(self, *args): - BaseMode.on_resize_norefresh(self, *args) - - # Always refresh Legacy(it will also refresh AllTorrents), otherwise it will bug deluge out - legacy = component.get("LegacyUI") - legacy.on_resize(*args) - self.stdscr.erase() + @overrides(BaseMode) + def on_resize(self, rows, cols): + BaseMode.on_resize(self, rows, cols) self.refresh() - def back_to_overview(self): - self.stdscr.erase() - component.get("ConsoleUI").set_mode(self.parent_mode) - self.parent_mode.resume() - + @overrides(BaseMode) def read_input(self): c = self.stdscr.getch() - if c > 31 and c < 256: - if chr(c) == "Q": - from twisted.internet import reactor - if client.connected(): - def on_disconnect(result): - reactor.stop() - client.disconnect().addCallback(on_disconnect) - else: - reactor.stop() - return - elif chr(c) == "q": - self.back_to_overview() - return - - if c == 27: + if c in [ord('q'), util.KEY_ESC]: self.back_to_overview() return diff --git a/deluge/ui/console/modes/input_popup.py b/deluge/ui/console/modes/input_popup.py deleted file mode 100644 index 938c42ace..000000000 --- a/deluge/ui/console/modes/input_popup.py +++ /dev/null @@ -1,916 +0,0 @@ -# -*- coding: utf-8 -*- -# -# 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. -# - -from __future__ import division - -import logging -import os -import os.path - -from deluge.ui.console import colors -from deluge.ui.console.modes.popup import ALIGN, Popup - -try: - import curses -except ImportError: - pass - - -log = logging.getLogger(__name__) - - -class InputField(object): - depend = None - # render the input. return number of rows taken up - - def get_height(self): - return 0 - - def render(self, screen, row, width, selected, col=1): - return 0 - - def handle_read(self, c): - if c in [curses.KEY_ENTER, 10, 127, 113]: - return True - return False - - def get_value(self): - return None - - def set_value(self, value): - pass - - 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 CheckedInput(InputField): - def __init__(self, parent, message, name, checked=False, additional_formatting=False): - self.parent = parent - self.additional_formatting = additional_formatting - self.chkd_inact = "[X] %s" % message - self.unchkd_inact = "[ ] %s" % message - self.chkd_act = "[{!black,white,bold!}X{!white,black!}] %s" % message - self.unchkd_act = "[{!black,white,bold!} {!white,black!}] %s" % message - self.name = name - self.checked = checked - - def get_height(self): - return 1 - - def render(self, screen, row, width, active, col=1): - if self.checked and active: - self.parent.add_string(row, self.chkd_act, screen, col, False, True) - elif self.checked: - self.parent.add_string(row, self.chkd_inact, screen, col, False, True) - elif active: - self.parent.add_string(row, self.unchkd_act, screen, col, False, True) - else: - self.parent.add_string(row, self.unchkd_inact, screen, col, False, True) - return 1 - - def handle_read(self, c): - if c == 32: - self.checked = not self.checked - - def get_value(self): - return self.checked - - def set_value(self, c): - self.checked = c - - -class CheckedPlusInput(InputField): - def __init__(self, parent, message, name, child, checked=False, additional_formatting=False): - self.parent = parent - self.additional_formatting = additional_formatting - self.chkd_inact = "[X] %s" % message - self.unchkd_inact = "[ ] %s" % message - self.chkd_act = "[{!black,white,bold!}X{!white,black!}] %s" % message - self.unchkd_act = "[{!black,white,bold!} {!white,black!}] %s" % message - self.name = name - self.checked = checked - self.msglen = len(self.chkd_inact) + 1 - self.child = child - self.child_active = False - - def get_height(self): - return max(2, self.child.height) - - def render(self, screen, row, width, active, col=1): - isact = active and not self.child_active - if self.checked and isact: - self.parent.add_string(row, self.chkd_act, screen, col, False, True) - elif self.checked: - self.parent.add_string(row, self.chkd_inact, screen, col, False, True) - elif isact: - self.parent.add_string(row, self.unchkd_act, screen, col, False, True) - else: - self.parent.add_string(row, self.unchkd_inact, screen, col, False, True) - - if active and self.checked and self.child_active: - self.parent.add_string(row + 1, "(esc to leave)", screen, col, False, True) - elif active and self.checked: - self.parent.add_string(row + 1, "(right arrow to edit)", screen, col, False, True) - rows = 2 - # show child - if self.checked: - if isinstance(self.child, (TextInput, IntSpinInput, FloatSpinInput)): - crows = self.child.render(screen, row, width - self.msglen, - self.child_active and active, col + self.msglen, self.msglen) - else: - crows = self.child.render(screen, row, width - self.msglen, - self.child_active and active, col + self.msglen) - rows = max(rows, crows) - else: - self.parent.add_string(row, "(enable to view/edit value)", screen, col + self.msglen, False, True) - return rows - - def handle_read(self, c): - if self.child_active: - if c == 27: # leave child on esc - self.child_active = False - return - # pass keys through to child - self.child.handle_read(c) - else: - if c == 32: - self.checked = not self.checked - if c == curses.KEY_RIGHT: - self.child_active = True - - def get_value(self): - return self.checked - - def set_value(self, c): - self.checked = c - - def get_child(self): - return self.child - - -class IntSpinInput(InputField): - def __init__(self, parent, message, name, move_func, value, min_val=None, max_val=None, - additional_formatting=False): - self.parent = parent - self.message = message - self.name = name - - self.additional_formatting = additional_formatting - - self.default_str = str(value) - self.set_value(value) - self.default_value = self.value - - self.cursor = len(self.valstr) - self.cursoff = colors.get_line_width(self.message) + 4 # + 4 for the " [ " in the rendered string - self.move_func = move_func - self.min_val = min_val - self.max_val = max_val - self.need_update = False - - def get_height(self): - return 1 - - def __limit_value(self): - if (self.min_val is not None) and self.value < self.min_val: - self.value = self.min_val - if (self.max_val is not None) and self.value > self.max_val: - self.value = self.max_val - - def render(self, screen, row, width, active, col=1, cursor_offset=0): - if not active and self.need_update: - if not self.valstr or self.valstr == "-": - self.value = self.default_value - self.valstr = self.default_str - try: - int(self.value) - except ValueError: - self.real_value = False - else: - self.value = int(self.valstr) - self.__limit_value() - self.valstr = "%d" % self.value - self.cursor = len(self.valstr) - self.cursor = colors.get_line_width(self.valstr) - self.need_update = False - elif self.need_update and self.valstr != "-": - self.real_value = True - try: - self.value = int(self.valstr) - except ValueError: - self.value = self.default_value - try: - int(self.value) - except ValueError: - self.real_value = False - if not self.valstr: - self.parent.add_string(row, "%s {!input!}[ ]" % self.message, screen, col, False, True) - elif active: - self.parent.add_string(row, "%s {!input!}[ {!black,white,bold!}%s{!input!} ]" % ( - self.message, self.valstr), screen, col, False, True) - elif self.additional_formatting and self.valstr == self.default_str: - self.parent.add_string(row, "%s {!input!}[ {!magenta,black!}%s{!input!} ]" % ( - self.message, self.valstr), screen, col, False, True) - else: - self.parent.add_string(row, "%s {!input!}[ %s ]" % (self.message, self.valstr), screen, col, False, True) - - if active: - self.move_func(row, self.cursor + self.cursoff + cursor_offset) - - return 1 - - def handle_read(self, c): - if c == curses.KEY_PPAGE and self.value < self.max_val: - if not self.real_value: - self.value = self.min_val - self.valstr = "%d" % self.value - self.real_value = True - else: - self.value += 1 - self.valstr = "%d" % self.value - self.cursor = len(self.valstr) - elif c == curses.KEY_NPAGE and self.value > self.min_val: - if not self.real_value: - self.value = self.min_val - self.valstr = "%d" % self.value - self.real_value = True - else: - self.value -= 1 - self.valstr = "%d" % self.value - self.cursor = len(self.valstr) - elif c == curses.KEY_LEFT: - if not self.real_value: - return None - self.cursor = max(0, self.cursor - 1) - elif c == curses.KEY_RIGHT: - if not self.real_value: - return None - self.cursor = min(len(self.valstr), self.cursor + 1) - elif c == curses.KEY_HOME: - if not self.real_value: - return None - self.cursor = 0 - elif c == curses.KEY_END: - if not self.real_value: - return None - self.cursor = len(self.valstr) - elif c == curses.KEY_BACKSPACE or c == 127: - if not self.real_value: - self.valstr = "" - self.cursor = 0 - self.real_value = True - self.need_update = True - elif self.valstr and self.cursor > 0: - self.valstr = self.valstr[:self.cursor - 1] + self.valstr[self.cursor:] - self.cursor -= 1 - self.need_update = True - elif c == curses.KEY_DC: - if not self.real_value: - return None - if self.valstr and self.cursor < len(self.valstr): - self.valstr = self.valstr[:self.cursor] + self.valstr[self.cursor + 1:] - self.need_update = True - elif c == 45 and self.min_val < 0: - if not self.real_value: - self.valstr = "-" - self.cursor = 1 - self.real_value = True - self.need_update = True - if self.cursor != 0: - return - minus_place = self.valstr.find("-") - if minus_place >= 0: - return - self.valstr = chr(c) + self.valstr - self.cursor += 1 - self.need_update = True - elif c > 47 and c < 58: - if (not self.real_value) and self.valstr: - self.valstr = "" - self.cursor = 0 - self.real_value = True - self.need_update = True - if c == 48 and self.cursor == 0: - return - minus_place = self.valstr.find("-") - if self.cursor <= minus_place: - return - if self.cursor == len(self.valstr): - self.valstr += chr(c) - else: - # Insert into string - self.valstr = self.valstr[:self.cursor] + chr(c) + self.valstr[self.cursor:] - self.need_update = True - # Move the cursor forward - self.cursor += 1 - - def get_value(self): - if self.real_value: - self.__limit_value() - return self.value - else: - return None - - def set_value(self, val): - try: - self.value = int(val) - self.valstr = "%d" % self.value - self.real_value = True - except ValueError: - self.value = None - self.real_value = False - self.valstr = val - self.cursor = len(self.valstr) - - -class FloatSpinInput(InputField): - def __init__(self, parent, message, name, move_func, value, inc_amt, precision, min_val=None, - max_val=None, additional_formatting=False): - self.parent = parent - self.message = message - self.name = name - self.precision = precision - self.inc_amt = inc_amt - - self.additional_formatting = additional_formatting - - self.fmt = "%%.%df" % precision - - self.default_str = str(value) - self.set_value(value) - self.default_value = self.value - - self.cursor = len(self.valstr) - self.cursoff = colors.get_line_width(self.message) + 4 # + 4 for the " [ " in the rendered string - self.move_func = move_func - self.min_val = min_val - self.max_val = max_val - self.need_update = False - - def get_height(self): - return 1 - - def __limit_value(self): - if (self.min_val is not None) and self.value < self.min_val: - self.value = self.min_val - if (self.max_val is not None) and self.value > self.max_val: - self.value = self.max_val - self.valstr = self.fmt % self.value - - def render(self, screen, row, width, active, col=1, cursor_offset=0): - if not active and self.need_update: - if not self.valstr or self.valstr == "-": - self.value = self.default_value - self.valstr = self.default_str - try: - float(self.value) - except ValueError: - self.real_value = False - else: - self.set_value(self.valstr) - self.__limit_value() - self.valstr = self.fmt % self.value - self.cursor = len(self.valstr) - self.cursor = colors.get_line_width(self.valstr) - self.need_update = False - elif self.need_update and self.valstr != "-": - self.real_value = True - try: - self.value = round(float(self.valstr), self.precision) - except ValueError: - self.value = self.default_value - try: - float(self.value) - except ValueError: - self.real_value = False - - if not self.valstr: - self.parent.add_string(row, "%s {!input!}[ ]" % self.message, screen, col, False, True) - elif active: - self.parent.add_string(row, "%s {!input!}[ {!black,white,bold!}%s{!white,black!} ]" % ( - self.message, self.valstr), screen, col, False, True) - elif self.additional_formatting and self.valstr == self.default_str: - self.parent.add_string(row, "%s {!input!}[ {!magenta,black!}%s{!input!} ]" % ( - self.message, self.valstr), screen, col, False, True) - else: - self.parent.add_string(row, "%s {!input!}[ %s ]" % (self.message, self.valstr), screen, col, False, True) - if active: - self.move_func(row, self.cursor + self.cursoff + cursor_offset) - - return 1 - - def handle_read(self, c): - if c == curses.KEY_PPAGE: - if not self.real_value: - self.value = self.min_val - self.valstr = "%d" % self.value - self.real_value = True - else: - self.value += self.inc_amt - self.__limit_value() - self.valstr = self.fmt % self.value - self.cursor = len(self.valstr) - elif c == curses.KEY_NPAGE: - if not self.real_value: - self.value = self.min_val - self.valstr = "%d" % self.value - self.real_value = True - else: - self.value -= self.inc_amt - self.__limit_value() - self.valstr = self.fmt % self.value - self.cursor = len(self.valstr) - elif c == curses.KEY_LEFT: - if not self.real_value: - return None - self.cursor = max(0, self.cursor - 1) - elif c == curses.KEY_RIGHT: - if not self.real_value: - return None - self.cursor = min(len(self.valstr), self.cursor + 1) - elif c == curses.KEY_HOME: - if not self.real_value: - return None - self.cursor = 0 - elif c == curses.KEY_END: - if not self.real_value: - return None - self.cursor = len(self.valstr) - elif c == curses.KEY_BACKSPACE or c == 127: - if not self.real_value: - self.valstr = "" - self.cursor = 0 - self.real_value = True - self.need_update = True - elif self.valstr and self.cursor > 0: - self.valstr = self.valstr[:self.cursor - 1] + self.valstr[self.cursor:] - self.cursor -= 1 - self.need_update = True - elif c == curses.KEY_DC: - if not self.real_value: - return None - if self.valstr and self.cursor < len(self.valstr): - self.valstr = self.valstr[:self.cursor] + self.valstr[self.cursor + 1:] - self.need_update = True - elif c == 45 and self.min_val < 0: - if not self.real_value: - self.valstr = "-" - self.cursor = 1 - self.need_update = True - self.real_value = True - if self.cursor != 0: - return - minus_place = self.valstr.find("-") - if minus_place >= 0: - return - self.valstr = chr(c) + self.valstr - self.cursor += 1 - self.need_update = True - elif c == 46: - if (not self.real_value) and self.valstr: - self.valstr = "0." - self.cursor = 2 - self.real_value = True - self.need_update = True - minus_place = self.valstr.find("-") - if self.cursor <= minus_place: - return - point_place = self.valstr.find(".") - if point_place >= 0: - return - if self.cursor == len(self.valstr): - self.valstr += chr(c) - else: - # Insert into string - self.valstr = self.valstr[:self.cursor] + chr(c) + self.valstr[self.cursor:] - self.need_update = True - # Move the cursor forward - self.cursor += 1 - elif c > 47 and c < 58: - if (not self.real_value) and self.valstr: - self.valstr = "" - self.cursor = 0 - self.real_value = True - self.need_update = True - if self.value == "mixed": - self.value = "" - minus_place = self.valstr.find("-") - if self.cursor <= minus_place: - return - if self.cursor == len(self.valstr): - self.valstr += chr(c) - else: - # Insert into string - self.valstr = self.valstr[:self.cursor] + chr(c) + self.valstr[self.cursor:] - self.need_update = True - # Move the cursor forward - self.cursor += 1 - - def get_value(self): - if self.real_value: - self.__limit_value() - return self.value - else: - return None - - def set_value(self, val): - try: - self.value = round(float(val), self.precision) - self.valstr = self.fmt % self.value - self.real_value = True - except ValueError: - self.value = None - self.real_value = False - self.valstr = val - self.cursor = len(self.valstr) - - -class SelectInput(InputField): - def __init__(self, parent, message, name, opts, vals, selidx, additional_formatting=False): - self.parent = parent - self.message = message - self.additional_formatting = additional_formatting - self.name = name - self.opts = opts - self.vals = vals - self.selidx = selidx - self.default_option = selidx - - def get_height(self): - return 1 + bool(self.message) - - def render(self, screen, row, width, selected, col=1): - if self.message: - self.parent.add_string(row, self.message, screen, col, False, True) - row += 1 - off = col + 1 - for i, opt in enumerate(self.opts): - if selected and i == self.selidx: - self.parent.add_string(row, "{!black,white,bold!}[%s]" % opt, screen, off, False, True) - elif i == self.selidx: - if self.additional_formatting and i == self.default_option: - self.parent.add_string(row, "[{!magenta,black!}%s{!white,black!}]" % opt, screen, off, False, True) - elif self.additional_formatting: - self.parent.add_string(row, "[{!white,blue!}%s{!white,black!}]" % opt, screen, off, False, True) - else: - self.parent.add_string(row, "[{!white,black,underline!}%s{!white,black!}]" % - opt, screen, off, False, True) - else: - self.parent.add_string(row, "[%s]" % opt, screen, off, False, True) - off += len(opt) + 3 - if self.message: - return 2 - else: - return 1 - - def handle_read(self, c): - if c == curses.KEY_LEFT: - self.selidx = max(0, self.selidx - 1) - if c == curses.KEY_RIGHT: - self.selidx = min(len(self.opts) - 1, self.selidx + 1) - - def get_value(self): - return self.vals[self.selidx] - - def set_value(self, nv): - for i, val in enumerate(self.vals): - if nv == val: - self.selidx = i - return - raise Exception("Invalid value for SelectInput") - - -class TextInput(InputField): - def __init__(self, parent, move_func, width, message, name, value, docmp, additional_formatting=False): - self.parent = parent - self.move_func = move_func - self.width = width - - self.additional_formatting = additional_formatting - - self.message = message - self.name = name - self.value = value - self.default_value = value - self.docmp = docmp - - self.tab_count = 0 - self.cursor = len(self.value) - self.opts = None - self.opt_off = 0 - - def get_height(self): - return 2 + bool(self.message) - - def render(self, screen, row, width, selected, col=1, cursor_offset=0): - if not self.value and not selected 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, screen, col, False, True) - row += 1 - if selected: - if self.opts: - self.parent.add_string(row + 1, self.opts[self.opt_off:], screen, col, False, True) - if self.cursor > (width - 3): - self.move_func(row, width - 2) - else: - self.move_func(row, self.cursor + 1 + cursor_offset) - slen = len(self.value) + 3 - if slen > width: - vstr = self.value[(slen - width):] - else: - vstr = self.value.ljust(width - 2) - - if self.additional_formatting and len(self.value) != 0 and self.value == self.default_value: - self.parent.add_string(row, "{!magenta,white!}%s" % vstr, screen, col, False, False) - else: - self.parent.add_string(row, "{!black,white,bold!}%s" % vstr, screen, col, False, False) - - if self.message: - return 3 - else: - return 2 - - def get_value(self): - return self.value - - def set_value(self, val): - self.value = val - self.cursor = len(self.value) - - # most of the cursor,input stuff here taken from ui/console/screen.py - def handle_read(self, c): - if c == 9 and self.docmp: - # 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.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) - - if c != 9: - self.opts = None - self.opt_off = 0 - self.tab_count = 0 - - # Delete a character in the input string based on cursor position - if c == curses.KEY_BACKSPACE or c == 127: - if self.value and self.cursor > 0: - self.value = self.value[:self.cursor - 1] + self.value[self.cursor:] - self.cursor -= 1 - 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 = "" - 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 - - 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 += 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 InputPopup(Popup): - def __init__(self, parent_mode, title, width_req=0, height_req=0, align=ALIGN.DEFAULT, close_cb=None, - additional_formatting=True, immediate_action=False): - Popup.__init__(self, parent_mode, title, width_req=width_req, height_req=height_req, - align=align, close_cb=close_cb) - self.inputs = [] - self.lines = [] - self.current_input = 0 - - self.additional_formatting = additional_formatting - self.immediate_action = immediate_action - - # We need to replicate some things in order to wrap our inputs - self.encoding = parent_mode.encoding - - def move(self, r, c): - self._cursor_row = r - self._cursor_col = c - - def add_text_input(self, message, name, value="", complete=True): - """ - Add a text input field to the popup. - - :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 - """ - self.inputs.append(TextInput(self, self.move, self.width, message, name, value, complete, - additional_formatting=self.additional_formatting)) - - def getmaxyx(self): - return self.screen.getmaxyx() - - def add_string(self, row, string, scr=None, col=0, pad=True, trim=True): - if row <= 0: - return False - elif row >= self.height - 1: - return False - self.parent.add_string(row, string, scr, col, pad, trim) - return True - - def add_spaces(self, num): - for i in range(num): - self.lines.append((len(self.inputs), "")) - - def add_text(self, string): - lines = string.split("\n") - for line in lines: - self.lines.append((len(self.inputs), line)) - - def add_select_input(self, message, name, opts, vals, default_index=0): - self.inputs.append(SelectInput(self, message, name, opts, vals, default_index, - additional_formatting=self.additional_formatting)) - - def add_checked_input(self, message, name, checked=False): - self.inputs.append(CheckedInput(self, message, name, checked, - additional_formatting=self.additional_formatting)) - - # def add_checked_plus_input(self, message, name, child) - - def add_float_spin_input(self, message, name, value=0.0, inc_amt=1.0, precision=1, min_val=None, max_val=None): - i = FloatSpinInput(self, message, name, self.move, value, inc_amt, precision, min_val, max_val, - additional_formatting=self.additional_formatting) - self.inputs.append(i) - - def add_int_spin_input(self, message, name, value=0, min_val=None, max_val=None): - i = IntSpinInput(self, message, name, self.move, value, min_val, max_val, - additional_formatting=self.additional_formatting) - self.inputs.append(i) - - def _refresh_lines(self): - self._cursor_row = -1 - self._cursor_col = -1 - curses.curs_set(0) - - start_row = 0 - end_row = 0 - for i, ipt in enumerate(self.inputs): - for line in self.lines: - if line[0] == i: - end_row += 1 - start_row = end_row - end_row += ipt.get_height() - active = (i == self.current_input) - - if active: - if end_row + 1 >= self.height + self.lineoff: - self.lineoff += ipt.get_height() - elif start_row < self.lineoff: - self.lineoff -= ipt.get_height() - self.content_height = end_row - - crow = 1 - self.lineoff - for i, ipt in enumerate(self.inputs): - for line in self.lines: - if line[0] == i: - self.add_string(crow, line[1], self.screen, 1, pad=False) - crow += 1 - crow += ipt.render(self.screen, crow, self.width, i == self.current_input) - - if self.content_height > (self.height - 2): - lts = self.content_height - (self.height - 3) - perc_sc = self.lineoff / lts - sb_pos = int((self.height - 2) * perc_sc) + 1 - if (sb_pos == 1) and (self.lineoff != 0): - sb_pos += 1 - self.add_string(sb_pos, "{!red,black,bold!}#", self.screen, col=(self.width - 1), pad=False, trim=False) - if self._cursor_row >= 0: - curses.curs_set(2) - self.screen.move(self._cursor_row, self._cursor_col) - - def handle_read(self, c): - if c == curses.KEY_UP: - self.current_input = max(0, self.current_input - 1) - elif c == curses.KEY_DOWN: - self.current_input = min(len(self.inputs) - 1, self.current_input + 1) - elif c == curses.KEY_ENTER or c == 10: - if self.close_cb: - vals = {} - for ipt in self.inputs: - vals[ipt.name] = ipt.get_value() - curses.curs_set(0) - self.close_cb(vals) - return True # close the popup - elif c == 27: # close on esc, no action - return True - elif self.inputs: - self.inputs[self.current_input].handle_read(c) - if self.immediate_action: - vals = {} - for ipt in self.inputs: - vals[ipt.name] = ipt.get_value() - self.close_cb(vals) - - self.refresh() - return False diff --git a/deluge/ui/console/modes/popup.py b/deluge/ui/console/modes/popup.py deleted file mode 100644 index deb2b3275..000000000 --- a/deluge/ui/console/modes/popup.py +++ /dev/null @@ -1,350 +0,0 @@ -# -*- coding: utf-8 -*- -# -# 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 __future__ import division - -import logging - -from deluge.ui.console.modes import format_utils - -try: - import curses -except ImportError: - pass - - -log = logging.getLogger(__name__) - - -class ALIGN(object): - 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 Popup(object): - def __init__(self, parent_mode, title, width_req=0, height_req=0, align=ALIGN.DEFAULT, - close_cb=None, init_lines=None): - """ - Init a new popup. The default constructor will handle sizing and borders and the like. - - NB: 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. - - :param parent_mode: must be a basemode (or subclass) which the popup will be drawn over - :parem title: string, the title of the popup window - - 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 - - add_string(self, row, string) - add string at row. handles triming/ignoring if the string won't fit in the popup - - read_input(self) - handle user input to the popup. - """ - self.parent = parent_mode - - self.height_req = height_req - self.width_req = width_req - self.align = align - - self.handle_resize() - - self.title = title - self.close_cb = close_cb - - self.divider = None - self.lineoff = 0 - if init_lines: - self._lines = init_lines - else: - self._lines = [] - - def _refresh_lines(self): - crow = 1 - for line in self._lines[self.lineoff:]: - if crow >= self.height - 1: - break - self.parent.add_string(crow, line, self.screen, 1, False, True) - crow += 1 - - def handle_resize(self): - if isinstance(self.height_req, float) and 0.0 < self.height_req <= 1.0: - hr = int((self.parent.rows - 2) * self.height_req) - else: - hr = self.height_req - - if isinstance(self.width_req, float) and 0.0 < self.width_req <= 1.0: - wr = int((self.parent.cols - 2) * self.width_req) - else: - wr = self.width_req - - log.debug("Resizing(or creating) popup window") - - # Height - if hr == 0: - hr = self.parent.rows // 2 - elif hr == -1: - hr = self.parent.rows - 2 - elif hr > self.parent.rows - 2: - hr = self.parent.rows - 2 - - # Width - if wr == 0: - wr = self.parent.cols // 2 - elif wr == -1: - wr = self.parent.cols - elif wr >= self.parent.cols: - wr = self.parent.cols - - if self.align in [ALIGN.TOP_CENTER, ALIGN.TOP_LEFT, ALIGN.TOP_RIGHT]: - by = 1 - elif self.align in [ALIGN.MIDDLE_CENTER, ALIGN.MIDDLE_LEFT, ALIGN.MIDDLE_RIGHT]: - by = (self.parent.rows // 2) - (hr // 2) - elif self.align in [ALIGN.BOTTOM_CENTER, ALIGN.BOTTOM_LEFT, ALIGN.BOTTOM_RIGHT]: - by = self.parent.rows - hr - 1 - - if self.align in [ALIGN.TOP_LEFT, ALIGN.MIDDLE_LEFT, ALIGN.BOTTOM_LEFT]: - bx = 0 - elif self.align in [ALIGN.TOP_CENTER, ALIGN.MIDDLE_CENTER, ALIGN.BOTTOM_CENTER]: - bx = (self.parent.cols // 2) - (wr // 2) - elif self.align in [ALIGN.TOP_RIGHT, ALIGN.MIDDLE_RIGHT, ALIGN.BOTTOM_RIGHT]: - bx = self.parent.cols - wr - 1 - - self.screen = curses.newwin(hr, wr, by, bx) - - self.x, self.y = bx, by - self.height, self.width = self.screen.getmaxyx() - - def refresh(self): - self.screen.erase() - self.screen.border(0, 0, 0, 0) - toff = max(1, (self.width // 2) - (len(self.title) // 2)) - self.parent.add_string(0, "{!white,black,bold!}%s" % self.title, self.screen, toff, False, True) - - self._refresh_lines() - if len(self._lines) > (self.height - 2): - lts = len(self._lines) - (self.height - 3) - perc_sc = self.lineoff / lts - sb_pos = int((self.height - 2) * perc_sc) + 1 - if (sb_pos == 1) and (self.lineoff != 0): - sb_pos += 1 - self.parent.add_string(sb_pos, "{!red,black,bold!}#", self.screen, col=(self.width - 1), - pad=False, trim=False) - - self.screen.redrawwin() - self.screen.noutrefresh() - - def clear(self): - self._lines = [] - - def handle_read(self, c): - p_off = self.height - 3 - if c == curses.KEY_UP: - self.lineoff = max(0, self.lineoff - 1) - elif c == curses.KEY_PPAGE: - self.lineoff = max(0, self.lineoff - p_off) - elif c == curses.KEY_HOME: - self.lineoff = 0 - elif c == curses.KEY_DOWN: - if len(self._lines) - self.lineoff > (self.height - 2): - self.lineoff += 1 - elif c == curses.KEY_NPAGE: - self.lineoff = min(len(self._lines) - self.height + 2, self.lineoff + p_off) - elif c == curses.KEY_END: - self.lineoff = len(self._lines) - self.height + 2 - - elif c == curses.KEY_ENTER or c == 10 or c == 27: # close on enter/esc - if self.close_cb: - self.close_cb() - return True # close the popup - - if c > 31 and c < 256 and chr(c) == "q": - if self.close_cb: - self.close_cb() - return True # close the popup - - self.refresh() - - return False - - def set_title(self, title): - self.title = title - - def add_line(self, string): - self._lines.append(string) - - def add_divider(self): - if not self.divider: - self.divider = "-" * (self.width - 2) - self._lines.append(self.divider) - - -class SelectablePopup(Popup): - """ - A popup which will let the user select from some of the lines that - are added. - """ - def __init__(self, parent_mode, title, selection_callback, args=(), align=ALIGN.DEFAULT, immediate_action=False): - Popup.__init__(self, parent_mode, title, align=align) - self._selection_callback = selection_callback - self._selection_args = args - self._selectable_lines = [] - - self._immediate_action = immediate_action - - self._select_data = [] - self._line_foregrounds = [] - self._udxs = {} - self._hotkeys = {} - self._selected = -1 - - def add_line(self, string, selectable=True, use_underline=True, data=None, foreground=None): - if use_underline: - udx = string.find("_") - if udx >= 0: - string = string[:udx] + string[udx + 1:] - self._udxs[len(self._lines) + 1] = udx - c = string[udx].lower() - self._hotkeys[c] = len(self._lines) - Popup.add_line(self, string) - self._line_foregrounds.append(foreground) - if selectable: - self._selectable_lines.append(len(self._lines) - 1) - self._select_data.append(data) - if self._selected < 0: - self._selected = (len(self._lines) - 1) - - def _refresh_lines(self): - crow = 1 - for row, line in enumerate(self._lines): - if crow >= self.height - 1: - break - if row < self.lineoff: - continue - fg = self._line_foregrounds[row] - udx = self._udxs.get(crow) - if row == self._selected: - if fg is None: - fg = "black" - colorstr = "{!%s,white,bold!}" % fg - if udx >= 0: - ustr = "{!%s,white,bold,underline!}" % fg - else: - if fg is None: - fg = "white" - colorstr = "{!%s,black!}" % fg - if udx >= 0: - ustr = "{!%s,black,underline!}" % fg - if udx == 0: - self.parent.add_string(crow, "- %s%c%s%s" % ( - ustr, line[0], colorstr, line[1:]), self.screen, 1, False, True) - elif udx > 0: - # well, this is a litte gross - self.parent.add_string(crow, "- %s%s%s%c%s%s" % ( - colorstr, line[:udx], ustr, line[udx], colorstr, line[udx + 1:]), self.screen, 1, False, True) - else: - self.parent.add_string(crow, "- %s%s" % (colorstr, line), self.screen, 1, False, True) - crow += 1 - - def current_selection(self): - "Returns a tuple of (selected index, selected data)" - idx = self._selectable_lines.index(self._selected) - return (idx, self._select_data[idx]) - - def add_divider(self, color="white"): - if not self.divider: - self.divider = "-" * (self.width - 6) + " -" - self._lines.append(self.divider) - self._line_foregrounds.append(color) - - def _move_cursor_up(self, amount): - if self._selectable_lines.index(self._selected) > amount: - idx = self._selectable_lines.index(self._selected) - self._selected = self._selectable_lines[idx - amount] - else: - self._selected = self._selectable_lines[0] - - if self._immediate_action: - self._selection_callback(idx, self._select_data[idx], *self._selection_args) - - def _move_cursor_down(self, amount): - idx = self._selectable_lines.index(self._selected) - if idx < len(self._selectable_lines) - amount: - self._selected = self._selectable_lines[idx + amount] - else: - self._selected = self._selectable_lines[-1] - - if self._immediate_action: - self._selection_callback(idx, self._select_data[idx], *self._selection_args) - - def handle_read(self, c): - if c == curses.KEY_UP: - self._move_cursor_up(1) - elif c == curses.KEY_DOWN: - self._move_cursor_down(1) - - elif c == curses.KEY_PPAGE: - self._move_cursor_up(4) - elif c == curses.KEY_NPAGE: - self._move_cursor_down(4) - - elif c == curses.KEY_HOME: - self._move_cursor_up(len(self._selectable_lines)) - elif c == curses.KEY_END: - self._move_cursor_down(len(self._selectable_lines)) - - elif c == 27: # close on esc, no action - return True - - elif c == curses.KEY_ENTER or c == 10: - idx = self._selectable_lines.index(self._selected) - return self._selection_callback(idx, self._select_data[idx], *self._selection_args) - - if c > 31 and c < 256: - if chr(c) == "q": - return True # close the popup - uc = chr(c).lower() - if uc in self._hotkeys: - # exec hotkey action - idx = self._selectable_lines.index(self._hotkeys[uc]) - return self._selection_callback(idx, self._select_data[idx], *self._selection_args) - self.refresh() - - return False - - -class MessagePopup(Popup): - """ - Popup that just displays a message - """ - def __init__(self, parent_mode, title, message, align=ALIGN.DEFAULT, width_req=0.5): - self.message = message - # self.width= int(parent_mode.cols/2) - Popup.__init__(self, parent_mode, title, align=align, width_req=width_req) - lns = format_utils.wrap_string(self.message, self.width - 2, 3, True) - self.height_req = min(len(lns) + 2, int(parent_mode.rows * 2 / 3)) - self.handle_resize() - self._lines = lns - - def handle_resize(self): - Popup.handle_resize(self) - self.clear() - self._lines = format_utils.wrap_string(self.message, self.width - 2, 3, True) diff --git a/deluge/ui/console/modes/preference_panes.py b/deluge/ui/console/modes/preference_panes.py deleted file mode 100644 index a136fd323..000000000 --- a/deluge/ui/console/modes/preference_panes.py +++ /dev/null @@ -1,490 +0,0 @@ -# -*- coding: utf-8 -*- -# -# 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.ui.console.modes.alltorrents -from deluge.common import is_ip -from deluge.ui.console.modes.input_popup import (CheckedInput, CheckedPlusInput, FloatSpinInput, IntSpinInput, - SelectInput, TextInput) - -try: - import curses -except ImportError: - pass - -log = logging.getLogger(__name__) - - -class NoInput(object): - def depend_skip(self): - return False - - -class Header(NoInput): - def __init__(self, parent, header, space_above, space_below): - self.parent = parent - self.header = "{!white,black,bold!}%s" % header - self.space_above = space_above - self.space_below = space_below - self.name = header - - def render(self, screen, row, width, active, offset): - rows = 1 - if self.space_above: - row += 1 - rows += 1 - self.parent.add_string(row, self.header, screen, offset - 1, False, True) - if self.space_below: - rows += 1 - return rows - - -class InfoField(NoInput): - def __init__(self, parent, label, value, name): - self.parent = parent - self.label = label - self.value = value - self.txt = "%s %s" % (label, value) - self.name = name - - def render(self, screen, row, width, active, offset): - self.parent.add_string(row, self.txt, screen, offset - 1, False, True) - return 1 - - def set_value(self, v): - self.value = v - if isinstance(v, float): - self.txt = "%s %.2f" % (self.label, self.value) - else: - self.txt = "%s %s" % (self.label, self.value) - - -class BasePane(object): - def __init__(self, offset, parent, width): - self.offset = offset + 1 - self.parent = parent - self.width = width - self.inputs = [] - self.active_input = -1 - - # have we scrolled down in the list - self.input_offset = 0 - - def move(self, r, c): - self._cursor_row = r - self._cursor_col = c - - def add_config_values(self, conf_dict): - for ipt in self.inputs: - if not isinstance(ipt, NoInput): - # gross, have to special case in/out ports since they are tuples - if ipt.name in ("listen_interface", "listen_port", "out_ports_from", "out_ports_to", - "i2p_port", "i2p_hostname", "proxy_type", "proxy_username", "proxy_hostnames", - "proxy_password", "proxy_hostname", "proxy_port", "proxy_peer_connections"): - if ipt.name == "listen_port": - conf_dict["listen_ports"] = [self.infrom.get_value()] * 2 - elif ipt.name == "out_ports_to": - conf_dict["outgoing_ports"] = (self.outfrom.get_value(), self.outto.get_value()) - elif ipt.name == "listen_interface": - interface = ipt.get_value().strip() - if is_ip(interface) or not interface: - conf_dict["listen_interface"] = interface - elif ipt.name == "i2p_port": - conf_dict.setdefault("i2p_proxy", {})["port"] = ipt.get_value() - elif ipt.name == "i2p_hostname": - conf_dict.setdefault("i2p_proxy", {})["hostname"] = ipt.get_value() - elif 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() - 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 not isinstance(ipt, NoInput): - 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, active): - self._cursor_row = -1 - if self.active_input < 0: - for i, ipt in enumerate(self.inputs): - if not isinstance(ipt, NoInput): - self.active_input = i - break - drew_act = not active - crow = 1 - for i, ipt in enumerate(self.inputs): - if ipt.depend_skip() or i < self.input_offset: - if active and i == self.active_input: - self.input_offset -= 1 - mode.refresh() - return 0 - continue - act = active and i == self.active_input - if act: - drew_act = True - crow += ipt.render(screen, crow, width, act, self.offset) - if crow >= (mode.prefs_height): - break - - if not drew_act: - self.input_offset += 1 - mode.refresh() - return 0 - - if active and self._cursor_row >= 0: - curses.curs_set(2) - screen.move(self._cursor_row, self._cursor_col + self.offset - 1) - else: - curses.curs_set(0) - - return crow - - # just handles setting the active input - def handle_read(self, c): - if not self.inputs: # no inputs added yet - return - - if c == curses.KEY_UP: - nc = max(0, self.active_input - 1) - while isinstance(self.inputs[nc], NoInput) or self.inputs[nc].depend_skip(): - nc -= 1 - if nc <= 0: - break - if not isinstance(self.inputs[nc], NoInput) or self.inputs[nc].depend_skip(): - self.active_input = nc - elif c == curses.KEY_DOWN: - ilen = len(self.inputs) - nc = min(self.active_input + 1, ilen - 1) - while isinstance(self.inputs[nc], NoInput) or self.inputs[nc].depend_skip(): - nc += 1 - if nc >= ilen: - nc -= 1 - break - if not isinstance(self.inputs[nc], NoInput) or self.inputs[nc].depend_skip(): - self.active_input = nc - else: - self.inputs[self.active_input].handle_read(c) - - def add_header(self, header, space_above=False, space_below=False): - self.inputs.append(Header(self.parent, header, space_above, space_below)) - - def add_info_field(self, label, value, name): - self.inputs.append(InfoField(self.parent, label, value, name)) - - def add_text_input(self, name, msg, dflt_val): - self.inputs.append(TextInput(self.parent, self.move, self.width, msg, name, dflt_val, False)) - - def add_select_input(self, name, msg, opts, vals, selidx): - self.inputs.append(SelectInput(self.parent, msg, name, opts, vals, selidx)) - - def add_checked_input(self, name, message, checked): - self.inputs.append(CheckedInput(self.parent, message, name, checked)) - - def add_checkedplus_input(self, name, message, child, checked): - self.inputs.append(CheckedPlusInput(self.parent, message, name, child, checked)) - - def add_int_spin_input(self, name, message, value, min_val, max_val): - self.inputs.append(IntSpinInput(self.parent, message, name, self.move, value, min_val, max_val)) - - def add_float_spin_input(self, name, message, value, inc_amt, precision, min_val, max_val): - self.inputs.append(FloatSpinInput(self.parent, message, name, self.move, value, - inc_amt, precision, min_val, max_val)) - - -class InterfacePane(BasePane): - def __init__(self, offset, parent, width): - BasePane.__init__(self, offset, parent, width) - self.add_header("General options", False) - - self.add_checked_input("ring_bell", "Ring system bell when a download finishes", - parent.console_config["ring_bell"]) - - self.add_header("New Console UI", True) - - self.add_checked_input("separate_complete", - "List complete torrents after incomplete regardless of sorting order", - parent.console_config["separate_complete"]) - self.add_checked_input("move_selection", "Move selection when moving torrents in the queue", - parent.console_config["move_selection"]) - - self.add_header("Legacy Mode", True) - - self.add_checked_input("ignore_duplicate_lines", "Do not store duplicate input in history", - parent.console_config["ignore_duplicate_lines"]) - self.add_checked_input("save_legacy_history", "Store and load command line history in Legacy mode", - parent.console_config["save_legacy_history"]) - - self.add_header("", False) - - self.add_checked_input("third_tab_lists_all", "Third tab lists all remaining torrents in legacy mode", - parent.console_config["third_tab_lists_all"]) - self.add_int_spin_input("torrents_per_tab_press", "Torrents per tab press", - parent.console_config["torrents_per_tab_press"], 5, 100) - - -class ColumnsPane(BasePane): - def __init__(self, offset, parent, width): - BasePane.__init__(self, offset, parent, width) - self.add_header("Columns To Display", True) - - default_prefs = deluge.ui.console.modes.alltorrents.DEFAULT_PREFS - - for cpn in deluge.ui.console.modes.alltorrents.column_pref_names: - pn = "show_%s" % cpn - # If there is no option for it, it's not togglable - # We check in default_prefs because it might still exist in config files - if pn not in default_prefs: - continue - self.add_checked_input(pn, - deluge.ui.console.modes.alltorrents.prefs_to_names[cpn], - parent.console_config[pn]) - self.add_header("Column Widths (-1 = expand)", True) - for cpn in deluge.ui.console.modes.alltorrents.column_pref_names: - pn = "%s_width" % cpn - if pn not in default_prefs: - continue - self.add_int_spin_input(pn, - deluge.ui.console.modes.alltorrents.prefs_to_names[cpn], - parent.console_config[pn], -1, 100) - - -class DownloadsPane(BasePane): - def __init__(self, offset, parent, width): - BasePane.__init__(self, offset, parent, width) - - self.add_header("Folders") - self.add_text_input("download_location", "Download To:", parent.core_config["download_location"]) - cmptxt = TextInput(self.parent, self.move, self.width, None, "move_completed_path", - parent.core_config["move_completed_path"], False) - self.add_checkedplus_input("move_completed", "Move completed to:", cmptxt, parent.core_config["move_completed"]) - copytxt = TextInput(self.parent, self.move, self.width, None, "torrentfiles_location", - parent.core_config["torrentfiles_location"], False) - self.add_checkedplus_input("copy_torrent_file", "Copy of .torrent files to:", copytxt, - parent.core_config["copy_torrent_file"]) - self.add_checked_input("del_copy_torrent_file", "Delete copy of torrent file on remove", - parent.core_config["del_copy_torrent_file"]) - - self.add_header("Options", True) - self.add_checked_input("prioritize_first_last_pieces", "Prioritize first and last pieces of torrent", - parent.core_config["prioritize_first_last_pieces"]) - self.add_checked_input("sequential_download", "", - parent.core_config["sequential_download"]) - self.add_checked_input("add_paused", "Sequential_download", parent.core_config["add_paused"]) - self.add_checked_input("pre_allocate_storage", "Pre-Allocate disk space", - parent.core_config["pre_allocate_storage"]) - - -class NetworkPane(BasePane): - def __init__(self, offset, parent, width): - BasePane.__init__(self, offset, parent, width) - self.add_header("Incomming Port") - inrand = CheckedInput(parent, "Use Random Port Active Port: %d" % parent.active_port, - "random_port", parent.core_config["random_port"]) - self.inputs.append(inrand) - listen_ports = parent.core_config["listen_ports"] - self.infrom = IntSpinInput(self.parent, " ", "listen_port", self.move, listen_ports[0], 0, 65535) - self.infrom.set_depend(inrand, True) - self.inputs.append(self.infrom) - - self.add_header("Outgoing Ports", True) - outrand = CheckedInput(parent, "Use Random Ports", "random_outgoing_ports", - parent.core_config["random_outgoing_ports"]) - self.inputs.append(outrand) - out_ports = parent.core_config["outgoing_ports"] - self.outfrom = IntSpinInput(self.parent, " From:", "out_ports_from", self.move, out_ports[0], 0, 65535) - self.outfrom.set_depend(outrand, True) - self.outto = IntSpinInput(self.parent, " To: ", "out_ports_to", self.move, out_ports[1], 0, 65535) - self.outto.set_depend(outrand, True) - self.inputs.append(self.outfrom) - self.inputs.append(self.outto) - - self.add_header("Interface", True) - self.add_text_input("listen_interface", "IP address of the interface to listen on (leave empty for default):", - parent.core_config["listen_interface"]) - - self.add_header("TOS", True) - self.add_text_input("peer_tos", "Peer TOS Byte:", parent.core_config["peer_tos"]) - - self.add_header("Network Extras") - self.add_checked_input("upnp", "UPnP", parent.core_config["upnp"]) - self.add_checked_input("natpmp", "NAT-PMP", parent.core_config["natpmp"]) - self.add_checked_input("utpex", "Peer Exchange", parent.core_config["utpex"]) - self.add_checked_input("lt_tex", "Tracker Exchange", parent.core_config["lt_tex"]) - self.add_checked_input("lsd", "LSD", parent.core_config["lsd"]) - self.add_checked_input("dht", "DHT", parent.core_config["dht"]) - - self.add_header("Encryption", True) - self.add_select_input("enc_in_policy", "Inbound:", ["Forced", "Enabled", "Disabled"], [0, 1, 2], - parent.core_config["enc_in_policy"]) - self.add_select_input("enc_out_policy", "Outbound:", ["Forced", "Enabled", "Disabled"], [0, 1, 2], - parent.core_config["enc_out_policy"]) - self.add_select_input("enc_level", "Level:", ["Handshake", "Full Stream", "Either"], [0, 1, 2], - parent.core_config["enc_level"]) - - -class BandwidthPane(BasePane): - def __init__(self, offset, parent, width): - BasePane.__init__(self, offset, parent, width) - self.add_header("Global Bandwidth Usage") - self.add_int_spin_input("max_connections_global", "Maximum Connections:", - parent.core_config["max_connections_global"], -1, 9000) - self.add_int_spin_input("max_upload_slots_global", "Maximum Upload Slots:", - parent.core_config["max_upload_slots_global"], -1, 9000) - self.add_float_spin_input("max_download_speed", "Maximum Download Speed (KiB/s):", - parent.core_config["max_download_speed"], 1.0, 1, -1.0, 60000.0) - self.add_float_spin_input("max_upload_speed", "Maximum Upload Speed (KiB/s):", - parent.core_config["max_upload_speed"], 1.0, 1, -1.0, 60000.0) - self.add_int_spin_input("max_half_open_connections", "Maximum Half-Open Connections:", - parent.core_config["max_half_open_connections"], -1, 9999) - self.add_int_spin_input("max_connections_per_second", "Maximum Connection Attempts per Second:", - parent.core_config["max_connections_per_second"], -1, 9999) - self.add_checked_input("ignore_limits_on_local_network", "Ignore limits on local network", - parent.core_config["ignore_limits_on_local_network"]) - self.add_checked_input("rate_limit_ip_overhead", "Rate Limit IP Overhead", - parent.core_config["rate_limit_ip_overhead"]) - self.add_header("Per Torrent Bandwidth Usage", True) - self.add_int_spin_input("max_connections_per_torrent", "Maximum Connections:", - parent.core_config["max_connections_per_torrent"], -1, 9000) - self.add_int_spin_input("max_upload_slots_per_torrent", "Maximum Upload Slots:", - parent.core_config["max_upload_slots_per_torrent"], -1, 9000) - self.add_float_spin_input("max_download_speed_per_torrent", "Maximum Download Speed (KiB/s):", - parent.core_config["max_download_speed_per_torrent"], 1.0, 1, -1.0, 60000.0) - self.add_float_spin_input("max_upload_speed_per_torrent", "Maximum Upload Speed (KiB/s):", - parent.core_config["max_upload_speed_per_torrent"], 1.0, 1, -1.0, 60000.0) - - -class OtherPane(BasePane): - def __init__(self, offset, parent, width): - BasePane.__init__(self, offset, parent, width) - self.add_header("System Information") - self.add_info_field(" Help us improve Deluge by sending us your", "", "") - self.add_info_field(" Python version, PyGTK version, OS and processor", "", "") - self.add_info_field(" types. Absolutely no other information is sent.", "", "") - self.add_checked_input("send_info", "Yes, please send anonymous statistics.", parent.core_config["send_info"]) - self.add_header("GeoIP Database", True) - self.add_text_input("geoip_db_location", "Location:", parent.core_config["geoip_db_location"]) - - -class DaemonPane(BasePane): - def __init__(self, offset, parent, width): - BasePane.__init__(self, offset, parent, width) - self.add_header("Port") - self.add_int_spin_input("daemon_port", "Daemon Port:", parent.core_config["daemon_port"], 0, 65535) - self.add_header("Connections", True) - self.add_checked_input("allow_remote", "Allow remote connections", parent.core_config["allow_remote"]) - self.add_header("Other", True) - self.add_checked_input("new_release_check", "Periodically check the website for new releases", - parent.core_config["new_release_check"]) - - -class QueuePane(BasePane): - def __init__(self, offset, parent, width): - BasePane.__init__(self, offset, parent, width) - self.add_header("New Torrents") - self.add_checked_input("queue_new_to_top", "Queue to top", parent.core_config["queue_new_to_top"]) - self.add_header("Active Torrents", True) - self.add_int_spin_input("max_active_limit", "Total:", parent.core_config["max_active_limit"], -1, 9999) - self.add_int_spin_input("max_active_downloading", "Downloading:", - parent.core_config["max_active_downloading"], -1, 9999) - self.add_int_spin_input("max_active_seeding", "Seeding:", - parent.core_config["max_active_seeding"], -1, 9999) - self.add_checked_input("dont_count_slow_torrents", "Ignore slow torrents", - parent.core_config["dont_count_slow_torrents"]) - self.add_checked_input("auto_manage_prefer_seeds", "Prefer seeding torrents", - parent.core_config["auto_manage_prefer_seeds"]) - self.add_header("Seeding Rotation", True) - self.add_float_spin_input("share_ratio_limit", "Share Ratio:", - parent.core_config["share_ratio_limit"], 1.0, 2, -1.0, 100.0) - self.add_float_spin_input("seed_time_ratio_limit", "Time Ratio:", - parent.core_config["seed_time_ratio_limit"], 1.0, 2, -1.0, 100.0) - self.add_int_spin_input("seed_time_limit", "Time (m):", parent.core_config["seed_time_limit"], -1, 10000) - seedratio = FloatSpinInput(self.parent, "", "stop_seed_ratio", self.move, - parent.core_config["stop_seed_ratio"], 0.1, 2, 0.5, 100.0) - self.add_checkedplus_input("stop_seed_at_ratio", "Share Ratio Reached:", seedratio, - parent.core_config["stop_seed_at_ratio"]) - self.add_checked_input("remove_seed_at_ratio", "Remove torrent (Unchecked pauses torrent)", - parent.core_config["remove_seed_at_ratio"]) - - -class ProxyPane(BasePane): - def __init__(self, offset, parent, width): - BasePane.__init__(self, offset, parent, width) - self.add_header("Proxy Settings") - self.add_header("Proxy", True) - proxy = parent.core_config["proxy"] - self.add_int_spin_input("proxy_type", "Type:", proxy["type"], 0, 5) - self.add_info_field(" 0: None 1: Socks4 2: Socks5", "", "") - self.add_info_field(" 3: Socks5 Auth 4: HTTP 5: HTTP Auth", "", "") - self.add_text_input("proxy_username", "Username:", proxy["username"]) - self.add_text_input("proxy_password", "Password:", proxy["password"]) - self.add_text_input("proxy_hostname", "Hostname:", proxy["hostname"]) - self.add_int_spin_input("proxy_port", "Port:", proxy["port"], 0, 65535) - self.add_checked_input("proxy_hostnames", "Proxy hostnames", proxy["proxy_hostnames"]) - self.add_checked_input("proxy_peer_connections", "Proxy peer connections", proxy["proxy_peer_connections"]) - - self.add_header("I2P Proxy", True) - i2p_proxy = parent.core_config["i2p_proxy"] - self.add_text_input("i2p_hostname", "Hostname:", i2p_proxy["hostname"]) - self.add_int_spin_input("i2p_port", "Port:", i2p_proxy["port"], 0, 65535) - self.add_checked_input("anonymous_mode", "Anonymous Mode", parent.core_config["anonymous_mode"]) - - -class CachePane(BasePane): - def __init__(self, offset, parent, width): - BasePane.__init__(self, offset, parent, width) - self.add_header("Settings") - self.add_int_spin_input("cache_size", "Cache Size (16 KiB blocks):", parent.core_config["cache_size"], 0, 99999) - self.add_int_spin_input("cache_expiry", "Cache Expiry (seconds):", parent.core_config["cache_expiry"], 1, 32000) - self.add_header("Status (press 'r' to refresh status)", True) - self.add_header(" Write") - self.add_info_field(" Blocks Written:", self.parent.status["blocks_written"], "blocks_written") - self.add_info_field(" Writes:", self.parent.status["writes"], "writes") - self.add_info_field(" Write Cache Hit Ratio:", "%.2f" % self.parent.status["write_hit_ratio"], - "write_hit_ratio") - self.add_header(" Read") - self.add_info_field(" Blocks Read:", self.parent.status["blocks_read"], "blocks_read") - self.add_info_field(" Blocks Read hit:", self.parent.status["blocks_read_hit"], "blocks_read_hit") - self.add_info_field(" Reads:", self.parent.status["reads"], "reads") - self.add_info_field(" Read Cache Hit Ratio:", "%.2f" % self.parent.status["read_hit_ratio"], "read_hit_ratio") - self.add_header(" Size") - self.add_info_field(" Cache Size:", self.parent.status["cache_size"], "cache_size") - self.add_info_field(" Read Cache Size:", self.parent.status["read_cache_size"], "read_cache_size") - - def update_cache_status(self, status): - for ipt in self.inputs: - if isinstance(ipt, InfoField): - try: - ipt.set_value(status[ipt.name]) - except KeyError: - pass diff --git a/deluge/ui/console/modes/preferences.py b/deluge/ui/console/modes/preferences.py deleted file mode 100644 index f589916ad..000000000 --- a/deluge/ui/console/modes/preferences.py +++ /dev/null @@ -1,296 +0,0 @@ -# -*- coding: utf-8 -*- -# -# 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.ui.client import client -from deluge.ui.console.modes.basemode import BaseMode -from deluge.ui.console.modes.input_popup import Popup, SelectInput -from deluge.ui.console.modes.popup import MessagePopup -from deluge.ui.console.modes.preference_panes import (BandwidthPane, CachePane, ColumnsPane, DaemonPane, DownloadsPane, - InterfacePane, NetworkPane, OtherPane, ProxyPane, QueuePane) - -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. - -- 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 - -- 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. - - -""" -HELP_LINES = HELP_STR.split("\n") - - -class ZONE(object): - CATEGORIES = 0 - PREFRENCES = 1 - ACTIONS = 2 - - -class Preferences(BaseMode): - def __init__(self, parent_mode, core_config, console_config, active_port, status, stdscr, encoding=None): - self.parent_mode = parent_mode - self.categories = [_("Interface"), _("Columns"), _("Downloads"), _("Network"), _("Bandwidth"), - _("Other"), _("Daemon"), _("Queue"), _("Proxy"), _("Cache")] # , _("Plugins")] - self.cur_cat = 0 - self.popup = None - self.messages = deque() - self.action_input = None - - self.core_config = core_config - self.console_config = console_config - self.active_port = active_port - self.status = status - - self.active_zone = ZONE.CATEGORIES - - # how wide is the left 'pane' with categories - self.div_off = 15 - - BaseMode.__init__(self, stdscr, encoding, False) - - # create the panes - self.__calc_sizes() - - self.action_input = SelectInput(self, None, None, ["Cancel", "Apply", "OK"], [0, 1, 2], 0) - self.refresh() - - def __calc_sizes(self): - self.prefs_width = self.cols - self.div_off - 1 - self.prefs_height = self.rows - 4 - # Needs to be same order as self.categories - self.panes = [ - InterfacePane(self.div_off + 2, self, self.prefs_width), - ColumnsPane(self.div_off + 2, self, self.prefs_width), - DownloadsPane(self.div_off + 2, self, self.prefs_width), - NetworkPane(self.div_off + 2, self, self.prefs_width), - BandwidthPane(self.div_off + 2, self, self.prefs_width), - OtherPane(self.div_off + 2, self, self.prefs_width), - DaemonPane(self.div_off + 2, self, self.prefs_width), - QueuePane(self.div_off + 2, self, self.prefs_width), - ProxyPane(self.div_off + 2, self, self.prefs_width), - CachePane(self.div_off + 2, self, self.prefs_width) - ] - - def __draw_catetories(self): - for i, category in enumerate(self.categories): - if i == self.cur_cat and self.active_zone == ZONE.CATEGORIES: - self.add_string(i + 1, "- {!black,white,bold!}%s" % category, pad=False) - elif i == self.cur_cat: - self.add_string(i + 1, "- {!black,white!}%s" % category, pad=False) - else: - self.add_string(i + 1, "- %s" % category) - self.stdscr.vline(1, self.div_off, "|", self.rows - 2) - - def __draw_preferences(self): - self.panes[self.cur_cat].render(self, self.stdscr, self.prefs_width, self.active_zone == ZONE.PREFRENCES) - - def __draw_actions(self): - selected = self.active_zone == ZONE.ACTIONS - self.stdscr.hline(self.rows - 3, self.div_off + 1, "_", self.cols) - self.action_input.render(self.stdscr, self.rows - 2, self.cols, selected, self.cols - 22) - - def on_resize(self, *args): - BaseMode.on_resize_norefresh(self, *args) - self.__calc_sizes() - - # Always refresh Legacy(it will also refresh AllTorrents), otherwise it will bug deluge out - legacy = component.get("LegacyUI") - legacy.on_resize(*args) - self.stdscr.erase() - self.refresh() - - def refresh(self): - if self.popup is None and self.messages: - title, msg = self.messages.popleft() - self.popup = MessagePopup(self, title, msg) - - self.stdscr.erase() - self.add_string(0, self.statusbars.topbar) - hstr = "%sPress [h] for help" % (" " * (self.cols - len(self.statusbars.bottombar) - 10)) - self.add_string(self.rows - 1, "%s%s" % (self.statusbars.bottombar, hstr)) - - self.__draw_catetories() - self.__draw_actions() - - # do this last since it moves the cursor - self.__draw_preferences() - - if component.get("ConsoleUI").screen != self: - return - - self.stdscr.noutrefresh() - - if self.popup: - self.popup.refresh() - - curses.doupdate() - - def __category_read(self, c): - # Navigate prefs - if c == curses.KEY_UP: - self.cur_cat = max(0, self.cur_cat - 1) - elif c == curses.KEY_DOWN: - self.cur_cat = min(len(self.categories) - 1, self.cur_cat + 1) - - def __prefs_read(self, c): - self.panes[self.cur_cat].handle_read(c) - - def __apply_prefs(self): - new_core_config = {} - for pane in self.panes: - if not isinstance(pane, InterfacePane) and not isinstance(pane, ColumnsPane): - 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.keys(): - # 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) or isinstance(pane, ColumnsPane): - pane.add_config_values(new_console_config) - for key in new_console_config.keys(): - # The values do not match so this needs to be updated - if self.console_config[key] != new_console_config[key]: - self.console_config[key] = new_console_config[key] - didupdate = True - if didupdate: - # changed something, save config and tell alltorrents - self.console_config.save() - self.parent_mode.update_config() - - 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 == curses.KEY_ENTER or c == 10: - # take action - if self.action_input.selidx == 0: # Cancel - self.back_to_parent() - elif self.action_input.selidx == 1: # Apply - self.__apply_prefs() - client.core.get_config().addCallback(self.__update_preferences) - elif self.action_input.selidx == 2: # OK - self.__apply_prefs() - self.back_to_parent() - - def back_to_parent(self): - self.stdscr.erase() - component.get("ConsoleUI").set_mode(self.parent_mode) - self.parent_mode.resume() - - def read_input(self): - c = self.stdscr.getch() - - if self.popup: - if self.popup.handle_read(c): - self.popup = None - self.refresh() - return - - if c > 31 and c < 256: - if chr(c) == "Q": - from twisted.internet import reactor - if client.connected(): - def on_disconnect(result): - reactor.stop() - client.disconnect().addCallback(on_disconnect) - else: - reactor.stop() - return - elif chr(c) == "h": - self.popup = Popup(self, "Preferences Help") - for l in HELP_LINES: - self.popup.add_line(l) - - if c == 9: - self.active_zone += 1 - if self.active_zone > ZONE.ACTIONS: - self.active_zone = ZONE.CATEGORIES - elif c == 27 and self.active_zone == ZONE.CATEGORIES: - self.back_to_parent() - elif c == curses.KEY_BTAB: - self.active_zone -= 1 - if self.active_zone < ZONE.CATEGORIES: - self.active_zone = ZONE.ACTIONS - - elif c == 114 and isinstance(self.panes[self.cur_cat], CachePane): - client.core.get_cache_status().addCallback(self.panes[self.cur_cat].update_cache_status) - - else: - if self.active_zone == ZONE.CATEGORIES: - self.__category_read(c) - elif self.active_zone == ZONE.PREFRENCES: - self.__prefs_read(c) - elif self.active_zone == ZONE.ACTIONS: - self.__actions_read(c) - - 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 000000000..7963d0366 --- /dev/null +++ b/deluge/ui/console/modes/preferences/__init__.py @@ -0,0 +1 @@ +from deluge.ui.console.modes.preferences.preferences import Preferences # NOQA 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 000000000..4c36510a7 --- /dev/null +++ b/deluge/ui/console/modes/preferences/preference_panes.py @@ -0,0 +1,447 @@ +# -*- coding: utf-8 -*- +# +# 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_ip +from deluge.decorators import overrides +from deluge.ui.client import client +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(): + # gross, have to special case in/out ports since they are tuples + if ipt.name in ("listen_ports_to", "listen_ports_from", "out_ports_from", "out_ports_to", + "i2p_port", "i2p_hostname", "proxy_type", "proxy_username", "proxy_hostnames", + "proxy_password", "proxy_hostname", "proxy_port", "proxy_peer_connections", + "listen_interface"): + if ipt.name == "listen_ports_to": + conf_dict["listen_ports"] = (self.infrom.get_value(), self.into.get_value()) + elif ipt.name == "out_ports_to": + conf_dict["outgoing_ports"] = (self.outfrom.get_value(), self.outto.get_value()) + elif ipt.name == "listen_interface": + interface = ipt.get_value().strip() + if is_ip(interface) or not interface: + conf_dict["listen_interface"] = interface + elif ipt.name == "i2p_port": + conf_dict.setdefault("i2p_proxy", {})["port"] = ipt.get_value() + elif ipt.name == "i2p_hostname": + conf_dict.setdefault("i2p_proxy", {})["hostname"] = ipt.get_value() + elif 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() + 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"]) + from deluge.ui.util import lang + langs = lang.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(_("Interface"), space_above=True) + self.add_text_input("listen_interface", "%s:" % _("IP address of the interface to listen on " + "(leave empty for default)"), + core_conf["listen_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("lt_tex", "Tracker Exchange", core_conf["lt_tex"]) + 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): + self.add_header(_("Proxy Settings")) + self.add_header(_("Proxy"), space_above=True) + proxy = core_conf["proxy"] + self.add_int_spin_input("proxy_type", "%s:" % _("Type"), proxy["type"], min_val=0, max_val=5) + self.add_info_field("proxy_info_1", " 0: None 1: Socks4 2: Socks5", "") + self.add_info_field("proxy_info_2", " 3: Socks5 Auth 4: HTTP 5: HTTP Auth", "") + 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 peer connections"), proxy["proxy_peer_connections"]) + + self.add_header(_("I2P Proxy"), space_above=True) + self.add_text_input("i2p_hostname", "%s:" % _("Hostname"), + core_conf["i2p_proxy"]["hostname"]) + self.add_int_spin_input("i2p_port", + "%s:" % _("Port"), core_conf["i2p_proxy"]["port"], min_val=0, max_val=65535) + self.add_checked_input("anonymous_mode", _("Anonymous Mode"), core_conf["anonymous_mode"]) + + +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["blocks_written"]) + self.add_info_field("writes", " %s:" % _("Writes"), status["writes"]) + 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["blocks_read"]) + self.add_info_field("blocks_read_hit", + " %s:" % _("Blocks Read hit"), status["blocks_read_hit"]) + self.add_info_field("reads", + " %s:" % _("Reads"), status["reads"]) + 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["cache_size"]) + self.add_info_field("read_cache_size", + " %s:" % _("Read Cache Size"), status["read_cache_size"]) + + @overrides(BasePreferencePane) + def update(self, active): + if active: + client.core.get_cache_status().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 000000000..b083cd915 --- /dev/null +++ b/deluge/ui/console/modes/preferences/preferences.py @@ -0,0 +1,333 @@ +# -*- coding: utf-8 -*- +# +# 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(object): + length = 3 + CATEGORIES, PREFRENCES, ACTIONS = 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, "_", 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_char(c): + if chr(c) == "Q": + from twisted.internet import reactor + if client.connected(): + def on_disconnect(result): + reactor.stop() + client.disconnect().addCallback(on_disconnect) + else: + reactor.stop() + return + elif chr(c) == "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/torrent_actions.py b/deluge/ui/console/modes/torrent_actions.py deleted file mode 100644 index d68730f41..000000000 --- a/deluge/ui/console/modes/torrent_actions.py +++ /dev/null @@ -1,328 +0,0 @@ -# -*- coding: utf-8 -*- -# -# 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.ui.client import client -from deluge.ui.console import colors -from deluge.ui.console.modes.input_popup import InputPopup -from deluge.ui.console.modes.popup import Popup, SelectablePopup - -log = logging.getLogger(__name__) - -torrent_options = [ - ("max_download_speed", float), - ("max_upload_speed", float), - ("max_connections", int), - ("max_upload_slots", int), - ("prioritize_first_last", bool), - ("sequential_download", bool), - ("is_auto_managed", bool), - ("stop_at_ratio", bool), - ("stop_ratio", float), - ("remove_at_ratio", bool), - ("move_on_completed", bool), - ("move_on_completed_path", str) -] - -torrent_options_to_names = { - "max_download_speed": "Max DL speed", - "max_upload_speed": "Max UL speed", - "max_connections": "Max connections", - "max_upload_slots": "Max upload slots", - "prioritize_first_last": "Prioritize first/last pieces", - "sequential_download": "Sequential download", - "is_auto_managed": "Is auto managed?", - "stop_at_ratio": "Stop at ratio", - "stop_ratio": "Seeding ratio limit", - "remove_at_ratio": "Remove after reaching ratio", - "move_on_completed": "Move torrent after completion", - "move_on_completed_path": "Folder to move the torrent to" -} - - -class ACTION(object): - PAUSE = 0 - RESUME = 1 - REANNOUNCE = 2 - EDIT_TRACKERS = 3 - RECHECK = 4 - REMOVE = 5 - REMOVE_DATA = 6 - REMOVE_NODATA = 7 - DETAILS = 8 - MOVE_STORAGE = 9 - QUEUE = 10 - QUEUE_TOP = 11 - QUEUE_UP = 12 - QUEUE_DOWN = 13 - QUEUE_BOTTOM = 14 - TORRENT_OPTIONS = 15 - - -def action_error(error, mode): - rerr = error.value - mode.report_message("An Error Occurred", "%s got error %s: %s" % ( - rerr.method, rerr.exception_type, rerr.exception_msg)) - mode.refresh() - - -def torrent_action(idx, data, mode, ids): - if ids: - if data == ACTION.PAUSE: - log.debug("Pausing torrents: %s", ids) - client.core.pause_torrent(ids).addErrback(action_error, mode) - elif data == ACTION.RESUME: - log.debug("Resuming torrents: %s", ids) - client.core.resume_torrent(ids).addErrback(action_error, mode) - elif data == ACTION.QUEUE: - def do_queue(idx, qact, mode, ids): - def move_selection(r): - if mode.config["move_selection"]: - queue_length = 0 - selected_num = 0 - for tid in mode.curstate: - tq = mode.curstate.get(tid)["queue"] - if tq != -1: - queue_length += 1 - if tq in mode.marked: - selected_num += 1 - if qact == ACTION.QUEUE_TOP: - if mode.marked: - mode.cursel = 1 + sorted(mode.marked).index(mode.cursel) - else: - mode.cursel = 1 - mode.marked = range(1, selected_num + 1) - elif qact == ACTION.QUEUE_UP: - mode.cursel = max(1, mode.cursel - 1) - mode.marked = [marked - 1 for marked in mode.marked] - mode.marked = [marked for marked in mode.marked if marked > 0] - elif qact == ACTION.QUEUE_DOWN: - mode.cursel = min(queue_length, mode.cursel + 1) - mode.marked = [marked + 1 for marked in mode.marked] - mode.marked = [marked for marked in mode.marked if marked <= queue_length] - elif qact == ACTION.QUEUE_BOTTOM: - if mode.marked: - mode.cursel = queue_length - selected_num + 1 + sorted(mode.marked).index(mode.cursel) - else: - mode.cursel = queue_length - mode.marked = range(queue_length - selected_num + 1, queue_length + 1) - - if qact == ACTION.QUEUE_TOP: - log.debug("Queuing torrents top") - client.core.queue_top(ids).addCallback(move_selection) - elif qact == ACTION.QUEUE_UP: - log.debug("Queuing torrents up") - client.core.queue_up(ids).addCallback(move_selection) - elif qact == ACTION.QUEUE_DOWN: - log.debug("Queuing torrents down") - client.core.queue_down(ids).addCallback(move_selection) - elif qact == ACTION.QUEUE_BOTTOM: - log.debug("Queuing torrents bottom") - client.core.queue_bottom(ids).addCallback(move_selection) - - if len(ids) == 1: - mode.clear_marks() - return True - popup = SelectablePopup(mode, "Queue Action", do_queue, (mode, ids)) - popup.add_line("_Top", data=ACTION.QUEUE_TOP) - popup.add_line("_Up", data=ACTION.QUEUE_UP) - popup.add_line("_Down", data=ACTION.QUEUE_DOWN) - popup.add_line("_Bottom", data=ACTION.QUEUE_BOTTOM) - mode.set_popup(popup) - return False - elif data == ACTION.REMOVE: - def do_remove(data): - if not data: - return - mode.clear_marks() - - remove_data = data["remove_files"] - - def on_removed_finished(errors): - if errors: - error_msgs = "" - for t_id, e_msg in errors: - error_msgs += "Error removing torrent %s : %s\n" % (t_id, e_msg) - mode.report_message("Error(s) occured when trying to delete torrent(s).", error_msgs) - mode.refresh() - - d = client.core.remove_torrents(ids, remove_data) - d.addCallback(on_removed_finished) - - def got_status(status): - return (status["name"], status["state"]) - - callbacks = [] - for tid in ids: - d = client.core.get_torrent_status(tid, ["name", "state"]) - callbacks.append(d.addCallback(got_status)) - - def finish_up(status): - status = [t_status[1] for t_status in status] - - if len(ids) == 1: - rem_msg = "{!info!}Removing the following torrent:{!input!}" - else: - rem_msg = "{!info!}Removing the following torrents:{!input!}" - - for i, (name, state) in enumerate(status): - color = colors.state_color[state] - rem_msg += "\n %s* {!input!}%s" % (color, name) - if i == 5: - if i < len(status): - rem_msg += "\n {!red!}And %i more" % (len(status) - 5) - break - - popup = InputPopup(mode, "(Esc to cancel, Enter to remove)", close_cb=do_remove) - popup.add_text(rem_msg) - popup.add_spaces(1) - popup.add_select_input("{!info!}Torrent files:", "remove_files", - ["Keep", "Remove"], [False, True], False) - mode.set_popup(popup) - defer.DeferredList(callbacks).addCallback(finish_up) - return False - elif data == ACTION.MOVE_STORAGE: - def do_move(res): - import os.path - if os.path.exists(res["path"]) and not os.path.isdir(res["path"]): - mode.report_message("Cannot Move Download Folder", - "{!error!}%s exists and is not a directory" % res["path"]) - else: - log.debug("Moving %s to: %s", ids, res["path"]) - client.core.move_storage(ids, res["path"]).addErrback(action_error, mode) - if len(ids) == 1: - mode.clear_marks() - return True - popup = InputPopup(mode, "Move Download Folder (Esc to cancel)", close_cb=do_move) - popup.add_text_input("Enter path to move to:", "path") - mode.set_popup(popup) - return False - elif data == ACTION.RECHECK: - log.debug("Rechecking torrents: %s", ids) - client.core.force_recheck(ids).addErrback(action_error, mode) - elif data == ACTION.REANNOUNCE: - log.debug("Reannouncing torrents: %s", ids) - client.core.force_reannounce(ids).addErrback(action_error, mode) - elif data == ACTION.DETAILS: - log.debug("Torrent details") - tid = mode.current_torrent_id() - if tid: - mode.show_torrent_details(tid) - else: - log.error("No current torrent in _torrent_action, this is a bug") - elif data == ACTION.TORRENT_OPTIONS: - mode.popup = Popup(mode, "Torrent options") - mode.popup.add_line("Querying core, please wait...") - - torrents = ids - - options = {} - - def _do_set_torrent_options(ids, result): - options = {} - for opt in result: - if result[opt] not in ["multiple", None]: - options[opt] = result[opt] - client.core.set_torrent_options(ids, options) - for tid in ids: - if "move_on_completed_path" in options: - client.core.set_torrent_move_completed_path(tid, options["move_on_completed_path"]) - if "move_on_completed" in options: - client.core.set_torrent_move_completed(tid, options["move_on_completed"]) - if "is_auto_managed" in options: - client.core.set_torrent_auto_managed(tid, options["is_auto_managed"]) - if "remove_at_ratio" in options: - client.core.set_torrent_remove_at_ratio(tid, options["remove_at_ratio"]) - if "prioritize_first_last" in options: - client.core.set_torrent_prioritize_first_last(tid, options["prioritize_first_last"]) - - 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): - def cb(result, ids=ids): - return _do_set_torrent_options(ids, result) - - option_popup = InputPopup(mode, "Set torrent options (Esc to cancel)", close_cb=cb, height_req=22) - - for (field, field_type) in torrent_options: - caption = "{!info!}" + torrent_options_to_names[field] - value = options[field] - if field_type == str: - if not isinstance(value, basestring): - value = str(value) - option_popup.add_text_input(caption, field, value) - elif field_type == bool: - if options[field] == "multiple": - choices = ( - ["Yes", "No", "Mixed"], - [True, False, None], - 2 - ) - else: - choices = ( - ["Yes", "No"], - [True, False], - [True, False].index(options[field]) - ) - option_popup.add_select_input(caption, field, choices[0], choices[1], choices[2]) - elif field_type == float: - option_popup.add_float_spin_input(caption, field, value, min_val=-1) - elif field_type == int: - option_popup.add_int_spin_input(caption, field, value, min_val=-1) - - mode.set_popup(option_popup) - mode.refresh() - - callbacks = [] - - field_list = [torrent_option[0] for torrent_option in torrent_options] - - for tid in torrents: - deferred = component.get("SessionProxy").get_torrent_status(tid, field_list) - callbacks.append(deferred.addCallback(on_torrent_status)) - - callbacks = defer.DeferredList(callbacks) - callbacks.addCallback(create_popup) - - if len(ids) == 1: - mode.clear_marks() - return True - - -# Creates the popup. mode is the calling mode, tids is a list of torrents to take action upon -def torrent_actions_popup(mode, tids, details=False, action=None): - if action is not None: - torrent_action(-1, action, mode, tids) - return - popup = SelectablePopup(mode, "Torrent Actions", torrent_action, (mode, tids)) - popup.add_line("_Pause", data=ACTION.PAUSE) - popup.add_line("_Resume", data=ACTION.RESUME) - if details: - popup.add_divider() - popup.add_line("Queue", data=ACTION.QUEUE) - popup.add_divider() - popup.add_line("_Update Tracker", data=ACTION.REANNOUNCE) - popup.add_divider() - popup.add_line("Remo_ve Torrent", data=ACTION.REMOVE) - popup.add_line("_Force Recheck", data=ACTION.RECHECK) - popup.add_line("_Move Download Folder", data=ACTION.MOVE_STORAGE) - popup.add_divider() - if details: - popup.add_line("Torrent _Details", data=ACTION.DETAILS) - popup.add_line("Torrent _Options", data=ACTION.TORRENT_OPTIONS) - mode.set_popup(popup) diff --git a/deluge/ui/console/modes/torrentdetail.py b/deluge/ui/console/modes/torrentdetail.py index e3764c9bb..7957faf55 100644 --- a/deluge/ui/console/modes/torrentdetail.py +++ b/deluge/ui/console/modes/torrentdetail.py @@ -10,17 +10,18 @@ from __future__ import division import logging -from collections import deque import deluge.component as component -from deluge.common import FILE_PRIORITY, fdate, fsize, ftime +from deluge.common import FILE_PRIORITY, fsize +from deluge.decorators import overrides from deluge.ui.client import client -from deluge.ui.console import colors -from deluge.ui.console.modes import format_utils from deluge.ui.console.modes.basemode import BaseMode -from deluge.ui.console.modes.input_popup import InputPopup -from deluge.ui.console.modes.popup import MessagePopup, SelectablePopup -from deluge.ui.console.modes.torrent_actions import ACTION, torrent_actions_popup +from deluge.ui.console.modes.torrentlist.torrentactions import ACTION, torrent_actions_popup +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.utils import colors +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 @@ -63,42 +64,35 @@ download priority of selected files and folders. """ -class TorrentDetail(BaseMode, component.Component): - def __init__(self, alltorrentmode, torrentid, stdscr, console_config, encoding=None): +class TorrentDetail(BaseMode, PopupsHandler): + def __init__(self, parent_mode, stdscr, console_config, encoding=None): + PopupsHandler.__init__(self) self.console_config = console_config - self.alltorrentmode = alltorrentmode - self.torrentid = torrentid + self.parent_mode = parent_mode + self.torrentid = None self.torrent_state = None - self.popup = None - self.messages = deque() 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"] - + "total_wanted", "tracker_host", "owner", "seed_rank", "last_seen_complete", + "completed_time"] 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) - component.Component.__init__(self, "TorrentDetail", 1, depend=["SessionProxy"]) - self.column_names = ["Filename", "Size", "Progress", "Priority"] self.__update_columns() - component.start(["TorrentDetail"]) - self._listing_start = self.rows // 2 self._listing_space = self._listing_start - self._listing_start @@ -106,18 +100,44 @@ class TorrentDetail(BaseMode, component.Component): client.register_event_handler("TorrentFolderRenamedEvent", self._on_torrentfolderrenamed_event) client.register_event_handler("TorrentRemovedEvent", self._on_torrentremoved_event) - curses.curs_set(0) + util.safe_curs_set(util.Curser.INVISIBLE) self.stdscr.notimeout(0) - # component start/update - def start(self): - component.get("SessionProxy").get_torrent_status(self.torrentid, self._status_keys).addCallback(self.set_state) + def set_torrent_id(self, torrentid): + self.torrentid = torrentid + self.file_list = None - def update(self): - component.get("SessionProxy").get_torrent_status(self.torrentid, self._status_keys).addCallback(self.set_state) + 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): - log.debug("got state") if state.get("files"): self.full_names = dict([(x["index"], x["path"]) for x in state["files"]]) @@ -130,11 +150,12 @@ class TorrentDetail(BaseMode, component.Component): ("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"]) - self._status_keys.remove("files") 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 @@ -170,7 +191,7 @@ class TorrentDetail(BaseMode, component.Component): if not cur or path != cur[-1][0]: child_list = [] if path == paths[-1]: - file_progress = format_utils.format_progress(progress[torrent_file["index"]] * 100) + 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 @@ -184,6 +205,7 @@ class TorrentDetail(BaseMode, component.Component): 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 @@ -207,10 +229,10 @@ class TorrentDetail(BaseMode, component.Component): for f in fs: if f[3]: # dir, has some children bd = self.__fill_progress(f[3], progs) - f[5] = format_utils.format_progress(bd / f[2] * 100) + 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_utils.format_progress(progs[f[1]] * 100) + f[5] = format_progress(progs[f[1]] * 100) tb += bd return tb @@ -242,16 +264,6 @@ class TorrentDetail(BaseMode, component.Component): 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 report_message(self, title, message): - self.messages.append((title, message)) - - def clear_marks(self): - self.marked = {} - - def set_popup(self, pu): - self.popup = pu - self.refresh() - def _on_torrentremoved_event(self, torrent_id): if torrent_id == self.torrentid: self.back_to_overview() @@ -343,10 +355,10 @@ class TorrentDetail(BaseMode, component.Component): else: # file xchar = "-" - r = format_utils.format_row(["%s%s %s" % (" " * depth, xchar, fl[0]), - fsize(fl[2]), fl[5], - format_utils.format_priority(fl[6])], - self.column_widths) + r = format_row(["%s%s %s" % (" " * depth, xchar, fl[0]), + fsize(fl[2]), fl[5], + format_priority(fl[6])], + self.column_widths) self.add_string(off, "%s%s" % (color_string, r), trim=False) off += 1 @@ -395,153 +407,109 @@ class TorrentDetail(BaseMode, component.Component): length += self.__get_contained_files_count(element[3]) return length - def on_resize(self, *args): - BaseMode.on_resize_norefresh(self, *args) - - # Always refresh Legacy(it will also refresh AllTorrents), otherwise it will bug deluge out - legacy = component.get("LegacyUI") - legacy.on_resize(*args) - - self.__update_columns() - if self.popup: - self.popup.handle_resize() - - self._listing_start = self.rows // 2 - self.refresh() - - def render_header(self, off): + def render_header(self, row): status = self.torrent_state - up_color = colors.state_color["Seeding"] - down_color = colors.state_color["Downloading"] + 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 = "%s%s: %s%s" % (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 - s = "{!info!}Name: {!input!}%s" % status["name"] - self.add_string(off, s) - off += 1 + row = add_field("name", row) + # State + row = add_field("state", row) # Print DL info and ETA - if status["download_payload_rate"] > 0: - s = "%sDownloading: {!input!}" % down_color - else: - s = "{!info!}Downloaded: {!input!}" - s += fsize(status["all_time_download"]) + 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!}@ %s%s" % (down_color, fsize(status["download_payload_rate"])) - s += "{!info!} ETA: {!input!}%s" % format_utils.format_time(status["eta"]) - self.add_string(off, s) - off += 1 + s += " {!yellow!}@ %s%s" % (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 = "%sUploading: {!input!}" % up_color - else: - s = "{!info!}Uploaded: {!input!}" - s += fsize(status["total_uploaded"]) - if status["upload_payload_rate"] > 0: - s += " {!yellow!}@ %s%s" % (up_color, fsize(status["upload_payload_rate"])) - ratio_str = format_utils.format_float(status["ratio"]) - if ratio_str == "-": - ratio_str = "inf" - s += " {!info!}Ratio: {!input!}%s" % ratio_str - self.add_string(off, s) - off += 1 + s += " {!yellow!}@ %s%s" % (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!}Seeds:{!green!} %s {!input!}(%s)" % (status["num_seeds"], status["total_seeds"]) - self.add_string(off, s) - off += 1 - s = "{!info!}Peers:{!red!} %s {!input!}(%s)" % (status["num_peers"], status["total_peers"]) - self.add_string(off, s) - off += 1 + s = "{!info!}%s:{!green!} %s {!input!}(%s)" % (torrent_data_fields["seeds"]["name"], + status["num_seeds"], status["total_seeds"]) + row = self.add_string(row, s) + s = "{!info!}%s:{!red!} %s {!input!}(%s)" % (torrent_data_fields["peers"]["name"], + status["num_peers"], status["total_peers"]) + row = self.add_string(row, s) # Tracker - if status["message"] == "OK": - color = "{!green!}" - else: - color = "{!red!}" - s = "{!info!}Tracker: {!magenta!}%s{!input!} says \"%s%s{!input!}\"" % ( - status["tracker_host"], color, status["message"]) - self.add_string(off, s) - off += 1 + tracker_color = "{!green!}" if status["message"] == "OK" else "{!red!}" + s = "{!info!}%s: {!magenta!}%s{!input!} says \"%s%s{!input!}\"" % ( + torrent_data_fields["tracker"]["name"], status["tracker_host"], tracker_color, status["message"]) + row = self.add_string(row, s) # Pieces and availability - s = "{!info!}Pieces: {!yellow!}%s {!input!}x {!yellow!}%s" % ( - status["num_pieces"], fsize(status["piece_length"])) + s = "{!info!}%s: {!yellow!}%s {!input!}x {!yellow!}%s" % ( + torrent_data_fields["pieces"]["name"], status["num_pieces"], fsize(status["piece_length"])) if status["distributed_copies"]: - s += " {!info!}Availability: {!input!}%s" % format_utils.format_float(status["distributed_copies"]) - self.add_string(off, s) - off += 1 + s += "{!info!}%s: {!input!}%s" % (torrent_data_fields["seed_rank"]["name"], status["seed_rank"]) + row = self.add_string(row, s) # Time added - s = "{!info!}Added: {!input!}%s" % fdate(status["time_added"]) - self.add_string(off, s) - off += 1 - + row = add_field("time_added", row) # Time active - s = "{!info!}Time active: {!input!}%s" % (ftime(status["active_time"])) + row = add_field("active_time", row) if status["seeding_time"]: - s += ", {!cyan!}%s{!input!} seeding" % (ftime(status["seeding_time"])) - self.add_string(off, s) - off += 1 - + row = add_field("seeding_time", row) # Download Folder - s = "{!info!}Download Folder: {!input!}%s" % status["download_location"] - self.add_string(off, s) - off += 1 - + row = add_field("download_location", row) + # Seed Rank + row = add_field("seed_rank", row) + # Last seen complete + row = add_field("last_seen_complete", row) # Owner if status["owner"]: - s = "{!info!}Owner: {!input!}%s" % status["owner"] - - return off + row = add_field("owner", row) + return row + @overrides(BaseMode) def refresh(self, lines=None): - # show a message popup if there's anything queued - if self.popup is None and self.messages: - title, msg = self.messages.popleft() - self.popup = MessagePopup(self, title, msg) - # Update the status bars self.stdscr.erase() - self.add_string(0, self.statusbars.topbar) + self.draw_statusbars() - # This will quite likely fail when switching modes - try: - rf = format_utils.remove_formatting - string = self.statusbars.bottombar - hstr = "Press {!magenta,blue,bold!}[h]{!status!} for help" - - string += " " * (self.cols - len(rf(string)) - len(rf(hstr))) + hstr - - self.add_string(self.rows - 1, string) - except Exception as ex: - log.debug("Exception caught: %s", ex) - - off = 1 + row = 1 if self.torrent_state: - off = self.render_header(off) + row = self.render_header(row) else: self.add_string(1, "Waiting for torrent state") - off += 1 + row += 1 if self.files_sep: - self.add_string(off, self.files_sep) - off += 1 + self.add_string(row, self.files_sep) + row += 1 - self._listing_start = off + self._listing_start = row self._listing_space = self.rows - self._listing_start - self.add_string(off, self.column_string) + self.add_string(row, self.column_string) if self.file_list: - off += 1 + row += 1 self.more_to_draw = False - self.draw_files(self.file_list, 0, off, 0) + self.draw_files(self.file_list, 0, row, 0) - if component.get("ConsoleUI").screen != self: + if not component.get("ConsoleUI").is_active_mode(self): return self.stdscr.noutrefresh() @@ -576,14 +544,6 @@ class TorrentDetail(BaseMode, component.Component): self.file_off = min(self.file_off, self.current_file_idx) self.refresh() - def back_to_overview(self): - component.stop(["TorrentDetail"]) - component.deregister(self) - self.stdscr.erase() - component.get("ConsoleUI").set_mode(self.alltorrentmode) - self.alltorrentmode._go_top = False - self.alltorrentmode.resume() - # 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): @@ -600,13 +560,11 @@ class TorrentDetail(BaseMode, component.Component): # not selected, just keep old priority ret_list.append((f[1], f[6])) - def do_priority(self, idx, data, was_empty): + def do_priority(self, name, data, was_empty): plist = [] self.build_prio_list(self.file_list, plist, -1, data) plist.sort() priorities = [p[1] for p in plist] - log.debug("priorities: %s", priorities) - client.core.set_torrent_file_priorities(self.torrentid, priorities) if was_empty: @@ -615,16 +573,23 @@ class TorrentDetail(BaseMode, component.Component): # show popup for priority selections def show_priority_popup(self, was_empty): - def popup_func(idx, data, we=was_empty): - return self.do_priority(idx, data, we) + + def popup_func(data, *args, **kwargs): + if data is None: + return + return self.do_priority(data, kwargs[data], was_empty) if self.marked: - self.popup = SelectablePopup(self, "Set File Priority", popup_func) - self.popup.add_line("_Do Not Download", data=FILE_PRIORITY["Do Not Download"], foreground="red") - self.popup.add_line("_Normal Priority", data=FILE_PRIORITY["Normal Priority"]) - self.popup.add_line("_High Priority", data=FILE_PRIORITY["High Priority"], foreground="yellow") - self.popup.add_line("H_ighest Priority", data=FILE_PRIORITY["Highest Priority"], foreground="green") - self.popup._selected = 1 + popup = SelectablePopup(self, "Set File Priority", popup_func, border_off_north=1) + popup.add_line("do_not_download", "_Do Not Download", + cb_arg=FILE_PRIORITY["Do Not Download"], foreground="red") + popup.add_line("normal_priority", "_Normal Priority", cb_arg=FILE_PRIORITY["Normal Priority"]) + popup.add_line("high_priority", "_High Priority", + cb_arg=FILE_PRIORITY["High Priority"], foreground="yellow") + popup.add_line("highest_priority", "H_ighest Priority", + cb_arg=FILE_PRIORITY["Highest Priority"], foreground="green") + popup._selected = 1 + self.push_popup(popup) def __mark_unmark(self, idx): """ @@ -680,23 +645,19 @@ class TorrentDetail(BaseMode, component.Component): 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 - else: - idx = 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): @@ -705,7 +666,6 @@ class TorrentDetail(BaseMode, component.Component): idx = i else: idx += 1 - return idx def __unmark_tree(self, file_list, idx, unmark_all=False): @@ -762,7 +722,7 @@ class TorrentDetail(BaseMode, component.Component): idx, true_idx = i else: idx += 1 - _, true_idx = i + tmp, true_idx = i else: return i else: @@ -780,19 +740,15 @@ class TorrentDetail(BaseMode, component.Component): if not element[3]: idx += 1 continue - if num == idx: return "%s%s/" % (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 - else: - idx = i + idx = i else: idx += 1 - return idx def _do_rename_folder(self, torrent_id, folder, new_folder): @@ -806,70 +762,55 @@ class TorrentDetail(BaseMode, component.Component): def _show_rename_popup(self): # Perhaps in the future: Renaming multiple files if self.marked: - title = "Error (Enter to close)" - text = "Sorry, you can't rename multiple files, please clear selection with {!info!}'c'{!normal!} key" - self.popup = MessagePopup(self, title, text) + self.report_message("Error (Enter to close)", + "Sorry, you can't 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): - if not result["new_foldername"]: + 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 = "%s/%s/" % (old_fname.strip("/").rpartition("/")[0], result["new_foldername"]) + new_fname = "%s/%s/" % (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("{!info!}Renaming folder:{!input!}") - popup.add_text(" * %s\n" % old_filename) - popup.add_text_input("Enter new folder name:", "new_foldername", old_filename.strip("/")) - - self.popup = popup + popup.add_text_input("new_foldername", "Enter new folder name:", old_filename.strip("/"), complete=True) + self.push_popup(popup) else: - def do_rename(result): - fname = "%s/%s" % (self.full_names[idx].rpartition("/")[0], result["new_filename"]) + 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 = "%s/%s" % (self.full_names[idx].rpartition("/")[0], result["new_filename"]["value"]) self._do_rename_file(tid, idx, fname) - popup = InputPopup(self, "Rename file (Esc to cancel)", close_cb=do_rename) - popup.add_text("{!info!}Renaming file:{!input!}") - popup.add_text(" * %s\n" % old_filename) - popup.add_text_input("Enter new filename:", "new_filename", old_filename) - - self.popup = popup + 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: - if self.popup.handle_read(c): - self.popup = None + ret = self.popup.handle_read(c) + if ret != util.ReadState.IGNORED and self.popup.closed(): + self.pop_popup() self.refresh() return - if c > 31 and c < 256: - if chr(c) == "Q": - from twisted.internet import reactor - if client.connected(): - def on_disconnect(result): - reactor.stop() - client.disconnect().addCallback(on_disconnect) - else: - reactor.stop() - return - elif chr(c) == "q": - self.back_to_overview() - return - - if c == 27 or c == curses.KEY_LEFT: + if c in [util.KEY_ESC, curses.KEY_LEFT, ord('q')]: self.back_to_overview() - return + return util.ReadState.READ if not self.torrent_state: # actions below only make sense if there is a torrent state @@ -892,35 +833,30 @@ class TorrentDetail(BaseMode, component.Component): 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) - # Enter Key - elif c == curses.KEY_ENTER or c == 10: + 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) - - # space - elif c == 32: + elif c == util.KEY_SPACE: self.expcol_cur_file() - else: - if c > 31 and c < 256: - if chr(c) == "m": - if self.current_file: - self.__mark_unmark(self.current_file[1]) - elif chr(c) == "r": - self._show_rename_popup() - elif chr(c) == "c": - self.marked = {} - elif chr(c) == "a": - torrent_actions_popup(self, [self.torrentid], details=False) - return - elif chr(c) == "o": - torrent_actions_popup(self, [self.torrentid], action=ACTION.TORRENT_OPTIONS) - return - elif chr(c) == "h": - self.popup = MessagePopup(self, "Help", HELP_STR, width_req=0.75) - elif chr(c) == "j": - self.file_list_up() - if chr(c) == "k": - self.file_list_down() + 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_up() + elif c == ord('k'): + self.file_list_down() 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 000000000..bc2952647 --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/__init__.py @@ -0,0 +1,18 @@ + +class ACTION(object): + 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 000000000..e7d6266b2 --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/add_torrents_popup.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# +# 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 = "{!input!} * %s: {!error!}%s" % (url, 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 000000000..6b0a2373e --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/filtersidebar.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# +# 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 000000000..917db396d --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/queue_mode.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# +# 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. +# + +try: + import curses +except ImportError: + pass + +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 + +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(object): + + 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 = 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 = 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 000000000..c0b8eed7d --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/search_mode.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +# +# 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. +# + +try: + import curses +except ImportError: + pass + +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 + +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): + InputKeyHandler.__init__(self) + 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 = "" + 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 000000000..1f3a034f2 --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/torrentactions.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- +# +# 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.widgets.popup import InputPopup, MessagePopup, SelectablePopup + +from . import ACTION + +log = logging.getLogger(__name__) + +torrent_options = [ + "max_download_speed", "max_upload_speed", "max_connections", "max_upload_slots", + "prioritize_first_last", "sequential_download", "is_auto_managed", "stop_at_ratio", + "stop_ratio", "remove_at_ratio", "move_completed", "move_completed_path"] + + +def action_error(error, mode): + rerr = error.value + mode.report_message("An Error Occurred", "%s got error %s: %s" % ( + rerr.method, rerr.exception_type, rerr.exception_msg)) + 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.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 += "Error removing torrent %s : %s\n" % (t_id, e_msg) + 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) + + 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 += "\n %s* {!input!}%s" % (color, 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.iteritems(): + 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] + field_type = type(value) + if field_type in [str, unicode]: + if not isinstance(value, basestring): + value = str(value) + option_popup.add_text_input(field, caption, value) + elif field_type == bool: + choices = (["Yes", "No"], [True, False], [True, False].index(options[field])) + option_popup.add_select_input(field, caption, choices[0], choices[1], choices[2]) + elif field_type == float: + option_popup.add_float_spin_input(field, caption, value=value, min_val=-1) + elif field_type == 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, 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_torrent(torrent_ids).addErrback(action_error, mode) + retval = True + elif action == ACTION.RESUME: + log.debug("Resuming torrents: %s", torrent_ids) + client.core.resume_torrent(torrent_ids).addErrback(action_error, mode) + retval = True + elif action == ACTION.QUEUE: + queue_mode = QueueMode(mode, torrent_ids) + queue_mode.popup(**kwargs) + return False + elif action == ACTION.REMOVE: + action_remove(**kwargs) + return False + 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 000000000..e537acfdc --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/torrentlist.py @@ -0,0 +1,335 @@ +# -*- coding: utf-8 -*- +# +# 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(TorrentList, self).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"] = ("%s {!filterstatus!}Current filter: %s" + % (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_char(c): + if chr(c) == "Q": + from twisted.internet import reactor + if client.connected(): + def on_disconnect(result): + reactor.stop() + client.disconnect().addCallback(on_disconnect) + else: + reactor.stop() + return + 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_char(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 000000000..7b35fd8a8 --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/torrentview.py @@ -0,0 +1,481 @@ + +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"} + + +def _queue_sort(v1, v2): + if v1 == v2: + return 0 + if v2 < 0: + return -1 + if v1 < 0: + return 1 + if v1 > v2: + return 1 + if v2 > v1: + return -1 + + +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): + InputKeyHandler.__init__(self) + 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[state.keys()[0]] + if field in first_element: + is_string = isinstance(first_element[field], basestring) + + def sort_key(s): + return state.get(s)[field] + + def sort_key2(s): + return state.get(s)[field].lower() + + # If it's a string, sort case-insensitively but preserve A>a order + to_sort = sorted(to_sort, _queue_sort, sort_key, reverse) + if is_string: + to_sort = sorted(to_sort, _queue_sort, sort_key2, 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, _queue_sort, 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 l in lines: + if l < tidx: + continue + if l >= (tidx + self.torrent_rows) or l >= self.numtorrents: + break + todraw.append((l, l - self.curoff, draw_row(l))) + 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, "%s%s" % (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_up(1) + elif c == ord("k"): + affected_lines = self._scroll_down(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 = range(self.last_mark, self.cursel + 1) + else: + mrange = 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 000000000..b7872ca59 --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/torrentviewcolumns.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# +# 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", + "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 000000000..94f911c55 --- /dev/null +++ b/deluge/ui/console/parser.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +# +# 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 __future__ import print_function + +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 bahavior mangles the formatting. + + """ + # Handle epilog manually to keep the text formatting + epilog = self.epilog + self.epilog = "" + help_str = super(ConsoleBaseParser, self).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(ConsoleCommandParser, self).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(ConsoleCommandParser, self).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(OptionParser, self).__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(OptionParser, self).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 000000000..e69de29bb diff --git a/deluge/ui/console/colors.py b/deluge/ui/console/utils/colors.py similarity index 67% rename from deluge/ui/console/colors.py rename to deluge/ui/console/utils/colors.py index b0c2a9cdd..f63c91f11 100644 --- a/deluge/ui/console/colors.py +++ b/deluge/ui/console/utils/colors.py @@ -7,15 +7,18 @@ # See LICENSE for more details. # +import logging import re -from deluge.ui.console.modes import format_utils +from deluge.ui.console.utils import format_utils try: import curses except ImportError: pass +log = logging.getLogger(__name__) + colors = [ "COLOR_BLACK", "COLOR_BLUE", @@ -69,24 +72,34 @@ type_color = { } +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 as ex: + log.warn("Error: %s", ex) + return counter + # Create the color_pairs dict counter = 1 for fg in colors: for bg in colors: - if fg == "COLOR_WHITE" and bg == "COLOR_BLACK": - continue - color_pairs[(fg[6:].lower(), bg[6:].lower())] = counter - curses.init_pair(counter, getattr(curses, fg), getattr(curses, bg)) - counter += 1 + counter = define_pair(counter, fg[6:].lower(), bg[6:].lower(), getattr(curses, fg), getattr(curses, bg)) - # try to redefine white/black as it makes underlining work for some terminals - # but can also fail on others, so we try/except - try: - curses.init_pair(counter, curses.COLOR_WHITE, curses.COLOR_BLACK) - color_pairs[("white", "black")] = counter - except Exception: - pass + 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): @@ -169,6 +182,7 @@ def parse_color_string(s, encoding="UTF-8"): s = s.encode(encoding, "replace") ret = [] + last_color_attr = None # Keep track of where the strings while s.find("{!") != -1: begin = s.find("{!") @@ -185,11 +199,19 @@ def parse_color_string(s, encoding="UTF-8"): if len(attrs) == 1 and not attrs[0].strip(" "): raise BadColorString("No description in {! !}") - def apply_attrs(cp, a): + def apply_attrs(cp, attrs): # This function applies any additional attributes as necessary - if len(a) > 2: - for attr in a[2:]: + 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 @@ -203,35 +225,44 @@ def parse_color_string(s, encoding="UTF-8"): color_pair = apply_attrs(color_pair, schemes[attrs[0]][2:]) last_color_attr = color_pair 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("Pair doesn't exist: %s", pair) - if pair[1] == "white": - if "ignore" == attrs[2]: - 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("Bad color value in tag: %s,%s" % (fg, bg)) + 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 doesn't 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("Bad color value in tag: %s,%s" % (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 = s.find("{!", end) @@ -251,7 +282,7 @@ def parse_color_string(s, encoding="UTF-8"): class ConsoleColorFormatter(object): """ - Format help in a way suited to deluge Legacy mode - colors, format, indentation... + Format help in a way suited to deluge CmdLine mode - colors, format, indentation... """ replace_dict = { diff --git a/deluge/ui/console/utils/column.py b/deluge/ui/console/utils/column.py new file mode 100644 index 000000000..016d52a69 --- /dev/null +++ b/deluge/ui/console/utils/column.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# +# 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.ui.common import TORRENT_DATA_FIELD +from deluge.ui.util import lang + +from . import format_utils + +lang.setup_translations() + +log = logging.getLogger(__name__) + +torrent_data_fields = copy.deepcopy(TORRENT_DATA_FIELD) + + +def format_queue(qnum): + if qnum < 0: + return "" + return "%d" % (qnum + 1) + + +formatters = { + "queue": 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": deluge.common.fsize, + "downloaded": deluge.common.fsize, + "uploaded": deluge.common.fsize, + "remaining": deluge.common.fsize, + + "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": deluge.common.ftime, + "active_time": deluge.common.ftime, + "finished_time": deluge.common.ftime, + + "last_seen_complete": format_utils.format_date_never, + "completed_time": format_utils.format_date, + "eta": format_utils.format_time, + "pieces": format_utils.format_pieces, +} + +torrent_data_fields["pieces"] = {"name": _("Pieces"), "status": ["num_pieces", "piece_length"]} +torrent_data_fields["seed_rank"] = {"name": _("Seed Rank"), "status": ["seed_rank"]} + +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/curses_util.py b/deluge/ui/console/utils/curses_util.py new file mode 100644 index 000000000..4c8baafee --- /dev/null +++ b/deluge/ui/console/utils/curses_util.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# +# 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_char(c): + return c >= 32 and c <= 126 + + +def is_int_chr(c): + return c > 47 and c < 58 + + +class Curser(object): + 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(object): + IGNORED = 0 + READ = 1 + CHANGED = 2 diff --git a/deluge/ui/console/modes/format_utils.py b/deluge/ui/console/utils/format_utils.py similarity index 71% rename from deluge/ui/console/modes/format_utils.py rename to deluge/ui/console/utils/format_utils.py index c30c47f36..64cdd7abb 100644 --- a/deluge/ui/console/modes/format_utils.py +++ b/deluge/ui/console/utils/format_utils.py @@ -123,6 +123,8 @@ def format_row(row, column_widths): _strip_re = re.compile("\\{!.*?!\\}") +_format_code = re.compile(r"\{\|(.*)\|\}") + def remove_formatting(string): return re.sub(_strip_re, "", string) @@ -140,6 +142,7 @@ def wrap_string(string, width, min_lines=0, strip_colors=True): """ ret = [] s1 = string.split("\n") + indent = "" def insert_clr(s, offset, mtchs, clrs): end_pos = offset + len(s) @@ -152,6 +155,16 @@ def wrap_string(string, width, min_lines=0, strip_colors=True): 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() @@ -161,17 +174,28 @@ def wrap_string(string, width, min_lines=0, strip_colors=True): cstr = _strip_re.sub("", s) else: cstr = s - while len(cstr) > width: - sidx = cstr.rfind(" ", 0, width - 1) + + def append_indent(l, string, offset): + """Prepends indent to string if specified""" + if indent and offset != 0: + string = indent + string + l.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) - ret.append(to_app) + append_indent(ret, to_app, offset) offset += len(to_app) else: - ret.append(cstr[0:sidx]) + append_indent(ret, cstr[0:sidx], offset) cstr = cstr[sidx:] if not cstr: cstr = None @@ -181,19 +205,19 @@ def wrap_string(string, width, min_lines=0, strip_colors=True): if strip_colors: to_app = cstr[0:width] to_app = insert_clr(to_app, offset, mtchs, clrs) - ret.append(to_app) + append_indent(ret, to_app, offset) offset += len(to_app) else: - ret.append(cstr[0:width]) + 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: - ret.append(insert_clr(cstr, offset, mtchs, clrs)) - else: - ret.append(cstr) + 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): @@ -231,3 +255,38 @@ def pad_string(string, length, character=" ", side="right"): return "%s%s" % (character * diff, string) elif side == "right": return "%s%s" % (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 000000000..6aac1c410 --- /dev/null +++ b/deluge/ui/console/widgets/__init__.py @@ -0,0 +1,3 @@ +from deluge.ui.console.widgets.inputpane import BaseInputPane # NOQA +from deluge.ui.console.widgets.statusbars import StatusBars # NOQA +from deluge.ui.console.widgets.window import BaseWindow # NOQA diff --git a/deluge/ui/console/widgets/fields.py b/deluge/ui/console/widgets/fields.py new file mode 100644 index 000000000..06e3ff49f --- /dev/null +++ b/deluge/ui/console/widgets/fields.py @@ -0,0 +1,969 @@ +# -*- coding: utf-8 -*- +# +# 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. +# + +try: + import curses +except ImportError: + pass + +import logging +import os + +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 colors +from deluge.ui.console.utils.format_utils import delete_alt_backspace, remove_formatting, wrap_string + +log = logging.getLogger(__name__) + + +class BaseField(InputKeyHandler): + + def __init__(self, parent=None, name=None, selectable=True, **kwargs): + 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!}%%(%s)s{!%%(%s)s!}" % (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 = "%s %s" % (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 = "%s %.2f" % (self.label, self.value) + else: + self.txt = "%s %s" % (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(CheckedInput, self).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.warn("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(SelectInput, self).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 + 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 = "" + 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_char(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 SelectablePopup # Must import here + 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.warn("Setting a value '%s' found found 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, "%s%s" % (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 000000000..243d585dd --- /dev/null +++ b/deluge/ui/console/widgets/inputpane.py @@ -0,0 +1,310 @@ +# -*- coding: utf-8 -*- +# +# 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. +# + +try: + import curses +except ImportError: + pass + +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) + +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): + 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.warn("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, basestring) 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 000000000..e5df2a878 --- /dev/null +++ b/deluge/ui/console/widgets/popup.py @@ -0,0 +1,349 @@ +# -*- coding: utf-8 -*- +# +# 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. +# + +try: + import curses +except ImportError: + pass + +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 + +log = logging.getLogger(__name__) + + +class ALIGN(object): + 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(object): + + 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 = index + + 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().iteritems(): + 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 000000000..ff985f417 --- /dev/null +++ b/deluge/ui/console/widgets/sidebar.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# +# 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/statusbars.py b/deluge/ui/console/widgets/statusbars.py similarity index 100% rename from deluge/ui/console/statusbars.py rename to deluge/ui/console/widgets/statusbars.py diff --git a/deluge/ui/console/widgets/window.py b/deluge/ui/console/widgets/window.py new file mode 100644 index 000000000..1c83c1b25 --- /dev/null +++ b/deluge/ui/console/widgets/window.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +# +# 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. +# + +try: + import curses +except ImportError: + pass + +import logging + +from deluge.ui.console.modes.basemode import add_string, mkpad, mkpanel +from deluge.ui.console.utils.colors import get_color_pair + +log = logging.getLogger(__name__) + + +class BaseWindow(object): + """ + 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): + 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.warn("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()))