diff options
Diffstat (limited to 'deluge/ui/console/modes')
20 files changed, 6281 insertions, 0 deletions
diff --git a/deluge/ui/console/modes/__init__.py b/deluge/ui/console/modes/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/deluge/ui/console/modes/__init__.py diff --git a/deluge/ui/console/modes/add_util.py b/deluge/ui/console/modes/add_util.py new file mode 100644 index 0000000..88a24d0 --- /dev/null +++ b/deluge/ui/console/modes/add_util.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2008-2009 Ido Abramovich <ido.deluge@gmail.com> +# Copyright (C) 2009 Andrew Resch <andrewresch@gmail.com> +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +from __future__ import unicode_literals + +import glob +import logging +import os +from base64 import b64encode + +from six import unichr as chr + +import deluge.common +from deluge.ui.client import client +from deluge.ui.common import TorrentInfo + +log = logging.getLogger(__name__) + + +def _bracket_fixup(path): + if path.find('[') == -1 and path.find(']') == -1: + return path + sentinal = 256 + while path.find(chr(sentinal)) != -1: + sentinal += 1 + if sentinal > 65535: + log.error( + 'Cannot fix brackets in path, path contains all possible sentinal characters' + ) + return path + newpath = path.replace(']', chr(sentinal)) + newpath = newpath.replace('[', '[[]') + newpath = newpath.replace(chr(sentinal), '[]]') + return newpath + + +def add_torrent(t_file, options, success_cb, fail_cb, ress): + t_options = {} + if options['path']: + t_options['download_location'] = os.path.expanduser(options['path']) + t_options['add_paused'] = options['add_paused'] + + is_url = (options['path_type'] != 1) and ( + deluge.common.is_url(t_file) or options['path_type'] == 2 + ) + is_magnet = ( + not (is_url) and (options['path_type'] != 1) and deluge.common.is_magnet(t_file) + ) + + if is_url or is_magnet: + files = [t_file] + else: + files = glob.glob(_bracket_fixup(t_file)) + num_files = len(files) + ress['total'] = num_files + + if num_files <= 0: + fail_cb('Does not exist', t_file, ress) + + for f in files: + if is_url: + client.core.add_torrent_url(f, t_options).addCallback( + success_cb, f, ress + ).addErrback(fail_cb, f, ress) + elif is_magnet: + client.core.add_torrent_magnet(f, t_options).addCallback( + success_cb, f, ress + ).addErrback(fail_cb, f, ress) + else: + if not os.path.exists(f): + fail_cb('Does not exist', f, ress) + continue + if not os.path.isfile(f): + fail_cb('Is a directory', f, ress) + continue + + try: + TorrentInfo(f) + except Exception as ex: + fail_cb(ex.message, f, ress) + continue + + filename = os.path.split(f)[-1] + with open(f, 'rb') as _file: + filedump = b64encode(_file.read()) + + client.core.add_torrent_file_async( + filename, filedump, t_options + ).addCallback(success_cb, f, ress).addErrback(fail_cb, f, ress) diff --git a/deluge/ui/console/modes/addtorrents.py b/deluge/ui/console/modes/addtorrents.py new file mode 100644 index 0000000..6b2c105 --- /dev/null +++ b/deluge/ui/console/modes/addtorrents.py @@ -0,0 +1,545 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2012 Arek StefaĆski <asmageddon@gmail.com> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +from __future__ import unicode_literals + +import logging +import os +from base64 import b64encode + +import deluge.common +import deluge.component as component +from deluge.decorators import overrides +from deluge.ui.client import client +from deluge.ui.console.modes.basemode import BaseMode +from deluge.ui.console.modes.torrentlist.add_torrents_popup import report_add_status +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.utils import format_utils +from deluge.ui.console.widgets.popup import InputPopup, MessagePopup + +try: + from future_builtins import zip +except ImportError: + # Ignore on Py3. + pass + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) + +# Big help string that gets displayed when the user hits 'h' +HELP_STR = """\ +This screen allows you to browse and add torrent files located on your \ +hard disk. Currently selected file is highlighted with a white background. +You can change the selected file using the up/down arrows or the \ +PgUp/PgDown keys. Home and End keys go to the first and last file \ +in current directory respectively. + +Select files with the 'm' key. Use 'M' for multi-selection. Press \ +enter key to add them to session. + +{!info!}'h'{!normal!} - Show this help + +{!info!}'<'{!normal!} and {!info!}'>'{!normal!} - Change sort column and/or order + +{!info!}'m'{!normal!} - Mark or unmark currently highlighted file +{!info!}'M'{!normal!} - Mark all files between current file and last selection. +{!info!}'c'{!normal!} - Clear selection. + +{!info!}Left Arrow{!normal!} - Go up in directory hierarchy. +{!info!}Right Arrow{!normal!} - Enter currently highlighted folder. + +{!info!}Enter{!normal!} - Enter currently highlighted folder or add torrents \ +if a file is highlighted + +{!info!}'q'{!normal!} - Go back to torrent overview +""" + + +class AddTorrents(BaseMode): + def __init__(self, parent_mode, stdscr, console_config, encoding=None): + self.console_config = console_config + self.parent_mode = parent_mode + self.popup = None + self.view_offset = 0 + self.cursel = 0 + self.marked = set() + self.last_mark = -1 + + path = os.path.expanduser(self.console_config['addtorrents']['last_path']) + + self.path_stack = ['/'] + path.strip('/').split('/') + self.path_stack_pos = len(self.path_stack) + self.listing_files = [] + self.listing_dirs = [] + + self.raw_rows = [] + self.raw_rows_files = [] + self.raw_rows_dirs = [] + self.formatted_rows = [] + + self.sort_column = self.console_config['addtorrents']['sort_column'] + self.reverse_sort = self.console_config['addtorrents']['reverse_sort'] + + BaseMode.__init__(self, stdscr, encoding) + + self._listing_space = self.rows - 5 + self.__refresh_listing() + + util.safe_curs_set(util.Curser.INVISIBLE) + self.stdscr.notimeout(0) + + @overrides(component.Component) + def start(self): + pass + + @overrides(component.Component) + def update(self): + pass + + def __refresh_listing(self): + path = os.path.join(*self.path_stack[: self.path_stack_pos]) + + listing = os.listdir(path) + + self.listing_files = [] + self.listing_dirs = [] + + self.raw_rows = [] + self.raw_rows_files = [] + self.raw_rows_dirs = [] + self.formatted_rows = [] + + for f in listing: + if os.path.isdir(os.path.join(path, f)): + if self.console_config['addtorrents']['show_hidden_folders']: + self.listing_dirs.append(f) + elif f[0] != '.': + self.listing_dirs.append(f) + elif os.path.isfile(os.path.join(path, f)): + if self.console_config['addtorrents']['show_misc_files']: + self.listing_files.append(f) + elif f.endswith('.torrent'): + self.listing_files.append(f) + + for dirname in self.listing_dirs: + row = [] + full_path = os.path.join(path, dirname) + try: + size = len(os.listdir(full_path)) + except OSError: + size = -1 + time = os.stat(full_path).st_mtime + + row = [dirname, size, time, full_path, 1] + + self.raw_rows.append(row) + self.raw_rows_dirs.append(row) + + # Highlight the directory we came from + if self.path_stack_pos < len(self.path_stack): + selected = self.path_stack[self.path_stack_pos] + ld = sorted(self.listing_dirs, key=lambda n: n.lower()) + c = ld.index(selected) + self.cursel = c + + if (self.view_offset + self._listing_space) <= self.cursel: + self.view_offset = self.cursel - self._listing_space + + for filename in self.listing_files: + row = [] + full_path = os.path.join(path, filename) + size = os.stat(full_path).st_size + time = os.stat(full_path).st_mtime + + row = [filename, size, time, full_path, 0] + + self.raw_rows.append(row) + self.raw_rows_files.append(row) + + self.__sort_rows() + + def __sort_rows(self): + self.console_config['addtorrents']['sort_column'] = self.sort_column + self.console_config['addtorrents']['reverse_sort'] = self.reverse_sort + self.console_config.save() + + self.raw_rows_dirs.sort(key=lambda r: r[0].lower()) + + if self.sort_column == 'name': + self.raw_rows_files.sort( + key=lambda r: r[0].lower(), reverse=self.reverse_sort + ) + elif self.sort_column == 'date': + self.raw_rows_files.sort(key=lambda r: r[2], reverse=self.reverse_sort) + self.raw_rows = self.raw_rows_dirs + self.raw_rows_files + self.__refresh_rows() + + def __refresh_rows(self): + self.formatted_rows = [] + + for row in self.raw_rows: + filename = deluge.common.decode_bytes(row[0]) + size = row[1] + time = row[2] + + if row[4]: + if size != -1: + size_str = '%i items' % size + else: + size_str = ' unknown' + + cols = [filename, size_str, deluge.common.fdate(time)] + widths = [self.cols - 35, 12, 23] + self.formatted_rows.append(format_utils.format_row(cols, widths)) + else: + # Size of .torrent file itself couldn't matter less so we'll leave it out + cols = [filename, deluge.common.fdate(time)] + widths = [self.cols - 23, 23] + self.formatted_rows.append(format_utils.format_row(cols, widths)) + + def scroll_list_up(self, distance): + self.cursel -= distance + if self.cursel < 0: + self.cursel = 0 + + if self.cursel < self.view_offset + 1: + self.view_offset = max(self.cursel - 1, 0) + + def scroll_list_down(self, distance): + self.cursel += distance + if self.cursel >= len(self.formatted_rows): + self.cursel = len(self.formatted_rows) - 1 + + if (self.view_offset + self._listing_space) <= self.cursel + 1: + self.view_offset = self.cursel - self._listing_space + 1 + + def set_popup(self, pu): + self.popup = pu + self.refresh() + + @overrides(BaseMode) + def on_resize(self, rows, cols): + BaseMode.on_resize(self, rows, cols) + if self.popup: + self.popup.handle_resize() + self._listing_space = self.rows - 5 + self.refresh() + + def refresh(self, lines=None): + if self.mode_paused(): + return + + # Update the status bars + self.stdscr.erase() + self.draw_statusbars() + + off = 1 + + # Render breadcrumbs + s = 'Location: ' + for i, e in enumerate(self.path_stack): + if e == '/': + if i == self.path_stack_pos - 1: + s += '{!black,red,bold!}root' + else: + s += '{!red,black,bold!}root' + else: + if i == self.path_stack_pos - 1: + s += '{!black,white,bold!}%s' % e + else: + s += '{!white,black,bold!}%s' % e + + if e != len(self.path_stack) - 1: + s += '{!white,black!}/' + + self.add_string(off, s) + off += 1 + + # Render header + cols = ['Name', 'Contents', 'Modification time'] + widths = [self.cols - 35, 12, 23] + s = '' + for i, (c, w) in enumerate(zip(cols, widths)): + cn = '' + if i == 0: + cn = 'name' + elif i == 2: + cn = 'date' + + if cn == self.sort_column: + s += '{!black,green,bold!}' + c.ljust(w - 2) + if self.reverse_sort: + s += '^ ' + else: + s += 'v ' + else: + s += '{!green,black,bold!}' + c.ljust(w) + self.add_string(off, s) + off += 1 + + # Render files and folders + for i, row in enumerate(self.formatted_rows[self.view_offset :]): + i += self.view_offset + # It's a folder + color_string = '' + if self.raw_rows[i][4]: + if self.raw_rows[i][1] == -1: + if i == self.cursel: + color_string = '{!black,red,bold!}' + else: + color_string = '{!red,black!}' + else: + if i == self.cursel: + color_string = '{!black,cyan,bold!}' + else: + color_string = '{!cyan,black!}' + + elif i == self.cursel: + if self.raw_rows[i][0] in self.marked: + color_string = '{!blue,white,bold!}' + else: + color_string = '{!black,white,bold!}' + elif self.raw_rows[i][0] in self.marked: + color_string = '{!white,blue,bold!}' + + self.add_string(off, color_string + row) + off += 1 + + if off > self.rows - 2: + break + + if not component.get('ConsoleUI').is_active_mode(self): + return + + self.stdscr.noutrefresh() + + if self.popup: + self.popup.refresh() + + curses.doupdate() + + def back_to_overview(self): + self.parent_mode.go_top = False + component.get('ConsoleUI').set_mode(self.parent_mode.mode_name) + + def _perform_action(self): + if self.cursel < len(self.listing_dirs): + self._enter_dir() + else: + s = self.raw_rows[self.cursel][0] + if s not in self.marked: + self.last_mark = self.cursel + self.marked.add(s) + self._show_add_dialog() + + def _enter_dir(self): + # Enter currently selected directory + dirname = self.raw_rows[self.cursel][0] + new_dir = self.path_stack_pos >= len(self.path_stack) + new_dir = new_dir or (dirname != self.path_stack[self.path_stack_pos]) + if new_dir: + self.path_stack = self.path_stack[: self.path_stack_pos] + self.path_stack.append(dirname) + + path = os.path.join(*self.path_stack[: self.path_stack_pos + 1]) + + if not os.access(path, os.R_OK): + self.path_stack = self.path_stack[: self.path_stack_pos] + self.popup = MessagePopup( + self, 'Error', '{!error!}Access denied: %s' % path + ) + self.__refresh_listing() + return + + self.path_stack_pos += 1 + + self.view_offset = 0 + self.cursel = 0 + self.last_mark = -1 + self.marked = set() + + self.__refresh_listing() + + def _show_add_dialog(self): + def _do_add(result, **kwargs): + ress = {'succ': 0, 'fail': 0, 'total': len(self.marked), 'fmsg': []} + + def fail_cb(msg, t_file, ress): + log.debug('failed to add torrent: %s: %s', t_file, msg) + ress['fail'] += 1 + ress['fmsg'].append('{!input!} * %s: {!error!}%s' % (t_file, msg)) + if (ress['succ'] + ress['fail']) >= ress['total']: + report_add_status( + component.get('TorrentList'), + ress['succ'], + ress['fail'], + ress['fmsg'], + ) + + def success_cb(tid, t_file, ress): + if tid: + log.debug('added torrent: %s (%s)', t_file, tid) + ress['succ'] += 1 + if (ress['succ'] + ress['fail']) >= ress['total']: + report_add_status( + component.get('TorrentList'), + ress['succ'], + ress['fail'], + ress['fmsg'], + ) + else: + fail_cb('Already in session (probably)', t_file, ress) + + for m in self.marked: + filename = m + directory = os.path.join(*self.path_stack[: self.path_stack_pos]) + path = os.path.join(directory, filename) + with open(path, 'rb') as _file: + filedump = b64encode(_file.read()) + t_options = {} + if result['location']['value']: + t_options['download_location'] = result['location']['value'] + t_options['add_paused'] = result['add_paused']['value'] + + d = client.core.add_torrent_file_async(filename, filedump, t_options) + d.addCallback(success_cb, filename, ress) + d.addErrback(fail_cb, filename, ress) + + self.console_config['addtorrents']['last_path'] = os.path.join( + *self.path_stack[: self.path_stack_pos] + ) + self.console_config.save() + + self.back_to_overview() + + config = component.get('ConsoleUI').coreconfig + if config['add_paused']: + ap = 0 + else: + ap = 1 + self.popup = InputPopup( + self, 'Add Torrents (Esc to cancel)', close_cb=_do_add, height_req=17 + ) + + msg = 'Adding torrent files:' + for i, m in enumerate(self.marked): + name = m + msg += '\n * {!input!}%s' % name + if i == 5: + if i < len(self.marked): + msg += '\n {!red!}And %i more' % (len(self.marked) - 5) + break + self.popup.add_text(msg) + self.popup.add_spaces(1) + + self.popup.add_text_input( + 'location', 'Download Folder:', config['download_location'], complete=True + ) + self.popup.add_select_input( + 'add_paused', 'Add Paused:', ['Yes', 'No'], [True, False], ap + ) + + def _go_up(self): + # Go up in directory hierarchy + if self.path_stack_pos > 1: + self.path_stack_pos -= 1 + + self.view_offset = 0 + self.cursel = 0 + self.last_mark = -1 + self.marked = set() + + self.__refresh_listing() + + def read_input(self): + c = self.stdscr.getch() + + if self.popup: + if self.popup.handle_read(c): + self.popup = None + self.refresh() + return + + if util.is_printable_chr(c): + if chr(c) == 'Q': + component.get('ConsoleUI').quit() + elif chr(c) == 'q': + self.back_to_overview() + return + + # Navigate the torrent list + if c == curses.KEY_UP: + self.scroll_list_up(1) + elif c == curses.KEY_PPAGE: + self.scroll_list_up(self.rows // 2) + elif c == curses.KEY_HOME: + self.scroll_list_up(len(self.formatted_rows)) + elif c == curses.KEY_DOWN: + self.scroll_list_down(1) + elif c == curses.KEY_NPAGE: + self.scroll_list_down(self.rows // 2) + elif c == curses.KEY_END: + self.scroll_list_down(len(self.formatted_rows)) + elif c == curses.KEY_RIGHT: + if self.cursel < len(self.listing_dirs): + self._enter_dir() + elif c == curses.KEY_LEFT: + self._go_up() + elif c in [curses.KEY_ENTER, util.KEY_ENTER2]: + self._perform_action() + elif c == util.KEY_ESC: + self.back_to_overview() + else: + if util.is_printable_chr(c): + if chr(c) == 'h': + self.popup = MessagePopup(self, 'Help', HELP_STR, width_req=0.75) + elif chr(c) == '>': + if self.sort_column == 'date': + self.reverse_sort = not self.reverse_sort + else: + self.sort_column = 'date' + self.reverse_sort = True + self.__sort_rows() + elif chr(c) == '<': + if self.sort_column == 'name': + self.reverse_sort = not self.reverse_sort + else: + self.sort_column = 'name' + self.reverse_sort = False + self.__sort_rows() + elif chr(c) == 'm': + s = self.raw_rows[self.cursel][0] + if s in self.marked: + self.marked.remove(s) + else: + self.marked.add(s) + + self.last_mark = self.cursel + elif chr(c) == 'j': + self.scroll_list_up(1) + elif chr(c) == 'k': + self.scroll_list_down(1) + elif chr(c) == 'M': + if self.last_mark != -1: + if self.last_mark > self.cursel: + m = list(range(self.cursel, self.last_mark)) + else: + m = list(range(self.last_mark, self.cursel + 1)) + + for i in m: + s = self.raw_rows[i][0] + self.marked.add(s) + elif chr(c) == 'c': + self.marked.clear() + + self.refresh() diff --git a/deluge/ui/console/modes/basemode.py b/deluge/ui/console/modes/basemode.py new file mode 100644 index 0000000..dd3681f --- /dev/null +++ b/deluge/ui/console/modes/basemode.py @@ -0,0 +1,357 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# Copyright (C) 2009 Andrew Resch <andrewresch@gmail.com> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +from __future__ import unicode_literals + +import logging +import sys + +import deluge.component as component +import deluge.ui.console.utils.colors as colors +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.utils.format_utils import remove_formatting + +try: + import curses + import curses.panel +except ImportError: + pass + +try: + import signal + from fcntl import ioctl + import termios + import struct +except ImportError: + pass + + +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, b'\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 + """ + + def fileno(self): + """ We want to select on FD 0 """ + return 0 + + def doRead(self): # NOQA: N802 + """called when input is ready""" + pass + + def logPrefix(self): # NOQA: N802 + return 'CursesClient' + + +class BaseMode(CursesStdIO, component.Component): + def __init__( + self, stdscr, encoding=None, do_refresh=True, mode_name=None, depend=None + ): + """ + A mode that provides a curses screen designed to run as a reader in a twisted reactor. + This mode doesn't do much, just shows status bars and "Base Mode" on the screen + + Modes should subclass this and provide overrides for: + + do_read(self) - Handle user input + refresh(self) - draw the mode to the screen + add_string(self, row, string) - add a string of text to be displayed. + see method for detailed info + + The init method of a subclass *must* call BaseMode.__init__ + + Useful fields after calling BaseMode.__init__: + self.stdscr - the curses screen + self.rows - # of rows on the curses screen + self.cols - # of cols on the curses screen + self.topbar - top statusbar + self.bottombar - bottom statusbar + """ + self.mode_name = mode_name if mode_name else self.__class__.__name__ + component.Component.__init__(self, self.mode_name, 1, depend=depend) + self.stdscr = stdscr + # Make the input calls non-blocking + self.stdscr.nodelay(1) + + self.paused = False + # Strings for the 2 status bars + self.statusbars = component.get('StatusBars') + self.help_hstr = '{!status!} Press {!magenta,blue,bold!}[h]{!status!} for help' + + # Keep track of the screen size + self.rows, self.cols = self.stdscr.getmaxyx() + + if not encoding: + self.encoding = sys.getdefaultencoding() + else: + self.encoding = encoding + + # Do a refresh right away to draw the screen + if do_refresh: + self.refresh() + + def on_resize(self, rows, cols): + self.rows, self.cols = rows, cols + + def connectionLost(self, reason): # NOQA: N802 + self.close() + + def add_string(self, row, string, scr=None, **kwargs): + if scr: + screen = scr + else: + screen = self.stdscr + + return add_string(row, string, screen, self.encoding, **kwargs) + + def draw_statusbars( + self, + top_row=0, + bottom_row=-1, + topbar=None, + bottombar=None, + bottombar_help=True, + scr=None, + ): + self.add_string(top_row, topbar if topbar else self.statusbars.topbar, scr=scr) + bottombar = bottombar if bottombar else self.statusbars.bottombar + if bottombar_help: + if bottombar_help is True: + bottombar_help = self.help_hstr + bottombar += ( + ' ' + * ( + self.cols + - len(remove_formatting(bottombar)) + - len(remove_formatting(bottombar_help)) + ) + + bottombar_help + ) + self.add_string(self.rows + bottom_row, bottombar, scr=scr) + + # This mode doesn't do anything with popups + def set_popup(self, popup): + pass + + def pause(self): + self.paused = True + + def mode_paused(self): + return self.paused + + def resume(self): + self.paused = False + self.refresh() + + def refresh(self): + """ + Refreshes the screen. + Updates the lines based on the`:attr:lines` based on the `:attr:display_lines_offset` + attribute and the status bars. + """ + self.stdscr.erase() + self.draw_statusbars() + # Update the status bars + + self.add_string(1, '{!info!}Base Mode (or subclass has not overridden refresh)') + + self.stdscr.redrawwin() + self.stdscr.refresh() + + def doRead(self): # NOQA: N802 + """ + Called when there is data to be read, ie, input from the keyboard. + """ + # We wrap this function to catch exceptions and shutdown the mainloop + try: + self.read_input() + except Exception as ex: # pylint: disable=broad-except + log.exception(ex) + + def read_input(self): + # Read the character + self.stdscr.getch() + self.stdscr.refresh() + + def close(self): + """ + Clean up the curses stuff on exit. + """ + curses.nocbreak() + self.stdscr.keypad(0) + curses.echo() + curses.endwin() + + +def add_string( + row, fstring, screen, encoding, col=0, pad=True, pad_char=' ', trim='..', leaveok=0 +): + """ + Adds a string to the desired `:param:row`. + + Args: + row(int): the row number to write the string + row(int): the row number to write the string + fstring(str): the (formatted) string of text to add + scr(curses.window): optional window to add string to instead of self.stdscr + col(int): optional starting column offset + pad(bool): optional bool if the string should be padded out to the width of the screen + trim(bool): optional bool if the string should be trimmed if it is too wide for the screen + + The text can be formatted with color using the following format: + + "{!fg, bg, attributes, ...!}" + + See: http://docs.python.org/library/curses.html#constants for attributes. + + Alternatively, it can use some built-in scheme for coloring. + See colors.py for built-in schemes. + + "{!scheme!}" + + Examples: + + "{!blue, black, bold!}My Text is {!white, black!}cool" + "{!info!}I am some info text!" + "{!error!}Uh oh!" + + Returns: + int: the next row + + """ + try: + parsed = colors.parse_color_string(fstring) + except colors.BadColorString as ex: + log.error('Cannot add bad color string %s: %s', fstring, ex) + return + + if leaveok: + screen.leaveok(leaveok) + + max_y, max_x = screen.getmaxyx() + for index, (color, string) in enumerate(parsed): + # Skip printing chars beyond max_x + if col >= max_x: + break + + if index + 1 == len(parsed) and pad: + # This is the last string so lets append some padding to it + string += pad_char * (max_x - (col + len(string))) + + if col + len(string) > max_x: + remaining_chrs = max(0, max_x - col) + if trim: + string = string[0 : max(0, remaining_chrs - len(trim))] + trim + else: + string = string[0:remaining_chrs] + + try: + screen.addstr(row, col, string.encode(encoding), color) + except curses.error: + # Ignore exception for writing offscreen. + pass + + col += len(string) + + if leaveok: + screen.leaveok(0) + + return row + 1 + + +def mkpanel(color, rows, cols, tly, tlx): + win = curses.newwin(rows, cols, tly, tlx) + pan = curses.panel.new_panel(win) + if curses.has_colors(): + win.bkgdset(ord(' '), curses.color_pair(color)) + else: + win.bkgdset(ord(' '), curses.A_BOLD) + return pan + + +def mkwin(color, rows, cols, tly, tlx): + win = curses.newwin(rows, cols, tly, tlx) + if curses.has_colors(): + win.bkgdset(ord(' '), curses.color_pair(color)) + else: + win.bkgdset(ord(' '), curses.A_BOLD) + return win + + +def mkpad(color, rows, cols): + win = curses.newpad(rows, cols) + if curses.has_colors(): + win.bkgdset(ord(' '), curses.color_pair(color)) + else: + win.bkgdset(ord(' '), curses.A_BOLD) + return win + + +def move_cursor(screen, row, col): + try: + screen.move(row, col) + except curses.error as ex: + import traceback + + log.warning( + 'Error on screen.move(%s, %s): (curses.LINES: %s, curses.COLS: %s) Error: %s\nStack: %s', + row, + col, + curses.LINES, + curses.COLS, + ex, + ''.join(traceback.format_stack()), + ) diff --git a/deluge/ui/console/modes/cmdline.py b/deluge/ui/console/modes/cmdline.py new file mode 100644 index 0000000..2735168 --- /dev/null +++ b/deluge/ui/console/modes/cmdline.py @@ -0,0 +1,850 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2008-2009 Ido Abramovich <ido.deluge@gmail.com> +# Copyright (C) 2009 Andrew Resch <andrewresch@gmail.com> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +from __future__ import unicode_literals + +import logging +import os +import re +from io import open + +import deluge.component as component +import deluge.configmanager +from deluge.common import PY2 +from deluge.decorators import overrides +from deluge.ui.console.cmdline.command import Commander +from deluge.ui.console.modes.basemode import BaseMode, move_cursor +from deluge.ui.console.utils import colors +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.utils.format_utils import ( + delete_alt_backspace, + remove_formatting, + strwidth, +) + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) +LINES_BUFFER_SIZE = 5000 +INPUT_HISTORY_SIZE = 500 +MAX_HISTFILE_SIZE = 2000 + + +def complete_line(line, possible_matches): + """Find the common prefix of possible matches. + + Proritizing matching-case elements. + """ + + if not possible_matches: + return line + + line = line.replace(r'\ ', ' ') + + matches1 = [] + matches2 = [] + + for match in possible_matches: + match = remove_formatting(match) + match = match.replace(r'\ ', ' ') + m1, m2 = '', '' + for i, c in enumerate(line): + if m1 and m2: + break + if not m1 and c != line[i]: + m1 = line[:i] + if not m2 and c.lower() != line[i].lower(): + m2 = line[:i] + if not m1: + matches1.append(match) + elif not m2: + matches2.append(match) + + possible_matches = matches1 + matches2 + + maxlen = 9001 + + for match in possible_matches[1:]: + for i, c in enumerate(match): + try: + if c.lower() != possible_matches[0][i].lower(): + maxlen = min(maxlen, i) + break + except IndexError: + maxlen = min(maxlen, i) + break + + return possible_matches[0][:maxlen].replace(' ', r'\ ') + + +def commonprefix(m): + """Returns the longest common leading component from list of pathnames.""" + if not m: + return '' + s1 = min(m) + s2 = max(m) + for i, c in enumerate(s1): + if c != s2[i]: + return s1[:i] + return s2 + + +class CmdLine(BaseMode, Commander): + def __init__(self, stdscr, encoding=None): + # Get a handle to the main console + self.console = component.get('ConsoleUI') + Commander.__init__(self, self.console._commands, interactive=True) + + self.batch_write = False + + # A list of strings to be displayed based on the offset (scroll) + self.lines = [] + # The offset to display lines + self.display_lines_offset = 0 + + # Holds the user input and is cleared on 'enter' + self.input = '' + self.input_incomplete = '' + + # Keep track of where the cursor is + self.input_cursor = 0 + # Keep a history of inputs + self.input_history = [] + self.input_history_index = 0 + + # Keep track of double- and multi-tabs + self.tab_count = 0 + + self.console_config = component.get('TorrentList').config + + # To avoid having to truncate the file every time we're writing + # or doing it on exit(and therefore relying on an error-less + # or in other words clean exit, we're going to have two files + # that we swap around based on length + config_dir = deluge.configmanager.get_config_dir() + self.history_file = [ + os.path.join(config_dir, 'cmd_line.hist1'), + os.path.join(config_dir, 'cmd_line.hist2'), + ] + self._hf_lines = [0, 0] + if self.console_config['cmdline']['save_command_history']: + try: + with open(self.history_file[0], 'r', encoding='utf8') as _file: + lines1 = _file.read().splitlines() + self._hf_lines[0] = len(lines1) + except IOError: + lines1 = [] + self._hf_lines[0] = 0 + + try: + with open(self.history_file[1], 'r', encoding='utf8') as _file: + lines2 = _file.read().splitlines() + self._hf_lines[1] = len(lines2) + except IOError: + lines2 = [] + self._hf_lines[1] = 0 + + # The non-full file is the active one + if self._hf_lines[0] > self._hf_lines[1]: + self.lines = lines1 + lines2 + else: + self.lines = lines2 + lines1 + + if len(self.lines) > MAX_HISTFILE_SIZE: + self.lines = self.lines[-MAX_HISTFILE_SIZE:] + + # Instead of having additional input history file, we can + # simply scan for lines beginning with ">>> " + for i, line in enumerate(self.lines): + line = remove_formatting(line) + if line.startswith('>>> '): + console_input = line[4:] + if self.console_config['cmdline']['ignore_duplicate_lines']: + if len(self.input_history) > 0: + if self.input_history[-1] != console_input: + self.input_history.append(console_input) + else: + self.input_history.append(console_input) + + self.input_history_index = len(self.input_history) + + # show the cursor + util.safe_curs_set(util.Curser.VERY_VISIBLE) + BaseMode.__init__(self, stdscr, encoding, depend=['SessionProxy']) + + @overrides(component.Component) + def update(self): + if not component.get('ConsoleUI').is_active_mode(self): + return + # Update just the status bars + self.draw_statusbars(bottom_row=-2, bottombar_help=False) + move_cursor(self.stdscr, self.rows - 1, min(self.input_cursor, curses.COLS - 1)) + self.stdscr.refresh() + + @overrides(BaseMode) + def pause(self): + self.stdscr.leaveok(0) + + @overrides(BaseMode) + def resume(self): + util.safe_curs_set(util.Curser.VERY_VISIBLE) + + @overrides(BaseMode) + def read_input(self): + # Read the character + c = self.stdscr.getch() + + # Either ESC or ALT+<some key> + if c == util.KEY_ESC: + n = self.stdscr.getch() + if n == -1: + # Escape key + return + c = [c, n] + + # We remove the tab count if the key wasn't a tab + if c != util.KEY_TAB: + self.tab_count = 0 + + # We clear the input string and send it to the command parser on ENTER + if c in [curses.KEY_ENTER, util.KEY_ENTER2]: + if self.input: + if self.input.endswith('\\'): + self.input = self.input[:-1] + self.input_cursor -= 1 + self.add_line('{!yellow,black,bold!}>>>{!input!} %s' % self.input) + self.do_command(self.input) + if len(self.input_history) == INPUT_HISTORY_SIZE: + # Remove the oldest input history if the max history size + # is reached. + del self.input_history[0] + if self.console_config['cmdline']['ignore_duplicate_lines']: + if len(self.input_history) > 0: + if self.input_history[-1] != self.input: + self.input_history.append(self.input) + else: + self.input_history.append(self.input) + else: + self.input_history.append(self.input) + self.input_history_index = len(self.input_history) + self.input = '' + self.input_incomplete = '' + self.input_cursor = 0 + self.stdscr.refresh() + + # Run the tab completer function + elif c == util.KEY_TAB: + # Keep track of tab hit count to know when it's double-hit + self.tab_count += 1 + + if self.tab_completer: + # We only call the tab completer function if we're at the end of + # the input string on the cursor is on a space + if ( + self.input_cursor == len(self.input) + or self.input[self.input_cursor] == ' ' + ): + self.input, self.input_cursor = self.tab_completer( + self.input, self.input_cursor, self.tab_count + ) + + # We use the UP and DOWN keys to cycle through input history + elif c == curses.KEY_UP: + if self.input_history_index - 1 >= 0: + if self.input_history_index == len(self.input_history): + # We're moving from non-complete input so save it just incase + # we move back down to it. + self.input_incomplete = self.input + # Going back in the history + self.input_history_index -= 1 + self.input = self.input_history[self.input_history_index] + self.input_cursor = len(self.input) + elif c == curses.KEY_DOWN: + if self.input_history_index + 1 < len(self.input_history): + # Going forward in the history + self.input_history_index += 1 + self.input = self.input_history[self.input_history_index] + self.input_cursor = len(self.input) + elif self.input_history_index + 1 == len(self.input_history): + # We're moving back down to an incomplete input + self.input_history_index += 1 + self.input = self.input_incomplete + self.input_cursor = len(self.input) + + # Cursor movement + elif c == curses.KEY_LEFT: + if self.input_cursor: + self.input_cursor -= 1 + elif c == curses.KEY_RIGHT: + if self.input_cursor < len(self.input): + self.input_cursor += 1 + elif c == curses.KEY_HOME: + self.input_cursor = 0 + elif c == curses.KEY_END: + self.input_cursor = len(self.input) + + # Scrolling through buffer + elif c == curses.KEY_PPAGE: + self.display_lines_offset += self.rows - 3 + # We substract 3 for the unavailable lines and 1 extra due to len(self.lines) + if self.display_lines_offset > (len(self.lines) - 4 - self.rows): + self.display_lines_offset = len(self.lines) - 4 - self.rows + + self.refresh() + elif c == curses.KEY_NPAGE: + self.display_lines_offset -= self.rows - 3 + if self.display_lines_offset < 0: + self.display_lines_offset = 0 + self.refresh() + + # Delete a character in the input string based on cursor position + elif c in [curses.KEY_BACKSPACE, util.KEY_BACKSPACE2]: + if self.input and self.input_cursor > 0: + self.input = ( + self.input[: self.input_cursor - 1] + + self.input[self.input_cursor :] + ) + self.input_cursor -= 1 + # Delete a word when alt+backspace is pressed + elif c == [util.KEY_ESC, util.KEY_BACKSPACE2] or c == [ + util.KEY_ESC, + curses.KEY_BACKSPACE, + ]: + self.input, self.input_cursor = delete_alt_backspace( + self.input, self.input_cursor + ) + elif c == curses.KEY_DC: + if self.input and self.input_cursor < len(self.input): + self.input = ( + self.input[: self.input_cursor] + + self.input[self.input_cursor + 1 :] + ) + + # A key to add to the input string + else: + if c > 31 and c < 256: + # Emulate getwch + stroke = chr(c) + uchar = '' if PY2 else stroke + while not uchar: + try: + uchar = stroke.decode(self.encoding) + except UnicodeDecodeError: + c = self.stdscr.getch() + stroke += chr(c) + + if uchar: + if self.input_cursor == len(self.input): + self.input += uchar + else: + # Insert into string + self.input = ( + self.input[: self.input_cursor] + + uchar + + self.input[self.input_cursor :] + ) + + # Move the cursor forward + self.input_cursor += 1 + + self.refresh() + + @overrides(BaseMode) + def on_resize(self, rows, cols): + BaseMode.on_resize(self, rows, cols) + self.stdscr.erase() + self.refresh() + + @overrides(BaseMode) + def refresh(self): + """ + Refreshes the screen. + Updates the lines based on the`:attr:lines` based on the `:attr:display_lines_offset` + attribute and the status bars. + """ + if not component.get('ConsoleUI').is_active_mode(self): + return + self.stdscr.erase() + + # Update the status bars + self.add_string(0, self.statusbars.topbar) + self.add_string(self.rows - 2, self.statusbars.bottombar) + + # The number of rows minus the status bars and the input line + available_lines = self.rows - 3 + # If the amount of lines exceeds the number of rows, we need to figure out + # which ones to display based on the offset + if len(self.lines) > available_lines: + # Get the lines to display based on the offset + offset = len(self.lines) - self.display_lines_offset + lines = self.lines[-(available_lines - offset) : offset] + elif len(self.lines) == available_lines: + lines = self.lines + else: + lines = [''] * (available_lines - len(self.lines)) + lines.extend(self.lines) + + # Add the lines to the screen + for index, line in enumerate(lines): + self.add_string(index + 1, line) + + # Add the input string + self.add_string(self.rows - 1, self.input, pad=False, trim=False) + + move_cursor(self.stdscr, self.rows - 1, min(self.input_cursor, curses.COLS - 1)) + self.stdscr.redrawwin() + self.stdscr.refresh() + + def add_line(self, text, refresh=True): + """ + Add a line to the screen. This will be showed between the two bars. + The text can be formatted with color using the following format: + + "{!fg, bg, attributes, ...!}" + + See: http://docs.python.org/library/curses.html#constants for attributes. + + Alternatively, it can use some built-in scheme for coloring. + See colors.py for built-in schemes. + + "{!scheme!}" + + Examples: + + "{!blue, black, bold!}My Text is {!white, black!}cool" + "{!info!}I am some info text!" + "{!error!}Uh oh!" + + :param text: the text to show + :type text: string + :param refresh: if True, the screen will refresh after the line is added + :type refresh: bool + + """ + + if self.console_config['cmdline']['save_command_history']: + # Determine which file is the active one + # If both are under maximum, it's first, otherwise it's the one not full + if ( + self._hf_lines[0] < MAX_HISTFILE_SIZE + and self._hf_lines[1] < MAX_HISTFILE_SIZE + ): + active_file = 0 + elif self._hf_lines[0] == MAX_HISTFILE_SIZE: + active_file = 1 + else: + active_file = 0 + + # Write the line + with open(self.history_file[active_file], 'a', encoding='utf8') as _file: + _file.write(text + '\n') + + # And increment line counter + self._hf_lines[active_file] += 1 + + # If the active file reaches max size, we truncate it + # therefore swapping the currently active file + if self._hf_lines[active_file] == MAX_HISTFILE_SIZE: + self._hf_lines[1 - active_file] = 0 + with open( + self.history_file[1 - active_file], 'w', encoding='utf8' + ) as _file: + _file.truncate(0) + + def get_line_chunks(line): + """ + Returns a list of 2-tuples (color string, text) + + """ + if not line or line.count('{!') != line.count('!}'): + return [] + + chunks = [] + if not line.startswith('{!'): + begin = line.find('{!') + if begin == -1: + begin = len(line) + chunks.append(('', line[:begin])) + line = line[begin:] + + while line: + # We know the line starts with "{!" here + end_color = line.find('!}') + next_color = line.find('{!', end_color) + if next_color == -1: + next_color = len(line) + chunks.append((line[: end_color + 2], line[end_color + 2 : next_color])) + line = line[next_color:] + return chunks + + for line in text.splitlines(): + # We need to check for line lengths here and split as necessary + try: + line_length = colors.get_line_width(line) + except colors.BadColorString: + log.error('Passed a bad colored line: %s', line) + continue + + if line_length >= (self.cols - 1): + s = '' + # The length of the text without the color tags + s_len = 0 + # We need to split this over multiple lines + for chunk in get_line_chunks(line): + if (strwidth(chunk[1]) + s_len) < (self.cols - 1): + # This chunk plus the current string in 's' isn't over + # the maximum width, so just append the color tag and text + s += chunk[0] + chunk[1] + s_len += strwidth(chunk[1]) + else: + # The chunk plus the current string in 's' is too long. + # We need to take as much of the chunk and put it into 's' + # with the color tag. + remain = (self.cols - 1) - s_len + s += chunk[0] + chunk[1][:remain] + # We append the line since it's full + self.lines.append(s) + # Start a new 's' with the remainder chunk + s = chunk[0] + chunk[1][remain:] + s_len = strwidth(chunk[1][remain:]) + # Append the final string which may or may not be the full width + if s: + self.lines.append(s) + else: + self.lines.append(line) + + while len(self.lines) > LINES_BUFFER_SIZE: + # Remove the oldest line if the max buffer size has been reached + del self.lines[0] + + if refresh: + self.refresh() + + def _add_string(self, row, string): + """ + Adds a string to the desired `:param:row`. + + :param row: int, the row number to write the string + + """ + col = 0 + try: + parsed = colors.parse_color_string(string) + except colors.BadColorString as ex: + log.error('Cannot add bad color string %s: %s', string, ex) + return + + for index, (color, p_str) in enumerate(parsed): + if index + 1 == len(parsed): + # This is the last string so lets append some " " to it + p_str += ' ' * (self.cols - (col + strwidth(p_str)) - 1) + try: + self.stdscr.addstr(row, col, p_str.encode(self.encoding), color) + except curses.error: + pass + + col += strwidth(p_str) + + def set_batch_write(self, batch): + """ + When this is set the screen is not refreshed after a `:meth:write` until + this is set to False. + + :param batch: set True to prevent screen refreshes after a `:meth:write` + :type batch: bool + + """ + self.batch_write = batch + if not batch: + self.refresh() + + def write(self, line): + """ + Writes a line out + + :param line: str, the line to print + + """ + + self.add_line(line, not self.batch_write) + + def tab_completer(self, line, cursor, hits): + """ + Called when the user hits 'tab' and will autocomplete or show options. + If a command is already supplied in the line, this function will call the + complete method of the command. + + :param line: str, the current input string + :param cursor: int, the cursor position in the line + :param second_hit: bool, if this is the second time in a row the tab key + has been pressed + + :returns: 2-tuple (string, cursor position) + + """ + # First check to see if there is no space, this will mean that it's a + # command that needs to be completed. + + # We don't want to split by escaped spaces + def split(string): + return re.split(r'(?<!\\) ', string) + + if ' ' not in line: + possible_matches = [] + # Iterate through the commands looking for ones that startwith the + # line. + for cmd in self.console._commands: + if cmd.startswith(line): + possible_matches.append(cmd) + + line_prefix = '' + else: + cmd = split(line)[0] + if cmd in self.console._commands: + # Call the command's complete method to get 'er done + possible_matches = self.console._commands[cmd].complete(split(line)[-1]) + line_prefix = ' '.join(split(line)[:-1]) + ' ' + else: + # This is a bogus command + return (line, cursor) + + # No matches, so just return what we got passed + if len(possible_matches) == 0: + return (line, cursor) + # If we only have 1 possible match, then just modify the line and + # return it, else we need to print out the matches without modifying + # the line. + elif len(possible_matches) == 1: + # Do not append space after directory names + new_line = line_prefix + possible_matches[0] + 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 = remove_formatting(new_line) + return (new_line, len(new_line)) + else: + if hits == 1: + p = ' '.join(split(line)[:-1]) + + try: + l_arg = split(line)[-1] + except IndexError: + l_arg = '' + + new_line = ' '.join( + [p, complete_line(l_arg, possible_matches)] + ).lstrip() + + if len(remove_formatting(new_line)) > len(line): + line = new_line + cursor = len(line) + elif hits >= 2: + max_list = self.console_config['cmdline']['torrents_per_tab_press'] + match_count = len(possible_matches) + listed = (hits - 2) * max_list + pages = (match_count - 1) // max_list + 1 + left = match_count - listed + if hits == 2: + self.write(' ') + + if match_count >= 4: + self.write('{!green!}Autocompletion matches:') + # Only list some of the matching torrents as there can be hundreds of them + if self.console_config['cmdline']['third_tab_lists_all']: + if hits == 2 and left > max_list: + for i in range(listed, listed + max_list): + match = possible_matches[i] + self.write(match.replace(r'\ ', ' ')) + self.write( + '{!error!}And %i more. Press <tab> to list them' + % (left - max_list) + ) + else: + self.tab_count = 0 + for match in possible_matches[listed:]: + self.write(match.replace(r'\ ', ' ')) + else: + if left > max_list: + for i in range(listed, listed + max_list): + match = possible_matches[i] + self.write(match.replace(r'\ ', ' ')) + self.write( + '{!error!}And %i more (%i/%i). Press <tab> to view more' + % (left - max_list, hits - 1, pages) + ) + else: + self.tab_count = 0 + for match in possible_matches[listed:]: + self.write(match.replace(r'\ ', ' ')) + if hits > 2: + self.write( + '{!green!}Finished listing %i torrents (%i/%i)' + % (match_count, hits - 1, pages) + ) + + # We only want to print eventual colors or other control characters, not return them + line = remove_formatting(line) + cursor = len(line) + return (line, cursor) + + def tab_complete_path( + self, line, path_type='file', ext='', sort='name', dirs_first=1 + ): + self.console = component.get('ConsoleUI') + + line = line.replace('\\ ', ' ') + line = os.path.abspath(os.path.expanduser(line)) + ret = [] + if os.path.exists(line): + # This is a correct path, check to see if it's a directory + if os.path.isdir(line): + # Directory, so we need to show contents of directory + # ret.extend(os.listdir(line)) + try: + for f in os.listdir(line): + # Skip hidden + if f.startswith('.'): + continue + f = os.path.join(line, f) + if os.path.isdir(f): + if os.sep == '\\': # Windows path support + f += '\\' + else: # Unix + f += '/' + elif not f.endswith(ext): + continue + ret.append(f) + except OSError: + self.console.write('{!error!}Permission denied: {!info!}%s' % line) + else: + try: + # This is a file, but we could be looking for another file that + # shares a common prefix. + for f in os.listdir(os.path.dirname(line)): + if f.startswith(os.path.split(line)[1]): + ret.append(os.path.join(os.path.dirname(line), f)) + except OSError: + self.console.write('{!error!}Permission denied: {!info!}%s' % line) + else: + # This path does not exist, so lets do a listdir on it's parent + # and find any matches. + try: + ret = [] + if os.path.isdir(os.path.dirname(line)): + for f in os.listdir(os.path.dirname(line)): + if f.startswith(os.path.split(line)[1]): + p = os.path.join(os.path.dirname(line), f) + + if os.path.isdir(p): + if os.sep == '\\': # Windows path support + p += '\\' + else: # Unix + p += '/' + ret.append(p) + except OSError: + self.console.write('{!error!}Permission denied: {!info!}%s' % line) + + if sort == 'date': + ret = sorted(ret, key=os.path.getmtime, reverse=True) + + if dirs_first == 1: + ret = sorted(ret, key=os.path.isdir, reverse=True) + elif dirs_first == -1: + ret = sorted(ret, key=os.path.isdir, reverse=False) + + # Highlight directory names + for i, filename in enumerate(ret): + if os.path.isdir(filename): + ret[i] = '{!cyan!}%s' % filename + + for i in range(0, len(ret)): + ret[i] = ret[i].replace(' ', r'\ ') + return ret + + def tab_complete_torrent(self, line): + """ + Completes torrent_ids or names. + + :param line: str, the string to complete + + :returns: list of matches + + """ + + empty = len(line) == 0 + + # Remove dangling backslashes to avoid breaking shlex + if line.endswith('\\'): + line = line[:-1] + + raw_line = line + line = line.replace('\\ ', ' ') + + possible_matches = [] + possible_matches2 = [] + + match_count = 0 + match_count2 = 0 + for torrent_id, torrent_name in self.console.torrents: + if torrent_id.startswith(line): + match_count += 1 + if torrent_name.startswith(line): + match_count += 1 + elif torrent_name.lower().startswith(line.lower()): + match_count2 += 1 + + # Find all possible matches + for torrent_id, torrent_name in self.console.torrents: + # Escape spaces to avoid, for example, expanding "Doc" into "Doctor Who" and removing + # everything containing one of these words + escaped_name = torrent_name.replace(' ', '\\ ') + # If we only matched one torrent, don't add the full name or it'll also get autocompleted + if match_count == 1: + if torrent_id.startswith(line): + possible_matches.append(torrent_id) + break + if torrent_name.startswith(line): + possible_matches.append(escaped_name) + break + elif match_count == 0 and match_count2 == 1: + if torrent_name.lower().startswith(line.lower()): + possible_matches.append(escaped_name) + break + else: + line_len = len(raw_line) + + # Let's avoid listing all torrents twice if there's no pattern + if not empty and torrent_id.startswith(line): + # Highlight the matching part + text = '{!info!}%s{!input!}%s - "%s"' % ( + torrent_id[:line_len], + torrent_id[line_len:], + torrent_name, + ) + possible_matches.append(text) + if torrent_name.startswith(line): + text = '{!info!}%s{!input!}%s ({!cyan!}%s{!input!})' % ( + escaped_name[:line_len], + escaped_name[line_len:], + torrent_id, + ) + possible_matches.append(text) + elif torrent_name.lower().startswith(line.lower()): + text = '{!info!}%s{!input!}%s ({!cyan!}%s{!input!})' % ( + escaped_name[:line_len], + escaped_name[line_len:], + torrent_id, + ) + possible_matches2.append(text) + + return possible_matches + possible_matches2 diff --git a/deluge/ui/console/modes/connectionmanager.py b/deluge/ui/console/modes/connectionmanager.py new file mode 100644 index 0000000..84a3fbc --- /dev/null +++ b/deluge/ui/console/modes/connectionmanager.py @@ -0,0 +1,206 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +from __future__ import unicode_literals + +import logging + +import deluge.component as component +from deluge.decorators import overrides +from deluge.ui.console.modes.basemode import BaseMode +from deluge.ui.console.utils.curses_util import is_printable_chr +from deluge.ui.console.widgets.popup import InputPopup, PopupsHandler, SelectablePopup +from deluge.ui.hostlist import HostList + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) + + +class ConnectionManager(BaseMode, PopupsHandler): + def __init__(self, stdscr, encoding=None): + PopupsHandler.__init__(self) + self.statuses = {} + self.all_torrents = None + self.hostlist = HostList() + self.update_hosts_status() + BaseMode.__init__(self, stdscr, encoding=encoding) + self.update_select_host_popup() + + def update_select_host_popup(self): + selected_index = self.popup.current_selection() if self.popup else None + + popup = SelectablePopup( + self, + _('Select Host'), + self._host_selected, + border_off_west=1, + active_wrap=True, + ) + popup.add_header( + "{!white,black,bold!}'Q'=%s, 'a'=%s, 'D'=%s" + % (_('Quit'), _('Add Host'), _('Delete Host')), + space_below=True, + ) + self.push_popup(popup, clear=True) + + for host_entry in self.hostlist.get_hosts_info(): + host_id, hostname, port, user = host_entry + args = {'data': host_id, 'foreground': 'red'} + state = 'Offline' + if host_id in self.statuses: + state = 'Online' + args.update({'data': self.statuses[host_id], 'foreground': 'green'}) + host_str = '%s:%d [%s]' % (hostname, port, state) + self.popup.add_line( + host_id, host_str, selectable=True, use_underline=True, **args + ) + + if selected_index: + self.popup.set_selection(selected_index) + self.inlist = True + self.refresh() + + def update_hosts_status(self): + for host_entry in self.hostlist.get_hosts_info(): + + def on_host_status(status_info): + self.statuses[status_info[0]] = status_info + self.update_select_host_popup() + + self.hostlist.get_host_status(host_entry[0]).addCallback(on_host_status) + + def _on_connected(self, result): + def on_console_start(result): + component.get('ConsoleUI').set_mode('TorrentList') + + d = component.get('ConsoleUI').start_console() + d.addCallback(on_console_start) + + def _on_connect_fail(self, result): + self.report_message('Failed to connect!', result) + self.refresh() + if hasattr(result, 'getTraceback'): + log.exception(result) + + def _host_selected(self, selected_host, *args, **kwargs): + if selected_host in self.statuses: + d = self.hostlist.connect_host(selected_host) + d.addCallback(self._on_connected) + d.addErrback(self._on_connect_fail) + + def _do_add(self, result, **kwargs): + if not result or kwargs.get('close', False): + self.pop_popup() + else: + self.add_host( + result['hostname']['value'], + result['port']['value'], + result['username']['value'], + result['password']['value'], + ) + + def add_popup(self): + self.inlist = False + popup = InputPopup( + self, + _('Add Host (Up & Down arrows to navigate, Esc to cancel)'), + border_off_north=1, + border_off_east=1, + close_cb=self._do_add, + ) + popup.add_text_input('hostname', _('Hostname:')) + popup.add_text_input('port', _('Port:')) + popup.add_text_input('username', _('Username:')) + popup.add_text_input('password', _('Password:')) + self.push_popup(popup, clear=True) + self.refresh() + + def add_host(self, hostname, port, username, password): + log.info('Adding host: %s', hostname) + try: + self.hostlist.add_host(hostname, port, username, password) + except ValueError as ex: + self.report_message(_('Error adding host'), '%s: %s' % (hostname, ex)) + else: + self.update_select_host_popup() + + def delete_host(self, host_id): + log.info('Deleting host: %s', host_id) + self.hostlist.remove_host(host_id) + self.update_select_host_popup() + + @overrides(component.Component) + def start(self): + self.refresh() + + @overrides(component.Component) + def update(self): + self.update_hosts_status() + + @overrides(BaseMode) + def pause(self): + self.pop_popup() + BaseMode.pause(self) + + @overrides(BaseMode) + def resume(self): + BaseMode.resume(self) + self.refresh() + + @overrides(BaseMode) + def refresh(self): + if self.mode_paused(): + return + + self.stdscr.erase() + self.draw_statusbars() + self.stdscr.noutrefresh() + + if not self.popup: + self.update_select_host_popup() + + self.popup.refresh() + curses.doupdate() + + @overrides(BaseMode) + def on_resize(self, rows, cols): + BaseMode.on_resize(self, rows, cols) + + if self.popup: + self.popup.handle_resize() + + self.stdscr.erase() + self.refresh() + + @overrides(BaseMode) + def read_input(self): + c = self.stdscr.getch() + + if is_printable_chr(c): + if chr(c) == 'Q': + component.get('ConsoleUI').quit() + elif self.inlist: + if chr(c) == 'q': + return + elif chr(c) == 'D': + host_id = self.popup.current_selection()[1] + self.delete_host(host_id) + return + elif chr(c) == 'a': + self.add_popup() + return + + if self.popup: + if self.popup.handle_read(c) and self.popup.closed(): + self.pop_popup() + self.refresh() diff --git a/deluge/ui/console/modes/eventview.py b/deluge/ui/console/modes/eventview.py new file mode 100644 index 0000000..cd3308c --- /dev/null +++ b/deluge/ui/console/modes/eventview.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +from __future__ import unicode_literals + +import logging + +import deluge.component as component +from deluge.decorators import overrides +from deluge.ui.console.modes.basemode import BaseMode +from deluge.ui.console.utils import curses_util as util + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) + + +class EventView(BaseMode): + def __init__(self, parent_mode, stdscr, encoding=None): + BaseMode.__init__(self, stdscr, encoding) + self.parent_mode = parent_mode + self.offset = 0 + + def back_to_overview(self): + component.get('ConsoleUI').set_mode(self.parent_mode.mode_name) + + @overrides(component.Component) + def update(self): + self.refresh() + + @overrides(BaseMode) + def refresh(self): + """ + This method just shows each line of the event log + """ + events = component.get('ConsoleUI').events + + self.stdscr.erase() + self.draw_statusbars() + + if events: + for i, event in enumerate(events): + if i - self.offset >= self.rows - 2: + more = len(events) - self.offset - self.rows + 2 + if more > 0: + self.add_string(i - self.offset, ' (And %i more)' % more) + break + + elif i - self.offset < 0: + continue + try: + self.add_string(i + 1 - self.offset, event) + except curses.error: + pass # This'll just cut the line. Note: This seriously should be fixed in a better way + else: + self.add_string(1, '{!white,black,bold!}No events to show yet') + + if not component.get('ConsoleUI').is_active_mode(self): + return + + self.stdscr.noutrefresh() + curses.doupdate() + + @overrides(BaseMode) + def on_resize(self, rows, cols): + BaseMode.on_resize(self, rows, cols) + self.refresh() + + @overrides(BaseMode) + def read_input(self): + c = self.stdscr.getch() + + if c in [ord('q'), util.KEY_ESC]: + self.back_to_overview() + return + + # TODO: Scroll event list + jumplen = self.rows - 3 + num_events = len(component.get('ConsoleUI').events) + + if c == curses.KEY_UP: + self.offset -= 1 + elif c == curses.KEY_PPAGE: + self.offset -= jumplen + elif c == curses.KEY_HOME: + self.offset = 0 + elif c == curses.KEY_DOWN: + self.offset += 1 + elif c == curses.KEY_NPAGE: + self.offset += jumplen + elif c == curses.KEY_END: + self.offset += num_events + elif c == ord('j'): + self.offset -= 1 + elif c == ord('k'): + self.offset += 1 + + if self.offset <= 0: + self.offset = 0 + elif num_events > self.rows - 3: + if self.offset > num_events - self.rows + 3: + self.offset = num_events - self.rows + 3 + else: + self.offset = 0 + + self.refresh() diff --git a/deluge/ui/console/modes/preferences/__init__.py b/deluge/ui/console/modes/preferences/__init__.py new file mode 100644 index 0000000..15d77c4 --- /dev/null +++ b/deluge/ui/console/modes/preferences/__init__.py @@ -0,0 +1,5 @@ +from __future__ import unicode_literals + +from deluge.ui.console.modes.preferences.preferences import Preferences + +__all__ = ['Preferences'] diff --git a/deluge/ui/console/modes/preferences/preference_panes.py b/deluge/ui/console/modes/preferences/preference_panes.py new file mode 100644 index 0000000..62029a6 --- /dev/null +++ b/deluge/ui/console/modes/preferences/preference_panes.py @@ -0,0 +1,764 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +from __future__ import unicode_literals + +import logging + +from deluge.common import is_ip +from deluge.decorators import overrides +from deluge.i18n import get_languages +from deluge.ui.client import client +from deluge.ui.common import DISK_CACHE_KEYS +from deluge.ui.console.widgets import BaseInputPane, BaseWindow +from deluge.ui.console.widgets.fields import FloatSpinInput, TextInput +from deluge.ui.console.widgets.popup import PopupsHandler + +log = logging.getLogger(__name__) + + +class BasePreferencePane(BaseInputPane, BaseWindow, PopupsHandler): + def __init__(self, name, preferences): + PopupsHandler.__init__(self) + self.preferences = preferences + BaseWindow.__init__( + self, + '%s' % name, + self.pane_width, + preferences.height, + posy=1, + posx=self.pane_x_pos, + ) + BaseInputPane.__init__(self, preferences, border_off_east=1) + self.name = name + + # have we scrolled down in the list + self.input_offset = 0 + + @overrides(BaseInputPane) + def handle_read(self, c): + if self.popup: + ret = self.popup.handle_read(c) + if self.popup and self.popup.closed(): + self.pop_popup() + self.refresh() + return ret + return BaseInputPane.handle_read(self, c) + + @property + def visible_content_pane_height(self): + y, x = self.visible_content_pane_size + return y + + @property + def pane_x_pos(self): + return self.preferences.sidebar_width + + @property + def pane_width(self): + return self.preferences.width + + @property + def cols(self): + return self.pane_width + + @property + def rows(self): + return self.preferences.height + + def is_active_pane(self): + return self.preferences.is_active_pane(self) + + def create_pane(self, core_conf, console_config): + pass + + def add_config_values(self, conf_dict): + for ipt in self.inputs: + if ipt.has_input(): + # Need special cases for in/out ports or proxy since they are tuples or dicts. + if ipt.name == 'listen_ports_to' or ipt.name == 'listen_ports_from': + conf_dict['listen_ports'] = ( + self.infrom.get_value(), + self.into.get_value(), + ) + elif ipt.name == 'out_ports_to' or ipt.name == 'out_ports_from': + conf_dict['outgoing_ports'] = ( + self.outfrom.get_value(), + self.outto.get_value(), + ) + elif ipt.name == 'listen_interface': + listen_interface = ipt.get_value().strip() + if is_ip(listen_interface) or not listen_interface: + conf_dict['listen_interface'] = listen_interface + elif ipt.name == 'outgoing_interface': + outgoing_interface = ipt.get_value().strip() + conf_dict['outgoing_interface'] = outgoing_interface + elif ipt.name.startswith('proxy_'): + if ipt.name == 'proxy_type': + conf_dict.setdefault('proxy', {})['type'] = ipt.get_value() + elif ipt.name == 'proxy_username': + conf_dict.setdefault('proxy', {})['username'] = ipt.get_value() + elif ipt.name == 'proxy_password': + conf_dict.setdefault('proxy', {})['password'] = ipt.get_value() + elif ipt.name == 'proxy_hostname': + conf_dict.setdefault('proxy', {})['hostname'] = ipt.get_value() + elif ipt.name == 'proxy_port': + conf_dict.setdefault('proxy', {})['port'] = ipt.get_value() + elif ipt.name == 'proxy_hostnames': + conf_dict.setdefault('proxy', {})[ + 'proxy_hostnames' + ] = ipt.get_value() + elif ipt.name == 'proxy_peer_connections': + conf_dict.setdefault('proxy', {})[ + 'proxy_peer_connections' + ] = ipt.get_value() + elif ipt.name == 'proxy_tracker_connections': + conf_dict.setdefault('proxy', {})[ + 'proxy_tracker_connections' + ] = ipt.get_value() + elif ipt.name == 'force_proxy': + conf_dict.setdefault('proxy', {})['force_proxy'] = ipt.get_value() + elif ipt.name == 'anonymous_mode': + conf_dict.setdefault('proxy', {})[ + 'anonymous_mode' + ] = ipt.get_value() + else: + conf_dict[ipt.name] = ipt.get_value() + + if hasattr(ipt, 'get_child'): + c = ipt.get_child() + conf_dict[c.name] = c.get_value() + + def update_values(self, conf_dict): + for ipt in self.inputs: + if ipt.has_input(): + try: + ipt.set_value(conf_dict[ipt.name]) + except KeyError: # just ignore if it's not in dict + pass + if hasattr(ipt, 'get_child'): + try: + c = ipt.get_child() + c.set_value(conf_dict[c.name]) + except KeyError: # just ignore if it's not in dict + pass + + def render(self, mode, screen, width, focused): + height = self.get_content_height() + self.ensure_content_pane_height(height) + self.screen.erase() + + if focused and self.active_input == -1: + self.move_active_down(1) + + self.render_inputs(focused=focused) + + @overrides(BaseWindow) + def refresh(self): + BaseWindow.refresh(self) + if self.popup: + self.popup.refresh() + + def update(self, active): + pass + + +class InterfacePane(BasePreferencePane): + def __init__(self, preferences): + BasePreferencePane.__init__(self, ' %s ' % _('Interface'), preferences) + + @overrides(BasePreferencePane) + def create_pane(self, core_conf, console_config): + self.add_header(_('General options')) + + self.add_checked_input( + 'ring_bell', + _('Ring system bell when a download finishes'), + console_config['ring_bell'], + ) + self.add_header('Console UI', space_above=True) + self.add_checked_input( + 'separate_complete', + _('List complete torrents after incomplete regardless of sorting order'), + console_config['torrentview']['separate_complete'], + ) + self.add_checked_input( + 'move_selection', + _('Move selection when moving torrents in the queue'), + console_config['torrentview']['move_selection'], + ) + + langs = get_languages() + langs.insert(0, ('', 'System Default')) + self.add_combo_input( + 'language', _('Language'), langs, default=console_config['language'] + ) + self.add_header(_('Command Line Mode'), space_above=True) + self.add_checked_input( + 'ignore_duplicate_lines', + _('Do not store duplicate input in history'), + console_config['cmdline']['ignore_duplicate_lines'], + ) + self.add_checked_input( + 'save_command_history', + _('Store and load command line history in command line mode'), + console_config['cmdline']['save_command_history'], + ) + self.add_header('') + self.add_checked_input( + 'third_tab_lists_all', + _('Third tab lists all remaining torrents in command line mode'), + console_config['cmdline']['third_tab_lists_all'], + ) + self.add_int_spin_input( + 'torrents_per_tab_press', + _('Torrents per tab press'), + console_config['cmdline']['torrents_per_tab_press'], + min_val=5, + max_val=10000, + ) + + +class DownloadsPane(BasePreferencePane): + def __init__(self, preferences): + BasePreferencePane.__init__(self, ' %s ' % _('Downloads'), preferences) + + @overrides(BasePreferencePane) + def create_pane(self, core_conf, console_config): + self.add_header(_('Folders')) + self.add_text_input( + 'download_location', + '%s:' % _('Download To'), + core_conf['download_location'], + complete=True, + activate_input=True, + col='+1', + ) + cmptxt = TextInput( + self.preferences, + 'move_completed_path', + None, + self.move, + self.pane_width, + core_conf['move_completed_path'], + False, + ) + self.add_checkedplus_input( + 'move_completed', + '%s:' % _('Move completed to'), + cmptxt, + core_conf['move_completed'], + ) + copytxt = TextInput( + self.preferences, + 'torrentfiles_location', + None, + self.move, + self.pane_width, + core_conf['torrentfiles_location'], + False, + ) + self.add_checkedplus_input( + 'copy_torrent_file', + '%s:' % _('Copy of .torrent files to'), + copytxt, + core_conf['copy_torrent_file'], + ) + self.add_checked_input( + 'del_copy_torrent_file', + _('Delete copy of torrent file on remove'), + core_conf['del_copy_torrent_file'], + ) + + self.add_header(_('Options'), space_above=True) + self.add_checked_input( + 'prioritize_first_last_pieces', + ('Prioritize first and last pieces of torrent'), + core_conf['prioritize_first_last_pieces'], + ) + self.add_checked_input( + 'sequential_download', + _('Sequential download'), + core_conf['sequential_download'], + ) + self.add_checked_input('add_paused', _('Add Paused'), core_conf['add_paused']) + self.add_checked_input( + 'pre_allocate_storage', + _('Pre-Allocate disk space'), + core_conf['pre_allocate_storage'], + ) + + +class NetworkPane(BasePreferencePane): + def __init__(self, preferences): + BasePreferencePane.__init__(self, ' %s ' % _('Network'), preferences) + + @overrides(BasePreferencePane) + def create_pane(self, core_conf, console_config): + self.add_header(_('Incomming Ports')) + inrand = self.add_checked_input( + 'random_port', + 'Use Random Ports Active Port: %d' % self.preferences.active_port, + core_conf['random_port'], + ) + listen_ports = core_conf['listen_ports'] + self.infrom = self.add_int_spin_input( + 'listen_ports_from', + ' %s:' % _('From'), + value=listen_ports[0], + min_val=0, + max_val=65535, + ) + self.infrom.set_depend(inrand, inverse=True) + self.into = self.add_int_spin_input( + 'listen_ports_to', + ' %s:' % _('To'), + value=listen_ports[1], + min_val=0, + max_val=65535, + ) + self.into.set_depend(inrand, inverse=True) + + self.add_header(_('Outgoing Ports'), space_above=True) + outrand = self.add_checked_input( + 'random_outgoing_ports', + _('Use Random Ports'), + core_conf['random_outgoing_ports'], + ) + out_ports = core_conf['outgoing_ports'] + self.outfrom = self.add_int_spin_input( + 'out_ports_from', + ' %s:' % _('From'), + value=out_ports[0], + min_val=0, + max_val=65535, + ) + self.outfrom.set_depend(outrand, inverse=True) + self.outto = self.add_int_spin_input( + 'out_ports_to', + ' %s:' % _('To'), + value=out_ports[1], + min_val=0, + max_val=65535, + ) + self.outto.set_depend(outrand, inverse=True) + + self.add_header(_('Incoming Interface'), space_above=True) + self.add_text_input( + 'listen_interface', + _('IP address of the interface to listen on (leave empty for default):'), + core_conf['listen_interface'], + ) + + self.add_header(_('Outgoing Interface'), space_above=True) + self.add_text_input( + 'outgoing_interface', + _( + 'The network interface name or IP address for outgoing ' + 'BitTorrent connections. (Leave empty for default.):' + ), + core_conf['outgoing_interface'], + ) + + self.add_header('TOS', space_above=True) + self.add_text_input('peer_tos', 'Peer TOS Byte:', core_conf['peer_tos']) + + self.add_header(_('Network Extras'), space_above=True) + self.add_checked_input('upnp', 'UPnP', core_conf['upnp']) + self.add_checked_input('natpmp', 'NAT-PMP', core_conf['natpmp']) + self.add_checked_input('utpex', 'Peer Exchange', core_conf['utpex']) + self.add_checked_input('lsd', 'LSD', core_conf['lsd']) + self.add_checked_input('dht', 'DHT', core_conf['dht']) + + self.add_header(_('Encryption'), space_above=True) + self.add_select_input( + 'enc_in_policy', + '%s:' % _('Inbound'), + [_('Forced'), _('Enabled'), _('Disabled')], + [0, 1, 2], + core_conf['enc_in_policy'], + active_default=True, + col='+1', + ) + self.add_select_input( + 'enc_out_policy', + '%s:' % _('Outbound'), + [_('Forced'), _('Enabled'), _('Disabled')], + [0, 1, 2], + core_conf['enc_out_policy'], + active_default=True, + ) + self.add_select_input( + 'enc_level', + '%s:' % _('Level'), + [_('Handshake'), _('Full Stream'), _('Either')], + [0, 1, 2], + core_conf['enc_level'], + active_default=True, + ) + + +class BandwidthPane(BasePreferencePane): + def __init__(self, preferences): + BasePreferencePane.__init__(self, ' %s ' % _('Bandwidth'), preferences) + + @overrides(BasePreferencePane) + def create_pane(self, core_conf, console_config): + self.add_header(_('Global Bandwidth Usage')) + self.add_int_spin_input( + 'max_connections_global', + '%s:' % _('Maximum Connections'), + core_conf['max_connections_global'], + min_val=-1, + max_val=9000, + ) + self.add_int_spin_input( + 'max_upload_slots_global', + '%s:' % _('Maximum Upload Slots'), + core_conf['max_upload_slots_global'], + min_val=-1, + max_val=9000, + ) + self.add_float_spin_input( + 'max_download_speed', + '%s:' % _('Maximum Download Speed (KiB/s)'), + core_conf['max_download_speed'], + min_val=-1.0, + max_val=60000.0, + ) + self.add_float_spin_input( + 'max_upload_speed', + '%s:' % _('Maximum Upload Speed (KiB/s)'), + core_conf['max_upload_speed'], + min_val=-1.0, + max_val=60000.0, + ) + self.add_int_spin_input( + 'max_half_open_connections', + '%s:' % _('Maximum Half-Open Connections'), + core_conf['max_half_open_connections'], + min_val=-1, + max_val=9999, + ) + self.add_int_spin_input( + 'max_connections_per_second', + '%s:' % _('Maximum Connection Attempts per Second'), + core_conf['max_connections_per_second'], + min_val=-1, + max_val=9999, + ) + self.add_checked_input( + 'ignore_limits_on_local_network', + _('Ignore limits on local network'), + core_conf['ignore_limits_on_local_network'], + ) + self.add_checked_input( + 'rate_limit_ip_overhead', + _('Rate Limit IP Overhead'), + core_conf['rate_limit_ip_overhead'], + ) + self.add_header(_('Per Torrent Bandwidth Usage'), space_above=True) + self.add_int_spin_input( + 'max_connections_per_torrent', + '%s:' % _('Maximum Connections'), + core_conf['max_connections_per_torrent'], + min_val=-1, + max_val=9000, + ) + self.add_int_spin_input( + 'max_upload_slots_per_torrent', + '%s:' % _('Maximum Upload Slots'), + core_conf['max_upload_slots_per_torrent'], + min_val=-1, + max_val=9000, + ) + self.add_float_spin_input( + 'max_download_speed_per_torrent', + '%s:' % _('Maximum Download Speed (KiB/s)'), + core_conf['max_download_speed_per_torrent'], + min_val=-1.0, + max_val=60000.0, + ) + self.add_float_spin_input( + 'max_upload_speed_per_torrent', + '%s:' % _('Maximum Upload Speed (KiB/s)'), + core_conf['max_upload_speed_per_torrent'], + min_val=-1.0, + max_val=60000.0, + ) + + +class OtherPane(BasePreferencePane): + def __init__(self, preferences): + BasePreferencePane.__init__(self, ' %s ' % _('Other'), preferences) + + @overrides(BasePreferencePane) + def create_pane(self, core_conf, console_config): + self.add_header(_('System Information')) + self.add_info_field('info1', ' Help us improve Deluge by sending us your', '') + self.add_info_field( + 'info2', ' Python version, PyGTK version, OS and processor', '' + ) + self.add_info_field( + 'info3', ' types. Absolutely no other information is sent.', '' + ) + self.add_checked_input( + 'send_info', + _('Yes, please send anonymous statistics.'), + core_conf['send_info'], + ) + self.add_header(_('GeoIP Database'), space_above=True) + self.add_text_input( + 'geoip_db_location', 'Location:', core_conf['geoip_db_location'] + ) + + +class DaemonPane(BasePreferencePane): + def __init__(self, preferences): + BasePreferencePane.__init__(self, ' %s ' % _('Daemon'), preferences) + + @overrides(BasePreferencePane) + def create_pane(self, core_conf, console_config): + self.add_header('Port') + self.add_int_spin_input( + 'daemon_port', + '%s:' % _('Daemon Port'), + core_conf['daemon_port'], + min_val=0, + max_val=65535, + ) + self.add_header('Connections', space_above=True) + self.add_checked_input( + 'allow_remote', _('Allow remote connections'), core_conf['allow_remote'] + ) + self.add_header('Other', space_above=True) + self.add_checked_input( + 'new_release_check', + _('Periodically check the website for new releases'), + core_conf['new_release_check'], + ) + + +class QueuePane(BasePreferencePane): + def __init__(self, preferences): + BasePreferencePane.__init__(self, ' %s ' % _('Queue'), preferences) + + @overrides(BasePreferencePane) + def create_pane(self, core_conf, console_config): + self.add_header(_('New Torrents')) + self.add_checked_input( + 'queue_new_to_top', _('Queue to top'), core_conf['queue_new_to_top'] + ) + self.add_header(_('Active Torrents'), True) + self.add_int_spin_input( + 'max_active_limit', + '%s:' % _('Total'), + core_conf['max_active_limit'], + min_val=-1, + max_val=9999, + ) + self.add_int_spin_input( + 'max_active_downloading', + '%s:' % _('Downloading'), + core_conf['max_active_downloading'], + min_val=-1, + max_val=9999, + ) + self.add_int_spin_input( + 'max_active_seeding', + '%s:' % _('Seeding'), + core_conf['max_active_seeding'], + min_val=-1, + max_val=9999, + ) + self.add_checked_input( + 'dont_count_slow_torrents', + 'Ignore slow torrents', + core_conf['dont_count_slow_torrents'], + ) + self.add_checked_input( + 'auto_manage_prefer_seeds', + 'Prefer seeding torrents', + core_conf['auto_manage_prefer_seeds'], + ) + self.add_header(_('Seeding Rotation'), space_above=True) + self.add_float_spin_input( + 'share_ratio_limit', + '%s:' % _('Share Ratio'), + core_conf['share_ratio_limit'], + precision=2, + min_val=-1.0, + max_val=100.0, + ) + self.add_float_spin_input( + 'seed_time_ratio_limit', + '%s:' % _('Time Ratio'), + core_conf['seed_time_ratio_limit'], + precision=2, + min_val=-1.0, + max_val=100.0, + ) + self.add_int_spin_input( + 'seed_time_limit', + '%s:' % _('Time (m)'), + core_conf['seed_time_limit'], + min_val=1, + max_val=10000, + ) + seedratio = FloatSpinInput( + self.mode, + 'stop_seed_ratio', + '', + self.move, + core_conf['stop_seed_ratio'], + precision=2, + inc_amt=0.1, + min_val=0.5, + max_val=100.0, + ) + self.add_checkedplus_input( + 'stop_seed_at_ratio', + '%s:' % _('Share Ratio Reached'), + seedratio, + core_conf['stop_seed_at_ratio'], + ) + self.add_checked_input( + 'remove_seed_at_ratio', + _('Remove torrent (Unchecked pauses torrent)'), + core_conf['remove_seed_at_ratio'], + ) + + +class ProxyPane(BasePreferencePane): + def __init__(self, preferences): + BasePreferencePane.__init__(self, ' %s ' % _('Proxy'), preferences) + + @overrides(BasePreferencePane) + def create_pane(self, core_conf, console_config): + proxy = core_conf['proxy'] + + self.add_header(_('Proxy Settings')) + self.add_header(_('Proxy'), space_above=True) + self.add_int_spin_input( + 'proxy_type', '%s:' % _('Type'), proxy['type'], min_val=0, max_val=5 + ) + self.add_text_input('proxy_username', '%s:' % _('Username'), proxy['username']) + self.add_text_input('proxy_password', '%s:' % _('Password'), proxy['password']) + self.add_text_input('proxy_hostname', '%s:' % _('Hostname'), proxy['hostname']) + self.add_int_spin_input( + 'proxy_port', '%s:' % _('Port'), proxy['port'], min_val=0, max_val=65535 + ) + self.add_checked_input( + 'proxy_hostnames', _('Proxy Hostnames'), proxy['proxy_hostnames'] + ) + self.add_checked_input( + 'proxy_peer_connections', _('Proxy Peers'), proxy['proxy_peer_connections'] + ) + self.add_checked_input( + 'proxy_tracker_connections', + _('Proxy Trackers'), + proxy['proxy_tracker_connections'], + ) + self.add_header('%s' % _('Force Proxy'), space_above=True) + self.add_checked_input('force_proxy', _('Force Proxy'), proxy['force_proxy']) + self.add_checked_input( + 'anonymous_mode', _('Hide Client Identity'), proxy['anonymous_mode'] + ) + self.add_header('%s' % _('Proxy Type Help'), space_above=True) + self.add_text_area( + 'proxy_text_area', + ' 0: None 1: Socks4\n' + ' 2: Socks5 3: Socks5 Auth\n' + ' 4: HTTP 5: HTTP Auth\n' + ' 6: I2P', + ) + + +class CachePane(BasePreferencePane): + def __init__(self, preferences): + BasePreferencePane.__init__(self, ' %s ' % _('Cache'), preferences) + self.created = False + + @overrides(BasePreferencePane) + def create_pane(self, core_conf, console_config): + self.core_conf = core_conf + + def build_pane(self, core_conf, status): + self.created = True + self.add_header(_('Settings'), space_below=True) + self.add_int_spin_input( + 'cache_size', + '%s:' % _('Cache Size (16 KiB blocks)'), + core_conf['cache_size'], + min_val=0, + max_val=99999, + ) + self.add_int_spin_input( + 'cache_expiry', + '%s:' % _('Cache Expiry (seconds)'), + core_conf['cache_expiry'], + min_val=1, + max_val=32000, + ) + self.add_header(' %s' % _('Write'), space_above=True) + self.add_info_field( + 'blocks_written', + ' %s:' % _('Blocks Written'), + status['disk.num_blocks_written'], + ) + self.add_info_field( + 'writes', ' %s:' % _('Writes'), status['disk.num_write_ops'] + ) + self.add_info_field( + 'write_hit_ratio', + ' %s:' % _('Write Cache Hit Ratio'), + '%.2f' % status['write_hit_ratio'], + ) + self.add_header(' %s' % _('Read')) + self.add_info_field( + 'blocks_read', ' %s:' % _('Blocks Read'), status['disk.num_blocks_read'] + ) + self.add_info_field( + 'blocks_read_hit', + ' %s:' % _('Blocks Read hit'), + status['disk.num_blocks_cache_hits'], + ) + self.add_info_field('reads', ' %s:' % _('Reads'), status['disk.num_read_ops']) + self.add_info_field( + 'read_hit_ratio', + ' %s:' % _('Read Cache Hit Ratio'), + '%.2f' % status['read_hit_ratio'], + ) + self.add_header(' %s' % _('Size')) + self.add_info_field( + 'cache_size_info', + ' %s:' % _('Cache Size'), + status['disk.disk_blocks_in_use'], + ) + self.add_info_field( + 'read_cache_size', + ' %s:' % _('Read Cache Size'), + status['disk.read_cache_blocks'], + ) + + @overrides(BasePreferencePane) + def update(self, active): + if active: + client.core.get_session_status(DISK_CACHE_KEYS).addCallback( + self.update_cache_status_fields + ) + + def update_cache_status_fields(self, status): + if not self.created: + self.build_pane(self.core_conf, status) + else: + for ipt in self.inputs: + if not ipt.has_input() and ipt.name in status: + ipt.set_value(status[ipt.name]) + self.preferences.refresh() diff --git a/deluge/ui/console/modes/preferences/preferences.py b/deluge/ui/console/modes/preferences/preferences.py new file mode 100644 index 0000000..45a39a6 --- /dev/null +++ b/deluge/ui/console/modes/preferences/preferences.py @@ -0,0 +1,379 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +from __future__ import unicode_literals + +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 = list(range(length)) + + +class PreferenceSidebar(Sidebar): + def __init__(self, torrentview, width): + height = curses.LINES - 2 + Sidebar.__init__( + self, torrentview, width, height, title=None, border_off_north=1 + ) + self.categories = [ + _('Interface'), + _('Downloads'), + _('Network'), + _('Bandwidth'), + _('Other'), + _('Daemon'), + _('Queue'), + _('Proxy'), + _('Cache'), + ] + for name in self.categories: + self.add_text_field( + name, + name, + selectable=True, + font_unfocused_active='bold', + color_unfocused_active='white,black', + ) + + def on_resize(self): + self.resize_window(curses.LINES - 2, self.width) + + +class Preferences(BaseMode, PopupsHandler): + def __init__(self, parent_mode, stdscr, console_config, encoding=None): + BaseMode.__init__(self, stdscr, encoding=encoding, do_refresh=False) + PopupsHandler.__init__(self) + self.parent_mode = parent_mode + self.cur_cat = 0 + self.messages = deque() + self.action_input = None + self.config_loaded = False + self.console_config = console_config + self.active_port = -1 + self.active_zone = ZONE.CATEGORIES + self.sidebar_width = 15 # Width of the categories pane + + self.sidebar = PreferenceSidebar(parent_mode, self.sidebar_width) + self.sidebar.set_focused(True) + self.sidebar.active_input = 0 + + self._calc_sizes(resize=False) + + self.panes = [ + InterfacePane(self), + DownloadsPane(self), + NetworkPane(self), + BandwidthPane(self), + OtherPane(self), + DaemonPane(self), + QueuePane(self), + ProxyPane(self), + CachePane(self), + ] + + self.action_input = SelectInput( + self, None, None, [_('Cancel'), _('Apply'), _('OK')], [0, 1, 2], 0 + ) + + def load_config(self): + if self.config_loaded: + return + + def on_get_config(core_config): + self.core_config = core_config + self.config_loaded = True + for p in self.panes: + p.create_pane(core_config, self.console_config) + self.refresh() + + client.core.get_config().addCallback(on_get_config) + + def on_get_listen_port(port): + self.active_port = port + + client.core.get_listen_port().addCallback(on_get_listen_port) + + @property + def height(self): + # top/bottom bars: 2, Action buttons (Cancel/Apply/OK): 1 + return self.rows - 3 + + @property + def width(self): + return self.prefs_width + + def _calc_sizes(self, resize=True): + self.prefs_width = self.cols - self.sidebar_width + + if not resize: + return + + for p in self.panes: + p.resize_window(self.height, p.pane_width) + + def _draw_preferences(self): + self.cur_cat = self.sidebar.active_input + self.panes[self.cur_cat].render( + self, self.stdscr, self.prefs_width, self.active_zone == ZONE.PREFRENCES + ) + self.panes[self.cur_cat].refresh() + + def _draw_actions(self): + selected = self.active_zone == ZONE.ACTIONS + self.stdscr.hline(self.rows - 3, self.sidebar_width, b'_', self.cols) + self.action_input.render( + self.stdscr, + self.rows - 2, + width=self.cols, + active=selected, + focus=True, + col=self.cols - 22, + ) + + @overrides(BaseMode) + def on_resize(self, rows, cols): + BaseMode.on_resize(self, rows, cols) + self._calc_sizes() + + if self.popup: + self.popup.handle_resize() + + self.sidebar.on_resize() + self.refresh() + + @overrides(component.Component) + def update(self): + for i, p in enumerate(self.panes): + self.panes[i].update(i == self.cur_cat) + + @overrides(BaseMode) + def resume(self): + BaseMode.resume(self) + self.sidebar.show() + + @overrides(BaseMode) + def refresh(self): + if ( + not component.get('ConsoleUI').is_active_mode(self) + or not self.config_loaded + ): + return + + if self.popup is None and self.messages: + title, msg = self.messages.popleft() + self.push_popup(MessagePopup(self, title, msg)) + + self.stdscr.erase() + self.draw_statusbars() + self._draw_actions() + # Necessary to force updating the stdscr + self.stdscr.noutrefresh() + + self.sidebar.refresh() + + # do this last since it moves the cursor + self._draw_preferences() + + if self.popup: + self.popup.refresh() + + curses.doupdate() + + def _apply_prefs(self): + if self.core_config is None: + return + + def update_conf_value(key, source_dict, dest_dict, updated): + if dest_dict[key] != source_dict[key]: + dest_dict[key] = source_dict[key] + updated = True + return updated + + new_core_config = {} + for pane in self.panes: + if not isinstance(pane, InterfacePane): + pane.add_config_values(new_core_config) + # Apply Core Prefs + if client.connected(): + # Only do this if we're connected to a daemon + config_to_set = {} + for key in new_core_config: + # The values do not match so this needs to be updated + if self.core_config[key] != new_core_config[key]: + config_to_set[key] = new_core_config[key] + + if config_to_set: + # Set each changed config value in the core + client.core.set_config(config_to_set) + client.force_call(True) + # Update the configuration + self.core_config.update(config_to_set) + + # Update Interface Prefs + new_console_config = {} + didupdate = False + for pane in self.panes: + # could just access panes by index, but that would break if panes + # are ever reordered, so do it the slightly slower but safer way + if isinstance(pane, InterfacePane): + pane.add_config_values(new_console_config) + for k in ['ring_bell', 'language']: + didupdate = update_conf_value( + k, new_console_config, self.console_config, didupdate + ) + for k in ['separate_complete', 'move_selection']: + didupdate = update_conf_value( + k, + new_console_config, + self.console_config['torrentview'], + didupdate, + ) + for k in [ + 'ignore_duplicate_lines', + 'save_command_history', + 'third_tab_lists_all', + 'torrents_per_tab_press', + ]: + didupdate = update_conf_value( + k, new_console_config, self.console_config['cmdline'], didupdate + ) + + if didupdate: + self.parent_mode.on_config_changed() + + def _update_preferences(self, core_config): + self.core_config = core_config + for pane in self.panes: + pane.update_values(core_config) + + def _actions_read(self, c): + self.action_input.handle_read(c) + if c in [curses.KEY_ENTER, util.KEY_ENTER2]: + # take action + if self.action_input.selected_index == 0: # Cancel + self.back_to_parent() + elif self.action_input.selected_index == 1: # Apply + self._apply_prefs() + client.core.get_config().addCallback(self._update_preferences) + elif self.action_input.selected_index == 2: # OK + self._apply_prefs() + self.back_to_parent() + + def back_to_parent(self): + component.get('ConsoleUI').set_mode(self.parent_mode.mode_name) + + @overrides(BaseMode) + def read_input(self): + c = self.stdscr.getch() + + if self.popup: + if self.popup.handle_read(c): + self.pop_popup() + self.refresh() + return + + if util.is_printable_chr(c): + char = chr(c) + if char == 'Q': + component.get('ConsoleUI').quit() + elif char == 'h': + self.push_popup(MessagePopup(self, 'Preferences Help', HELP_STR)) + + if self.sidebar.has_focus() and c == util.KEY_ESC: + self.back_to_parent() + return + + def update_active_zone(val): + self.active_zone += val + if self.active_zone == -1: + self.active_zone = ZONE.length - 1 + else: + self.active_zone %= ZONE.length + self.sidebar.set_focused(self.active_zone == ZONE.CATEGORIES) + + if c == util.KEY_TAB: + update_active_zone(1) + elif c == curses.KEY_BTAB: + update_active_zone(-1) + else: + if self.active_zone == ZONE.CATEGORIES: + self.sidebar.handle_read(c) + elif self.active_zone == ZONE.PREFRENCES: + self.panes[self.cur_cat].handle_read(c) + elif self.active_zone == ZONE.ACTIONS: + self._actions_read(c) + + self.refresh() + + def is_active_pane(self, pane): + return pane == self.panes[self.cur_cat] diff --git a/deluge/ui/console/modes/torrentdetail.py b/deluge/ui/console/modes/torrentdetail.py new file mode 100644 index 0000000..d02a0d3 --- /dev/null +++ b/deluge/ui/console/modes/torrentdetail.py @@ -0,0 +1,1026 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +from __future__ import division, unicode_literals + +import logging + +import deluge.component as component +from deluge.common import fsize +from deluge.decorators import overrides +from deluge.ui.client import client +from deluge.ui.common import FILE_PRIORITY +from deluge.ui.console.modes.basemode import BaseMode +from deluge.ui.console.modes.torrentlist.torrentactions import ( + ACTION, + torrent_actions_popup, +) +from deluge.ui.console.utils import colors +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.utils.column import get_column_value, torrent_data_fields +from deluge.ui.console.utils.format_utils import ( + format_priority, + format_progress, + format_row, +) +from deluge.ui.console.widgets.popup import ( + InputPopup, + MessagePopup, + PopupsHandler, + SelectablePopup, +) + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) + +# Big help string that gets displayed when the user hits 'h' +HELP_STR = """\ +This screen shows detailed information about a torrent, and also the \ +information about the individual files in the torrent. + +You can navigate the file list with the Up/Down arrows and use space to \ +collapse/expand the file tree. + +All popup windows can be closed/canceled by hitting the Esc key \ +(you might need to wait a second for an Esc to register) + +The actions you can perform and the keys to perform them are as follows: + +{!info!}'h'{!normal!} - Show this help + +{!info!}'a'{!normal!} - Show torrent actions popup. Here you can do things like \ +pause/resume, recheck, set torrent options and so on. + +{!info!}'r'{!normal!} - Rename currently highlighted folder or a file. You can't \ +rename multiple files at once so you need to first clear your selection \ +with {!info!}'c'{!normal!} + +{!info!}'m'{!normal!} - Mark or unmark a file or a folder +{!info!}'c'{!normal!} - Un-mark all files + +{!info!}Space{!normal!} - Expand/Collapse currently selected folder + +{!info!}Enter{!normal!} - Show priority popup in which you can set the \ +download priority of selected files and folders. + +{!info!}Left Arrow{!normal!} - Go back to torrent overview. +""" + + +class TorrentDetail(BaseMode, PopupsHandler): + def __init__(self, parent_mode, stdscr, console_config, encoding=None): + PopupsHandler.__init__(self) + self.console_config = console_config + self.parent_mode = parent_mode + self.torrentid = None + self.torrent_state = None + self._status_keys = [ + 'files', + 'name', + 'state', + 'download_payload_rate', + 'upload_payload_rate', + 'progress', + 'eta', + 'all_time_download', + 'total_uploaded', + 'ratio', + 'num_seeds', + 'total_seeds', + 'num_peers', + 'total_peers', + 'active_time', + 'seeding_time', + 'time_added', + 'distributed_copies', + 'num_pieces', + 'piece_length', + 'download_location', + 'file_progress', + 'file_priorities', + 'message', + 'total_wanted', + 'tracker_host', + 'owner', + 'seed_rank', + 'last_seen_complete', + 'completed_time', + 'time_since_transfer', + 'super_seeding', + ] + self.file_list = None + self.current_file = None + self.current_file_idx = 0 + self.file_off = 0 + self.more_to_draw = False + self.full_names = None + self.column_string = '' + self.files_sep = None + self.marked = {} + + BaseMode.__init__(self, stdscr, encoding) + self.column_names = ['Filename', 'Size', 'Progress', 'Priority'] + self.__update_columns() + + self._listing_start = self.rows // 2 + self._listing_space = self._listing_start - self._listing_start + + client.register_event_handler( + 'TorrentFileRenamedEvent', self._on_torrentfilerenamed_event + ) + client.register_event_handler( + 'TorrentFolderRenamedEvent', self._on_torrentfolderrenamed_event + ) + client.register_event_handler( + 'TorrentRemovedEvent', self._on_torrentremoved_event + ) + + util.safe_curs_set(util.Curser.INVISIBLE) + self.stdscr.notimeout(0) + + def set_torrent_id(self, torrentid): + self.torrentid = torrentid + self.file_list = None + + def back_to_overview(self): + component.get('ConsoleUI').set_mode(self.parent_mode.mode_name) + + @overrides(component.Component) + def start(self): + self.update() + + @overrides(component.Component) + def update(self, torrentid=None): + if torrentid: + self.set_torrent_id(torrentid) + + if self.torrentid: + component.get('SessionProxy').get_torrent_status( + self.torrentid, self._status_keys + ).addCallback(self.set_state) + + @overrides(BaseMode) + def pause(self): + self.set_torrent_id(None) + + @overrides(BaseMode) + def on_resize(self, rows, cols): + BaseMode.on_resize(self, rows, cols) + self.__update_columns() + if self.popup: + self.popup.handle_resize() + + self._listing_start = self.rows // 2 + self.refresh() + + def set_state(self, state): + + if state.get('files'): + self.full_names = {x['index']: x['path'] for x in state['files']} + + need_prio_update = False + if not self.file_list: + # don't keep getting the files once we've got them once + if state.get('files'): + self.files_sep = '{!green,black,bold,underline!}%s' % ( + ('Files (torrent has %d files)' % len(state['files'])).center( + self.cols + ) + ) + self.file_list, self.file_dict = self.build_file_list( + state['files'], state['file_progress'], state['file_priorities'] + ) + else: + self.files_sep = '{!green,black,bold,underline!}%s' % ( + ('Files (File list unknown)').center(self.cols) + ) + need_prio_update = True + + self.__fill_progress(self.file_list, state['file_progress']) + + for i, prio in enumerate(state['file_priorities']): + if self.file_dict[i][6] != prio: + need_prio_update = True + self.file_dict[i][6] = prio + if need_prio_update and self.file_list: + self.__fill_prio(self.file_list) + del state['file_progress'] + del state['file_priorities'] + self.torrent_state = state + self.refresh() + + def build_file_list(self, torrent_files, progress, priority): + """ Split file list from torrent state into a directory tree. + + Returns: + + Tuple: + A list of lists in the form: + [file/dir_name, index, size, children, expanded, progress, priority] + + Dictionary: + Map of file index for fast updating of progress and priorities. + """ + + file_list = [] + file_dict = {} + # directory index starts from total file count. + dir_idx = len(torrent_files) + for torrent_file in torrent_files: + cur = file_list + paths = torrent_file['path'].split('/') + for path in paths: + if not cur or path != cur[-1][0]: + child_list = [] + if path == paths[-1]: + file_progress = format_progress( + progress[torrent_file['index']] * 100 + ) + entry = [ + path, + torrent_file['index'], + torrent_file['size'], + child_list, + False, + file_progress, + priority[torrent_file['index']], + ] + file_dict[torrent_file['index']] = entry + else: + entry = [path, dir_idx, -1, child_list, False, 0, -1] + file_dict[dir_idx] = entry + dir_idx += 1 + cur.append(entry) + cur = child_list + else: + cur = cur[-1][3] + self.__build_sizes(file_list) + self.__fill_progress(file_list, progress) + + return file_list, file_dict + + # fill in the sizes of the directory entries based on their children + def __build_sizes(self, fs): + ret = 0 + for f in fs: + if f[2] == -1: + val = self.__build_sizes(f[3]) + ret += val + f[2] = val + else: + ret += f[2] + return ret + + # fills in progress fields in all entries based on progs + # returns the # of bytes complete in all the children of fs + def __fill_progress(self, fs, progs): + if not progs: + return 0 + tb = 0 + for f in fs: + if f[3]: # dir, has some children + bd = self.__fill_progress(f[3], progs) + f[5] = format_progress(bd // f[2] * 100) + else: # file, update own prog and add to total + bd = f[2] * progs[f[1]] + f[5] = format_progress(progs[f[1]] * 100) + tb += bd + return tb + + def __fill_prio(self, fs): + for f in fs: + if f[3]: # dir, so fill in children and compute our prio + self.__fill_prio(f[3]) + child_prios = [e[6] for e in f[3]] + if len(child_prios) > 1: + f[6] = -2 # mixed + else: + f[6] = child_prios.pop(0) + + def __update_columns(self): + self.column_widths = [-1, 15, 15, 20] + req = sum(col_width for col_width in self.column_widths if col_width >= 0) + if req > self.cols: # can't satisfy requests, just spread out evenly + cw = self.cols // len(self.column_names) + for i in range(0, len(self.column_widths)): + self.column_widths[i] = cw + else: + rem = self.cols - req + var_cols = len( + [col_width for col_width in self.column_widths if col_width < 0] + ) + vw = rem // var_cols + for i in range(0, len(self.column_widths)): + if self.column_widths[i] < 0: + self.column_widths[i] = vw + + self.column_string = '{!green,black,bold!}%s' % ( + ''.join( + [ + '%s%s' + % ( + self.column_names[i], + ' ' * (self.column_widths[i] - len(self.column_names[i])), + ) + for i in range(0, len(self.column_names)) + ] + ) + ) + + def _on_torrentremoved_event(self, torrent_id): + if torrent_id == self.torrentid: + self.back_to_overview() + + def _on_torrentfilerenamed_event(self, torrent_id, index, new_name): + if torrent_id == self.torrentid: + self.file_dict[index][0] = new_name.split('/')[-1] + component.get('SessionProxy').get_torrent_status( + self.torrentid, self._status_keys + ).addCallback(self.set_state) + + def _on_torrentfolderrenamed_event(self, torrent_id, old_folder, new_folder): + if torrent_id == self.torrentid: + fe = None + fl = None + for i in old_folder.strip('/').split('/'): + if not fl: + fe = fl = self.file_list + s = [files for files in fl if files[0].strip('/') == i][0] + fe = s + fl = s[3] + fe[0] = new_folder.strip('/').rpartition('/')[-1] + + # self.__get_file_by_name(old_folder, self.file_list)[0] = new_folder.strip('/') + component.get('SessionProxy').get_torrent_status( + self.torrentid, self._status_keys + ).addCallback(self.set_state) + + def draw_files(self, files, depth, off, idx): + + color_selected = 'blue' + color_partially_selected = 'magenta' + color_highlighted = 'white' + for fl in files: + # from sys import stderr + # print >> stderr, fl[6] + # kick out if we're going to draw too low on the screen + if off >= self.rows - 1: + self.more_to_draw = True + return -1, -1 + + # default color values + fg = 'white' + bg = 'black' + attr = '' + + priority_fg_color = { + -2: 'white', # Mixed + 0: 'red', # Skip + 1: 'yellow', # Low + 2: 'yellow', + 3: 'yellow', + 4: 'white', # Normal + 5: 'green', + 6: 'green', + 7: 'green', # High + } + + fg = priority_fg_color[fl[6]] + + if idx >= self.file_off: + # set fg/bg colors based on whether the file is selected/marked or not + + if fl[1] in self.marked: + bg = color_selected + if fl[3]: + if self.marked[fl[1]] < self.__get_contained_files_count( + file_list=fl[3] + ): + bg = color_partially_selected + attr = 'bold' + + if idx == self.current_file_idx: + self.current_file = fl + bg = color_highlighted + if fl[1] in self.marked: + fg = color_selected + if fl[3]: + if self.marked[fl[1]] < self.__get_contained_files_count( + file_list=fl[3] + ): + fg = color_partially_selected + else: + if fg == 'white': + fg = 'black' + attr = 'bold' + + if attr: + color_string = '{!%s,%s,%s!}' % (fg, bg, attr) + else: + color_string = '{!%s,%s!}' % (fg, bg) + + # actually draw the dir/file string + if fl[3] and fl[4]: # this is an expanded directory + xchar = 'v' + elif fl[3]: # collapsed directory + xchar = '>' + else: # file + xchar = '-' + + r = format_row( + [ + '%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 + + if fl[3] and fl[4]: + # recurse if we have children and are expanded + off, idx = self.draw_files(fl[3], depth + 1, off, idx + 1) + if off < 0: + return (off, idx) + else: + idx += 1 + + return (off, idx) + + def __get_file_list_length(self, file_list=None): + """ + Counts length of the displayed file list. + """ + if file_list is None: + file_list = self.file_list + length = 0 + if file_list: + for element in file_list: + length += 1 + if element[3] and element[4]: + length += self.__get_file_list_length(element[3]) + return length + + def __get_contained_files_count(self, file_list=None, idx=None): + length = 0 + if file_list is None: + file_list = self.file_list + if idx is not None: + for element in file_list: + if element[1] == idx: + return self.__get_contained_files_count(file_list=element[3]) + elif element[3]: + c = self.__get_contained_files_count(file_list=element[3], idx=idx) + if c > 0: + return c + else: + for element in file_list: + length += 1 + if element[3]: + length -= 1 + length += self.__get_contained_files_count(element[3]) + return length + + def render_header(self, row): + status = self.torrent_state + + download_color = '{!info!}' + if status['download_payload_rate'] > 0: + download_color = colors.state_color['Downloading'] + + def add_field(name, row, pre_color='{!info!}', post_color='{!input!}'): + s = '%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 + row = add_field('name', row) + # State + row = add_field('state', row) + + # Print DL info and ETA + s = add_field('downloaded', 0, download_color) + if status['progress'] != 100.0: + s += '/%s' % fsize(status['total_wanted']) + if status['download_payload_rate'] > 0: + s += ' {!yellow!}@ %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 += ' {!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!}%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 + 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!}%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!}%s: {!input!}%s' % ( + torrent_data_fields['seed_rank']['name'], + status['seed_rank'], + ) + row = self.add_string(row, s) + + # Time added + row = add_field('time_added', row) + # Time active + row = add_field('active_time', row) + if status['seeding_time']: + row = add_field('seeding_time', row) + # Download Folder + row = add_field('download_location', row) + # Seed Rank + row = add_field('seed_rank', row) + # Super Seeding + row = add_field('super_seeding', row) + # Last seen complete + row = add_field('last_seen_complete', row) + # Last activity + row = add_field('time_since_transfer', row) + # Owner + if status['owner']: + row = add_field('owner', row) + return row + # Last act + + @overrides(BaseMode) + def refresh(self, lines=None): + # Update the status bars + self.stdscr.erase() + self.draw_statusbars() + + row = 1 + if self.torrent_state: + row = self.render_header(row) + else: + self.add_string(1, 'Waiting for torrent state') + + row += 1 + + if self.files_sep: + self.add_string(row, self.files_sep) + row += 1 + + self._listing_start = row + self._listing_space = self.rows - self._listing_start + + self.add_string(row, self.column_string) + if self.file_list: + row += 1 + self.more_to_draw = False + self.draw_files(self.file_list, 0, row, 0) + + if not component.get('ConsoleUI').is_active_mode(self): + return + + self.stdscr.noutrefresh() + + if self.popup: + self.popup.refresh() + + curses.doupdate() + + def expcol_cur_file(self): + """ + Expand or collapse current file + """ + self.current_file[4] = not self.current_file[4] + self.refresh() + + def file_list_down(self, rows=1): + maxlen = self.__get_file_list_length() - 1 + + self.current_file_idx += rows + + if self.current_file_idx > maxlen: + self.current_file_idx = maxlen + + if self.current_file_idx > self.file_off + (self._listing_space - 3): + self.file_off = self.current_file_idx - (self._listing_space - 3) + + self.refresh() + + def file_list_up(self, rows=1): + self.current_file_idx = max(0, self.current_file_idx - rows) + self.file_off = min(self.file_off, self.current_file_idx) + self.refresh() + + # build list of priorities for all files in the torrent + # based on what is currently selected and a selected priority. + def build_prio_list(self, files, ret_list, parent_prio, selected_prio): + # has a priority been set on my parent (if so, I inherit it) + for f in files: + # Do not set priorities for the whole dir, just selected contents + if f[3]: + self.build_prio_list(f[3], ret_list, parent_prio, selected_prio) + else: # file, need to add to list + if f[1] in self.marked or parent_prio >= 0: + # selected (or parent selected), use requested priority + ret_list.append((f[1], selected_prio)) + else: + # not selected, just keep old priority + ret_list.append((f[1], f[6])) + + def do_priority(self, priority, was_empty): + plist = [] + self.build_prio_list(self.file_list, plist, -1, priority) + plist.sort() + priorities = [p[1] for p in plist] + client.core.set_torrent_options( + [self.torrentid], {'file_priorities': priorities} + ) + + if was_empty: + self.marked = {} + return True + + # show popup for priority selections + def show_priority_popup(self, was_empty): + def popup_func(name, data, was_empty, **kwargs): + if not name: + return + return self.do_priority(data[name], was_empty) + + if self.marked: + popup = SelectablePopup( + self, + 'Set File Priority', + popup_func, + border_off_north=1, + cb_args={'was_empty': was_empty}, + ) + popup.add_line( + 'skip_priority', + '_Skip', + foreground='red', + cb_arg=FILE_PRIORITY['Low'], + was_empty=was_empty, + ) + popup.add_line( + 'low_priority', '_Low', cb_arg=FILE_PRIORITY['Low'], foreground='yellow' + ) + popup.add_line('normal_priority', '_Normal', cb_arg=FILE_PRIORITY['Normal']) + popup.add_line( + 'high_priority', + '_High', + cb_arg=FILE_PRIORITY['High'], + foreground='green', + ) + popup._selected = 1 + self.push_popup(popup) + + def __mark_unmark(self, idx): + """ + Selects or unselects file or a catalog(along with contained files) + """ + fc = self.__get_contained_files_count(idx=idx) + if idx not in self.marked: + # Not selected, select it + self.__mark_tree(self.file_list, idx) + elif self.marked[idx] < fc: + # Partially selected, unselect all contents + self.__unmark_tree(self.file_list, idx) + else: + # Selected, unselect it + self.__unmark_tree(self.file_list, idx) + + def __mark_tree(self, file_list, idx, mark_all=False): + """ + Given file_list of TorrentDetail and index of file or folder, + recursively selects all files contained + as well as marks folders higher in hierarchy as partially selected + """ + total_marked = 0 + for element in file_list: + marked = 0 + # Select the file if it's the one we want or + # if it's inside a directory that got selected + if (element[1] == idx) or mark_all: + # If it's a folder then select everything inside + if element[3]: + marked = self.__mark_tree(element[3], idx, True) + self.marked[element[1]] = marked + else: + marked = 1 + self.marked[element[1]] = 1 + else: + # Does not match but the item to be selected might be inside, recurse + if element[3]: + marked = self.__mark_tree(element[3], idx, False) + # Partially select the folder if it contains files that were selected + if marked > 0: + self.marked[element[1]] = marked + else: + if element[1] in self.marked: + # It's not the element we want but it's marked so count it + marked = 1 + # Count and then return total amount of files selected in all subdirectories + total_marked += marked + + return total_marked + + def __get_file_by_num(self, num, file_list, idx=0): + for element in file_list: + if idx == num: + return element + if element[3] and element[4]: + i = self.__get_file_by_num(num, element[3], idx + 1) + if not isinstance(i, int): + return i + idx = i + else: + idx += 1 + return idx + + def __get_file_by_name(self, name, file_list, idx=0): + for element in file_list: + if element[0].strip('/') == name.strip('/'): + return element + if element[3] and element[4]: + i = self.__get_file_by_name(name, element[3], idx + 1) + if not isinstance(i, int): + return i + else: + idx = i + else: + idx += 1 + return idx + + def __unmark_tree(self, file_list, idx, unmark_all=False): + """ + Given file_list of TorrentDetail and index of file or folder, + recursively deselects all files contained + as well as marks folders higher in hierarchy as unselected or partially selected + """ + total_marked = 0 + for element in file_list: + marked = 0 + # It's either the item we want to select or + # a contained item, deselect it + if (element[1] == idx) or unmark_all: + if element[1] in self.marked: + del self.marked[element[1]] + # Deselect all contents if it's a catalog + if element[3]: + self.__unmark_tree(element[3], idx, True) + else: + # Not file we wanted but it might be inside this folder, recurse inside + if element[3]: + marked = self.__unmark_tree(element[3], idx, False) + # If none of the contents remain selected, unselect this folder as well + if marked == 0: + if element[1] in self.marked: + del self.marked[element[1]] + # Otherwise update selection count + else: + self.marked[element[1]] = marked + else: + if element[1] in self.marked: + marked = 1 + + # Count and then return selection count so we can update + # directories higher up in the hierarchy + total_marked += marked + return total_marked + + def _selection_to_file_idx(self, file_list=None, idx=0, true_idx=0, closed=False): + if not file_list: + file_list = self.file_list + + for element in file_list: + if idx == self.current_file_idx: + return true_idx + + # It's a folder + if element[3]: + i = self._selection_to_file_idx( + element[3], idx + 1, true_idx, closed or not element[4] + ) + if isinstance(i, tuple): + idx, true_idx = i + if element[4]: + idx, true_idx = i + else: + idx += 1 + tmp, true_idx = i + else: + return i + else: + if not closed: + idx += 1 + true_idx += 1 + + return (idx, true_idx) + + def _get_full_folder_path(self, num, file_list=None, path='', idx=0): + if not file_list: + file_list = self.file_list + + for element in file_list: + if not element[3]: + idx += 1 + continue + if num == idx: + return '%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 + idx = i + else: + idx += 1 + return idx + + def _do_rename_folder(self, torrent_id, folder, new_folder): + client.core.rename_folder(torrent_id, folder, new_folder) + + def _do_rename_file(self, torrent_id, file_idx, new_filename): + if not new_filename: + return + client.core.rename_files(torrent_id, [(file_idx, new_filename)]) + + def _show_rename_popup(self): + # Perhaps in the future: Renaming multiple files + if self.marked: + self.report_message( + 'Error (Enter to close)', + 'Sorry, you cannot rename multiple files, please clear ' + 'selection with {!info!}"c"{!normal!} key', + ) + else: + _file = self.__get_file_by_num(self.current_file_idx, self.file_list) + old_filename = _file[0] + idx = self._selection_to_file_idx() + tid = self.torrentid + + if _file[3]: + + def do_rename(result, **kwargs): + if ( + not result + or not result['new_foldername']['value'] + or kwargs.get('close', False) + ): + self.popup.close(None, call_cb=False) + return + old_fname = self._get_full_folder_path(self.current_file_idx) + new_fname = '%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_input( + 'new_foldername', + 'Enter new folder name:', + old_filename.strip('/'), + complete=True, + ) + self.push_popup(popup) + else: + + def do_rename(result, **kwargs): + if ( + not result + or not result['new_filename']['value'] + or kwargs.get('close', False) + ): + self.popup.close(None, call_cb=False) + return + fname = '%s/%s' % ( + self.full_names[idx].rpartition('/')[0], + result['new_filename']['value'], + ) + self._do_rename_file(tid, idx, fname) + + popup = InputPopup(self, ' Rename file ', close_cb=do_rename) + popup.add_text_input( + 'new_filename', 'Enter new filename:', old_filename, complete=True + ) + self.push_popup(popup) + + @overrides(BaseMode) + def read_input(self): + c = self.stdscr.getch() + + if self.popup: + ret = self.popup.handle_read(c) + if ret != util.ReadState.IGNORED and self.popup.closed(): + self.pop_popup() + self.refresh() + return + + if c in [util.KEY_ESC, curses.KEY_LEFT, ord('q')]: + self.back_to_overview() + return util.ReadState.READ + + if not self.torrent_state: + # actions below only make sense if there is a torrent state + return + + # Navigate the torrent list + if c == curses.KEY_UP: + self.file_list_up() + elif c == curses.KEY_PPAGE: + self.file_list_up(self._listing_space - 2) + elif c == curses.KEY_HOME: + self.file_off = 0 + self.current_file_idx = 0 + elif c == curses.KEY_DOWN: + self.file_list_down() + elif c == curses.KEY_NPAGE: + self.file_list_down(self._listing_space - 2) + elif c == curses.KEY_END: + self.current_file_idx = self.__get_file_list_length() - 1 + self.file_off = self.current_file_idx - (self._listing_space - 3) + elif c == curses.KEY_DC: + torrent_actions_popup(self, [self.torrentid], action=ACTION.REMOVE) + elif c in [curses.KEY_ENTER, util.KEY_ENTER2]: + was_empty = self.marked == {} + self.__mark_tree(self.file_list, self.current_file[1]) + self.show_priority_popup(was_empty) + elif c == util.KEY_SPACE: + self.expcol_cur_file() + elif c == ord('m'): + if self.current_file: + self.__mark_unmark(self.current_file[1]) + elif c == ord('r'): + self._show_rename_popup() + elif c == ord('c'): + self.marked = {} + elif c == ord('a'): + torrent_actions_popup(self, [self.torrentid], details=False) + return + elif c == ord('o'): + torrent_actions_popup(self, [self.torrentid], action=ACTION.TORRENT_OPTIONS) + return + elif c == ord('h'): + self.push_popup(MessagePopup(self, 'Help', HELP_STR, width_req=0.75)) + elif c == ord('j'): + self.file_list_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 0000000..18c4db3 --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/__init__.py @@ -0,0 +1,20 @@ +from __future__ import unicode_literals + + +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 0000000..b0ac483 --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/add_torrents_popup.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +from __future__ import unicode_literals + +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 0000000..0f39b5c --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/filtersidebar.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 bendikro <bro.devel+deluge@gmail.com> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +from __future__ import unicode_literals + +import curses +import logging + +from deluge.component import Component +from deluge.decorators import overrides +from deluge.ui.client import client +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.widgets import BaseInputPane +from deluge.ui.console.widgets.sidebar import Sidebar + +log = logging.getLogger(__name__) + + +class FilterSidebar(Sidebar, Component): + """The sidebar in the main torrentview + + Shows the different states of the torrents and allows to filter the + torrents based on state. + + """ + + def __init__(self, torrentlist, config): + self.config = config + height = curses.LINES - 2 + width = self.config['torrentview']['sidebar_width'] + Sidebar.__init__( + self, + torrentlist, + width, + height, + title=' Filter ', + border_off_north=1, + allow_resize=True, + ) + Component.__init__(self, 'FilterSidebar') + self.checked_index = 0 + kwargs = { + 'checked_char': '*', + 'unchecked_char': '-', + 'checkbox_format': ' %s ', + 'col': 0, + } + self.add_checked_input('All', 'All', checked=True, **kwargs) + self.add_checked_input('Active', 'Active', **kwargs) + self.add_checked_input( + 'Downloading', 'Downloading', color='green,black', **kwargs + ) + self.add_checked_input('Seeding', 'Seeding', color='cyan,black', **kwargs) + self.add_checked_input('Paused', 'Paused', **kwargs) + self.add_checked_input('Error', 'Error', color='red,black', **kwargs) + self.add_checked_input('Checking', 'Checking', color='blue,black', **kwargs) + self.add_checked_input('Queued', 'Queued', **kwargs) + self.add_checked_input( + 'Allocating', 'Allocating', color='yellow,black', **kwargs + ) + self.add_checked_input('Moving', 'Moving', color='green,black', **kwargs) + + @overrides(Component) + def update(self): + if not self.hidden() and client.connected(): + d = client.core.get_filter_tree(True, []).addCallback( + self._cb_update_filter_tree + ) + + def on_filter_tree_updated(changed): + if changed: + self.refresh() + + d.addCallback(on_filter_tree_updated) + + def _cb_update_filter_tree(self, filter_items): + """Callback function on client.core.get_filter_tree""" + states = filter_items['state'] + largest_count = 0 + largest_state_width = 0 + for state in states: + largest_state_width = max(len(state[0]), largest_state_width) + largest_count = max(int(state[1]), largest_count) + + border_and_spacing = 6 # Account for border + whitespace + filter_state_width = largest_state_width + filter_count_width = self.width - filter_state_width - border_and_spacing + + changed = False + for state in states: + field = self.get_input(state[0]) + if field: + txt = ( + '%%-%ds%%%ds' + % (filter_state_width, filter_count_width) + % (state[0], state[1]) + ) + if field.set_message(txt): + changed = True + return changed + + @overrides(BaseInputPane) + def immediate_action_cb(self, state_changed=True): + if state_changed: + self.parent.torrentview.set_torrent_filter( + self.inputs[self.active_input].name + ) + + @overrides(Sidebar) + def handle_read(self, c): + if c == util.KEY_SPACE: + if self.checked_index != self.active_input: + self.inputs[self.checked_index].set_value(False) + Sidebar.handle_read(self, c) + self.checked_index = self.active_input + return util.ReadState.READ + else: + return Sidebar.handle_read(self, c) + + @overrides(Sidebar) + def on_resize(self, width): + sidebar_width = self.config['torrentview']['sidebar_width'] + if sidebar_width != width: + self.config['torrentview']['sidebar_width'] = width + self.config.save() + self.resize_window(self.height, width) + self.parent.toggle_sidebar() + self.refresh() diff --git a/deluge/ui/console/modes/torrentlist/queue_mode.py b/deluge/ui/console/modes/torrentlist/queue_mode.py new file mode 100644 index 0000000..0c44aaf --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/queue_mode.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +from __future__ import unicode_literals + +from deluge.ui.client import client +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.widgets.popup import MessagePopup, SelectablePopup + +from . import ACTION + +try: + import curses +except ImportError: + pass + +key_to_action = { + curses.KEY_HOME: ACTION.QUEUE_TOP, + curses.KEY_UP: ACTION.QUEUE_UP, + curses.KEY_DOWN: ACTION.QUEUE_DOWN, + curses.KEY_END: ACTION.QUEUE_BOTTOM, +} +QUEUE_MODE_HELP_STR = """ +Change queue position of selected torrents + +{!info!}'+'{!normal!} - {|indent_pos:|}Move up +{!info!}'-'{!normal!} - {|indent_pos:|}Move down + +{!info!}'Home'{!normal!} - {|indent_pos:|}Move to top +{!info!}'End'{!normal!} - {|indent_pos:|}Move to bottom + +""" + + +class QueueMode(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 = list(range(1, selected_num + 1)) + elif qact == ACTION.QUEUE_UP: + self.torrentview.cursel = max(1, self.torrentview.cursel - 1) + self.torrentview.marked = [marked - 1 for marked in self.torrentview.marked] + self.torrentview.marked = [ + marked for marked in self.torrentview.marked if marked > 0 + ] + elif qact == ACTION.QUEUE_DOWN: + self.torrentview.cursel = min(queue_length, self.torrentview.cursel + 1) + self.torrentview.marked = [marked + 1 for marked in self.torrentview.marked] + self.torrentview.marked = [ + marked for marked in self.torrentview.marked if marked <= queue_length + ] + elif qact == ACTION.QUEUE_BOTTOM: + if self.torrentview.marked: + self.torrentview.cursel = ( + queue_length + - selected_num + + 1 + + sorted(self.torrentview.marked).index(self.torrentview.cursel) + ) + else: + self.torrentview.cursel = queue_length + self.torrentview.marked = list( + range(queue_length - selected_num + 1, queue_length + 1) + ) + + def do_queue(self, qact, *args, **kwargs): + if qact == ACTION.QUEUE_TOP: + client.core.queue_top(self.torrent_ids).addCallback( + self.move_selection, qact + ) + elif qact == ACTION.QUEUE_BOTTOM: + client.core.queue_bottom(self.torrent_ids).addCallback( + self.move_selection, qact + ) + elif qact == ACTION.QUEUE_UP: + client.core.queue_up(self.torrent_ids).addCallback( + self.move_selection, qact + ) + elif qact == ACTION.QUEUE_DOWN: + client.core.queue_down(self.torrent_ids).addCallback( + self.move_selection, qact + ) + + def popup(self, **kwargs): + popup = SelectablePopup( + self.torrentslist, + 'Queue Action', + self.do_queue, + cb_args=kwargs, + border_off_west=1, + ) + popup.add_line(ACTION.QUEUE_TOP, '_Top') + popup.add_line(ACTION.QUEUE_UP, '_Up') + popup.add_line(ACTION.QUEUE_DOWN, '_Down') + popup.add_line(ACTION.QUEUE_BOTTOM, '_Bottom') + self.torrentslist.push_popup(popup) diff --git a/deluge/ui/console/modes/torrentlist/search_mode.py b/deluge/ui/console/modes/torrentlist/search_mode.py new file mode 100644 index 0000000..57a8e5f --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/search_mode.py @@ -0,0 +1,210 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +from __future__ import unicode_literals + +import logging + +from deluge.common import PY2 +from deluge.decorators import overrides +from deluge.ui.console.modes.basemode import InputKeyHandler, move_cursor +from deluge.ui.console.modes.torrentlist.torrentactions import torrent_actions_popup +from deluge.ui.console.utils import curses_util as util + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) +QUEUE_MODE_HELP_STR = """ +Change queue position of selected torrents + +{!info!}'+'{!normal!} - {|indent_pos:|}Move up +{!info!}'-'{!normal!} - {|indent_pos:|}Move down + +{!info!}'Home'{!normal!} - {|indent_pos:|}Move to top +{!info!}'End'{!normal!} - {|indent_pos:|}Move to bottom + +""" +SEARCH_EMPTY = 0 +SEARCH_FAILING = 1 +SEARCH_SUCCESS = 2 +SEARCH_START_REACHED = 3 +SEARCH_END_REACHED = 4 +SEARCH_FORMAT = { + SEARCH_EMPTY: '{!black,white!}Search torrents: %s{!black,white!}', + SEARCH_SUCCESS: '{!black,white!}Search torrents: {!black,green!}%s{!black,white!}', + SEARCH_FAILING: '{!black,white!}Search torrents: {!black,red!}%s{!black,white!}', + SEARCH_START_REACHED: '{!black,white!}Search torrents: {!black,yellow!}%s{!black,white!} (start reached)', + SEARCH_END_REACHED: '{!black,white!}Search torrents: {!black,yellow!}%s{!black,white!} (end reached)', +} + + +class SearchMode(InputKeyHandler): + def __init__(self, torrentlist): + super(SearchMode, self).__init__() + self.torrentlist = torrentlist + self.torrentview = torrentlist.torrentview + self.search_state = SEARCH_EMPTY + self.search_string = '' + + def update_cursor(self): + util.safe_curs_set(util.Curser.VERY_VISIBLE) + move_cursor( + self.torrentlist.stdscr, + self.torrentlist.rows - 1, + len(self.search_string) + 17, + ) + + def set_statusbar_args(self, statusbar_args): + statusbar_args['bottombar'] = ( + SEARCH_FORMAT[self.search_state] % self.search_string + ) + statusbar_args['bottombar_help'] = False + + def update_colors(self, tidx, colors): + if len(self.search_string) > 1: + lcase_name = self.torrentview.torrent_names[tidx].lower() + sstring_lower = self.search_string.lower() + if lcase_name.find(sstring_lower) != -1: + if tidx == self.torrentview.cursel: + pass + elif tidx in self.torrentview.marked: + colors['bg'] = 'magenta' + else: + colors['bg'] = 'green' + if colors['fg'] == 'green': + colors['fg'] = 'black' + colors['attr'] = 'bold' + + def do_search(self, direction='first'): + """ + Performs a search on visible torrent and sets cursor to the match + + Args: + direction (str): The direction to search. Must be one of 'first', 'last', 'next' or 'previous' + + """ + search_space = list(enumerate(self.torrentview.torrent_names)) + + if direction == 'last': + search_space = reversed(search_space) + elif direction == 'next': + search_space = search_space[self.torrentview.cursel + 1 :] + elif direction == 'previous': + search_space = reversed(search_space[: self.torrentview.cursel]) + + search_string = self.search_string.lower() + for i, n in search_space: + n = n.lower() + if n.find(search_string) != -1: + self.torrentview.cursel = i + if ( + self.torrentview.curoff + + self.torrentview.torrent_rows + - self.torrentview.torrentlist_offset + ) < self.torrentview.cursel: + self.torrentview.curoff = ( + self.torrentview.cursel - self.torrentview.torrent_rows + 1 + ) + elif (self.torrentview.curoff + 1) > self.torrentview.cursel: + self.torrentview.curoff = max(0, self.torrentview.cursel) + self.search_state = SEARCH_SUCCESS + return + if direction in ['first', 'last']: + self.search_state = SEARCH_FAILING + elif direction == 'next': + self.search_state = SEARCH_END_REACHED + elif direction == 'previous': + self.search_state = SEARCH_START_REACHED + + @overrides(InputKeyHandler) + def handle_read(self, c): + cname = self.torrentview.torrent_names[self.torrentview.cursel] + refresh = True + + if c in [ + util.KEY_ESC, + util.KEY_BELL, + ]: # If Escape key or CTRL-g, we abort search + self.torrentlist.set_minor_mode(None) + self.search_state = SEARCH_EMPTY + elif c in [curses.KEY_BACKSPACE, util.KEY_BACKSPACE2]: + if self.search_string: + self.search_string = self.search_string[:-1] + if cname.lower().find(self.search_string.lower()) != -1: + self.search_state = SEARCH_SUCCESS + else: + self.torrentlist.set_minor_mode(None) + self.search_state = SEARCH_EMPTY + elif c == curses.KEY_DC: + self.search_string = '' + self.search_state = SEARCH_SUCCESS + elif c == curses.KEY_UP: + self.do_search('previous') + elif c == curses.KEY_DOWN: + self.do_search('next') + elif c == curses.KEY_LEFT: + self.torrentlist.set_minor_mode(None) + self.search_state = SEARCH_EMPTY + elif c == ord('/'): + self.torrentlist.set_minor_mode(None) + self.search_state = SEARCH_EMPTY + elif c == curses.KEY_RIGHT: + tid = self.torrentview.current_torrent_id() + self.torrentlist.show_torrent_details(tid) + refresh = False + elif c == curses.KEY_HOME: + self.do_search('first') + elif c == curses.KEY_END: + self.do_search('last') + elif c in [10, curses.KEY_ENTER]: + self.last_mark = -1 + tid = self.torrentview.current_torrent_id() + torrent_actions_popup(self.torrentlist, [tid], details=True) + refresh = False + elif c == util.KEY_ESC: + self.search_string = '' + self.search_state = SEARCH_EMPTY + elif c > 31 and c < 256: + old_search_string = self.search_string + stroke = chr(c) + uchar = '' if PY2 else stroke + while not uchar: + try: + uchar = stroke.decode(self.torrentlist.encoding) + except UnicodeDecodeError: + c = self.torrentlist.stdscr.getch() + stroke += chr(c) + + if uchar: + self.search_string += uchar + + still_matching = ( + cname.lower().find(self.search_string.lower()) + == cname.lower().find(old_search_string.lower()) + and cname.lower().find(self.search_string.lower()) != -1 + ) + + if self.search_string and not still_matching: + self.do_search() + elif self.search_string: + self.search_state = SEARCH_SUCCESS + else: + refresh = False + + if not self.search_string: + self.search_state = SEARCH_EMPTY + refresh = True + + if refresh: + self.torrentlist.refresh([]) + + return util.ReadState.READ diff --git a/deluge/ui/console/modes/torrentlist/torrentactions.py b/deluge/ui/console/modes/torrentlist/torrentactions.py new file mode 100644 index 0000000..f3cd395 --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/torrentactions.py @@ -0,0 +1,276 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +from __future__ import unicode_literals + +import logging +import os + +from twisted.internet import defer + +import deluge.component as component +from deluge.ui.client import client +from deluge.ui.common import TORRENT_DATA_FIELD +from deluge.ui.console.modes.torrentlist.queue_mode import QueueMode +from deluge.ui.console.utils import colors +from deluge.ui.console.utils.common import TORRENT_OPTIONS +from deluge.ui.console.widgets.popup import InputPopup, MessagePopup, SelectablePopup + +from . import ACTION + +log = logging.getLogger(__name__) + + +def action_error(error, mode): + mode.report_message('Error Occurred', error.getErrorMessage()) + mode.refresh() + + +def action_remove(mode=None, torrent_ids=None, **kwargs): + def do_remove(*args, **kwargs): + data = args[0] if args else None + if data is None or kwargs.get('close', False): + mode.pop_popup() + return True + + mode.torrentview.clear_marked() + remove_data = data['remove_files']['value'] + + def on_removed_finished(errors): + if errors: + error_msgs = '' + for t_id, e_msg in errors: + error_msgs += '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) + mode.pop_popup() + + def got_status(status): + return (status['name'], status['state']) + + callbacks = [] + for tid in torrent_ids: + d = client.core.get_torrent_status(tid, ['name', 'state']) + callbacks.append(d.addCallback(got_status)) + + def remove_dialog(status): + status = [t_status[1] for t_status in status] + + if len(torrent_ids) == 1: + rem_msg = '{!info!}Remove the following torrent?{!input!}' + else: + rem_msg = '{!info!}Remove the following %d torrents?{!input!}' % len( + torrent_ids + ) + + show_max = 6 + for i, (name, state) in enumerate(status): + color = colors.state_color[state] + rem_msg += '\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.items(): + if val['value'] not in ['multiple', None]: + options[opt] = val['value'] + client.core.set_torrent_options(torrent_ids, options) + + def on_torrent_status(status): + for key in status: + if key not in options: + options[key] = status[key] + elif options[key] != status[key]: + options[key] = 'multiple' + + def create_popup(status): + mode.pop_popup() + + def cb(result, **kwargs): + if result is None: + return + _do_set_torrent_options(torrent_ids, result) + if kwargs.get('close', False): + mode.pop_popup() + return True + + option_popup = InputPopup( + mode, + ' Set Torrent Options ', + close_cb=cb, + border_off_west=1, + border_off_north=1, + base_popup=kwargs.get('base_popup', None), + ) + for field in TORRENT_OPTIONS: + caption = '{!info!}' + TORRENT_DATA_FIELD[field]['name'] + value = options[field] + if isinstance(value, ''.__class__): + option_popup.add_text_input(field, caption, value) + elif isinstance(value, bool): + choices = (['Yes', 'No'], [True, False], [True, False].index(value)) + option_popup.add_select_input( + field, caption, choices[0], choices[1], choices[2] + ) + elif isinstance(value, float): + option_popup.add_float_spin_input( + field, caption, value=value, min_val=-1 + ) + elif isinstance(value, int): + option_popup.add_int_spin_input(field, caption, value=value, min_val=-1) + + mode.push_popup(option_popup) + + callbacks = [] + for tid in torrents: + deferred = component.get('SessionProxy').get_torrent_status( + tid, list(TORRENT_OPTIONS) + ) + callbacks.append(deferred.addCallback(on_torrent_status)) + + callbacks = defer.DeferredList(callbacks) + callbacks.addCallback(create_popup) + + +def torrent_action(action, *args, **kwargs): + retval = False + torrent_ids = kwargs.get('torrent_ids', None) + mode = kwargs.get('mode', None) + + if torrent_ids is None: + return + + if action == ACTION.PAUSE: + log.debug('Pausing torrents: %s', torrent_ids) + client.core.pause_torrents(torrent_ids).addErrback(action_error, mode) + retval = True + elif action == ACTION.RESUME: + log.debug('Resuming torrents: %s', torrent_ids) + client.core.resume_torrents(torrent_ids).addErrback(action_error, mode) + retval = True + elif action == ACTION.QUEUE: + queue_mode = QueueMode(mode, torrent_ids) + queue_mode.popup(**kwargs) + elif action == ACTION.REMOVE: + action_remove(**kwargs) + retval = True + elif action == ACTION.MOVE_STORAGE: + + def do_move(res, **kwargs): + if res is None or kwargs.get('close', False): + mode.pop_popup() + return True + + if os.path.exists(res['path']['value']) and not os.path.isdir( + res['path']['value'] + ): + mode.report_message( + 'Cannot Move Download Folder', + '{!error!}%s exists and is not a directory' % res['path']['value'], + ) + else: + log.debug('Moving %s to: %s', torrent_ids, res['path']['value']) + client.core.move_storage(torrent_ids, res['path']['value']).addErrback( + action_error, mode + ) + + popup = InputPopup( + mode, 'Move Download Folder', close_cb=do_move, border_off_east=1 + ) + popup.add_text_input('path', 'Enter path to move to:', complete=True) + mode.push_popup(popup) + elif action == ACTION.RECHECK: + log.debug('Rechecking torrents: %s', torrent_ids) + client.core.force_recheck(torrent_ids).addErrback(action_error, mode) + retval = True + elif action == ACTION.REANNOUNCE: + log.debug('Reannouncing torrents: %s', torrent_ids) + client.core.force_reannounce(torrent_ids).addErrback(action_error, mode) + retval = True + elif action == ACTION.DETAILS: + log.debug('Torrent details') + tid = mode.torrentview.current_torrent_id() + if tid: + mode.show_torrent_details(tid) + else: + log.error('No current torrent in _torrentaction, this is a bug') + elif action == ACTION.TORRENT_OPTIONS: + action_torrent_info(**kwargs) + + return retval + + +# Creates the popup. mode is the calling mode, tids is a list of torrents to take action upon +def torrent_actions_popup(mode, torrent_ids, details=False, action=None, close_cb=None): + + if action is not None: + torrent_action(action, mode=mode, torrent_ids=torrent_ids) + return + + popup = SelectablePopup( + mode, + 'Torrent Actions', + torrent_action, + cb_args={'mode': mode, 'torrent_ids': torrent_ids}, + close_cb=close_cb, + border_off_north=1, + border_off_west=1, + border_off_east=1, + ) + popup.add_line(ACTION.PAUSE, '_Pause') + popup.add_line(ACTION.RESUME, '_Resume') + if details: + popup.add_divider() + popup.add_line(ACTION.QUEUE, 'Queue') + popup.add_divider() + popup.add_line(ACTION.REANNOUNCE, '_Update Tracker') + popup.add_divider() + popup.add_line(ACTION.REMOVE, 'Remo_ve Torrent') + popup.add_line(ACTION.RECHECK, '_Force Recheck') + popup.add_line(ACTION.MOVE_STORAGE, '_Move Download Folder') + popup.add_divider() + if details: + popup.add_line(ACTION.DETAILS, 'Torrent _Details') + popup.add_line(ACTION.TORRENT_OPTIONS, 'Torrent _Options') + mode.push_popup(popup) diff --git a/deluge/ui/console/modes/torrentlist/torrentlist.py b/deluge/ui/console/modes/torrentlist/torrentlist.py new file mode 100644 index 0000000..a427d65 --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/torrentlist.py @@ -0,0 +1,348 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +from __future__ import unicode_literals + +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+<some key> + if c == util.KEY_ESC: + n = self.stdscr.getch() + if n == -1: # Means it was the escape key + pass + else: # ALT+<some key> + c = [c, n] + + if self.popup: + ret = self.popup.handle_read(c) + if self.popup and self.popup.closed(): + self.pop_popup() + self.refresh() + return ret + if util.is_printable_chr(c): + if chr(c) == 'Q': + component.get('ConsoleUI').quit() + elif chr(c) == 'C': + self.consoleui.set_mode('ConnectionManager') + return + elif chr(c) == 'q': + self.torrentview.update_marked(self.torrentview.cursel) + self.set_minor_mode( + QueueMode(self, self.torrentview._selected_torrent_ids()) + ) + return + elif chr(c) == '/': + self.set_minor_mode(SearchMode(self)) + return + + if self.sidebar.has_focus() and c not in [curses.KEY_RIGHT]: + self.sidebar.handle_read(c) + self.refresh() + return + + if self.torrentview.numtorrents < 0: + return + elif self.minor_mode: + self.minor_mode.handle_read(c) + return + + affected_lines = None + # Hand off to torrentview + if self.torrentview.handle_read(c) == util.ReadState.CHANGED: + affected_lines = self.torrentview.get_input_result() + + if c == curses.KEY_LEFT: + if not self.sidebar.has_focus(): + self.sidebar.set_focused(True) + self.refresh() + return + elif c == curses.KEY_RIGHT: + if self.sidebar.has_focus(): + self.sidebar.set_focused(False) + self.refresh() + return + # We enter a new mode for the selected torrent here + tid = self.torrentview.current_torrent_id() + if tid: + self.show_torrent_details(tid) + return + + elif util.is_printable_chr(c): + if chr(c) == 'a': + show_torrent_add_popup(self) + elif chr(c) == 'v': + self._show_visible_columns_popup() + elif chr(c) == 'h': + self.push_popup(MessagePopup(self, 'Help', HELP_STR, width_req=0.65)) + elif chr(c) == 'p': + mode = self.consoleui.set_mode('Preferences') + mode.load_config() + return + elif chr(c) == 'e': + self.consoleui.set_mode('EventView') + return + elif chr(c) == 'S': + self.config['torrentview']['show_sidebar'] = ( + self.config['torrentview']['show_sidebar'] is False + ) + self.config.save() + self.toggle_sidebar() + elif chr(c) == 'l': + self.consoleui.set_mode('CmdLine', refresh=True) + return + + self.refresh(affected_lines) diff --git a/deluge/ui/console/modes/torrentlist/torrentview.py b/deluge/ui/console/modes/torrentlist/torrentview.py new file mode 100644 index 0000000..67de3e7 --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/torrentview.py @@ -0,0 +1,517 @@ +# -*- coding: utf-8 -*- +# +# 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 unicode_literals + +import logging + +import deluge.component as component +from deluge.decorators import overrides +from deluge.ui.console.modes.basemode import InputKeyHandler +from deluge.ui.console.modes.torrentlist import torrentviewcolumns +from deluge.ui.console.modes.torrentlist.torrentactions import torrent_actions_popup +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.utils import format_utils +from deluge.ui.console.utils.column import ( + get_column_value, + get_required_fields, + torrent_data_fields, +) + +from . import ACTION + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) + + +state_fg_colors = { + 'Downloading': 'green', + 'Seeding': 'cyan', + 'Error': 'red', + 'Queued': 'yellow', + 'Checking': 'blue', + 'Moving': 'green', +} + + +reverse_sort_fields = [ + 'size', + 'download_speed', + 'upload_speed', + 'num_seeds', + 'num_peers', + 'distributed_copies', + 'time_added', + 'total_uploaded', + 'all_time_download', + 'total_remaining', + 'progress', + 'ratio', + 'seeding_time', + 'active_time', +] + + +default_column_values = { + 'queue': {'width': 4, 'visible': True}, + 'name': {'width': -1, 'visible': True}, + 'size': {'width': 8, 'visible': True}, + 'progress': {'width': 7, 'visible': True}, + 'download_speed': {'width': 7, 'visible': True}, + 'upload_speed': {'width': 7, 'visible': True}, + 'state': {'width': 13}, + 'eta': {'width': 8, 'visible': True}, + 'time_added': {'width': 15}, + 'tracker': {'width': 15}, + 'download_location': {'width': 15}, + 'downloaded': {'width': 13}, + 'uploaded': {'width': 7}, + 'remaining': {'width': 13}, + 'completed_time': {'width': 15}, + 'last_seen_complete': {'width': 15}, + 'max_upload_speed': {'width': 7}, +} + + +default_columns = {} +for col_i, col_name in enumerate(torrentviewcolumns.column_pref_names): + default_columns[col_name] = {'width': 10, 'order': col_i, 'visible': False} + if col_name in default_column_values: + default_columns[col_name].update(default_column_values[col_name]) + + +class TorrentView(InputKeyHandler): + def __init__(self, torrentlist, config): + super(TorrentView, self).__init__() + self.torrentlist = torrentlist + self.config = config + self.filter_dict = {} + self.curr_filter = None + self.cached_rows = {} + self.sorted_ids = None + self.torrent_names = None + self.numtorrents = -1 + self.column_string = '' + self.curoff = 0 + self.marked = [] + self.cursel = 0 + + @property + def rows(self): + return self.torrentlist.rows + + @property + def torrent_rows(self): + return self.torrentlist.rows - 3 # Account for header lines + columns line + + @property + def torrentlist_offset(self): + return 2 + + def update_state(self, state, refresh=False): + self.curstate = state # cache in case we change sort order + self.cached_rows.clear() + self.numtorrents = len(state) + self.sorted_ids = self._sort_torrents(state) + self.torrent_names = [] + for torrent_id in self.sorted_ids: + ts = self.curstate[torrent_id] + self.torrent_names.append(ts['name']) + + if refresh: + self.torrentlist.refresh() + + def set_torrent_filter(self, state): + self.curr_filter = state + filter_dict = {'state': [state]} + if state == 'All': + self.curr_filter = None + filter_dict = {} + self.filter_dict = filter_dict + self.torrentlist.go_top = True + self.torrentlist.update() + return True + + def _scroll_up(self, by): + cursel = self.cursel + prevoff = self.curoff + self.cursel = max(self.cursel - by, 0) + if self.cursel < self.curoff: + self.curoff = self.cursel + affected = [] + if prevoff == self.curoff: + affected.append(cursel) + if cursel != self.cursel: + affected.insert(0, self.cursel) + return affected + + def _scroll_down(self, by): + cursel = self.cursel + prevoff = self.curoff + self.cursel = min(self.cursel + by, self.numtorrents - 1) + if (self.curoff + self.torrent_rows) <= self.cursel: + self.curoff = self.cursel - self.torrent_rows + 1 + affected = [] + if prevoff == self.curoff: + affected.append(cursel) + if cursel != self.cursel: + affected.append(self.cursel) + return affected + + def current_torrent_id(self): + if not self.sorted_ids: + return None + return self.sorted_ids[self.cursel] + + def _selected_torrent_ids(self): + if not self.sorted_ids: + return None + ret = [] + for i in self.marked: + ret.append(self.sorted_ids[i]) + return ret + + def clear_marked(self): + self.marked = [] + self.last_mark = -1 + + def mark_unmark(self, idx): + if idx in self.marked: + self.marked.remove(idx) + self.last_mark = -1 + else: + self.marked.append(idx) + self.last_mark = idx + + def add_marked(self, indices, last_marked): + for i in indices: + if i not in self.marked: + self.marked.append(i) + self.last_mark = last_marked + + def update_marked(self, index, last_mark=True, clear=False): + if index not in self.marked: + if clear: + self.marked = [] + self.marked.append(index) + if last_mark: + self.last_mark = index + return True + return False + + def _sort_torrents(self, state): + """Sorts by primary and secondary sort fields.""" + + if not state: + return {} + + s_primary = self.config['torrentview']['sort_primary'] + s_secondary = self.config['torrentview']['sort_secondary'] + + result = state + + # Sort first by secondary sort field and then primary sort field + # so it all works out + + def sort_by_field(state, to_sort, field): + field = torrent_data_fields[field]['status'][0] + reverse = field in reverse_sort_fields + + # Get first element so we can check if it has given field + # and if it's a string + first_element = state[list(state)[0]] + if field in first_element: + + def sort_key(s): + try: + # Sort case-insensitively but preserve A>a order. + return state.get(s)[field].lower() + except AttributeError: + # Not a string. + return state.get(s)[field] + + to_sort = sorted(to_sort, key=sort_key, reverse=reverse) + + if field == 'eta': + to_sort = sorted(to_sort, key=lambda s: state.get(s)['eta'] == 0) + + return to_sort + + # Just in case primary and secondary fields are empty and/or + # both are too ambiguous, also sort by queue position first + if 'queue' not in [s_secondary, s_primary]: + result = sort_by_field(state, result, 'queue') + if s_secondary != s_primary: + result = sort_by_field(state, result, s_secondary) + result = sort_by_field(state, result, s_primary) + + if self.config['torrentview']['separate_complete']: + result = sorted( + result, key=lambda s: state.get(s).get('progress', 0) == 100.0 + ) + + return result + + def _get_colors(self, row, tidx): + # default style + colors = {'fg': 'white', 'bg': 'black', 'attr': None} + + if tidx in self.marked: + colors.update({'bg': 'blue', 'attr': 'bold'}) + + if tidx == self.cursel: + col_selected = {'bg': 'white', 'fg': 'black', 'attr': 'bold'} + if tidx in self.marked: + col_selected['fg'] = 'blue' + colors.update(col_selected) + + colors['fg'] = state_fg_colors.get(row[1], colors['fg']) + + if self.torrentlist.minor_mode: + self.torrentlist.minor_mode.update_colors(tidx, colors) + return colors + + def update_torrents(self, lines): + # add all the torrents + if self.numtorrents == 0: + cols = self.torrentlist.torrentview_columns() + msg = 'No torrents match filter'.center(cols) + self.torrentlist.add_string( + 3, '{!info!}%s' % msg, scr=self.torrentlist.torrentview_panel + ) + elif self.numtorrents == 0: + self.torrentlist.add_string(1, 'Waiting for torrents from core...') + return + + def draw_row(index): + if index not in self.cached_rows: + ts = self.curstate[self.sorted_ids[index]] + self.cached_rows[index] = ( + format_utils.format_row( + [get_column_value(name, ts) for name in self.cols_to_show], + self.column_widths, + ), + ts['state'], + ) + return self.cached_rows[index] + + tidx = self.curoff + currow = 0 + todraw = [] + # Affected lines are given when changing selected torrent + if lines: + for line in lines: + if line < tidx: + continue + if line >= (tidx + self.torrent_rows) or line >= self.numtorrents: + break + todraw.append((line, line - self.curoff, draw_row(line))) + else: + for i in range(tidx, tidx + self.torrent_rows): + if i >= self.numtorrents: + break + todraw.append((i, i - self.curoff, draw_row(i))) + + for tidx, currow, row in todraw: + if (currow + self.torrentlist_offset - 1) > self.torrent_rows: + continue + colors = self._get_colors(row, tidx) + if colors['attr']: + colorstr = '{!%(fg)s,%(bg)s,%(attr)s!}' % colors + else: + colorstr = '{!%(fg)s,%(bg)s!}' % colors + + self.torrentlist.add_string( + currow + self.torrentlist_offset, + '%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 = list(range(self.last_mark, self.cursel + 1)) + else: + mrange = list(range(self.cursel, self.last_mark)) + self.add_marked(mrange, self.cursel) + affected_lines = mrange + else: + self.mark_unmark(self.cursel) + affected_lines = [self.cursel] + elif c == ord('c'): + self.clear_marked() + elif c == ord('o'): + if not self.marked: + added = self.update_marked(self.cursel, clear=True) + else: + self.last_mark = -1 + torrent_actions_popup( + self.torrentlist, + self._selected_torrent_ids(), + action=ACTION.TORRENT_OPTIONS, + ) + elif c in [ord('>'), ord('<')]: + try: + i = self.cols_to_show.index(self.config['torrentview']['sort_primary']) + except ValueError: + i = 0 if chr(c) == '<' else len(self.cols_to_show) + else: + i += 1 if chr(c) == '>' else -1 + + i = max(0, min(len(self.cols_to_show) - 1, i)) + self.config['torrentview']['sort_primary'] = self.cols_to_show[i] + self.config.save() + self.on_config_changed() + self.update_columns() + self.torrentlist.refresh([]) + else: + return util.ReadState.IGNORED + + self.set_input_result(affected_lines) + return util.ReadState.CHANGED if affected_lines else util.ReadState.READ diff --git a/deluge/ui/console/modes/torrentlist/torrentviewcolumns.py b/deluge/ui/console/modes/torrentlist/torrentviewcolumns.py new file mode 100644 index 0000000..9dff843 --- /dev/null +++ b/deluge/ui/console/modes/torrentlist/torrentviewcolumns.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 bendikro <bro.devel+deluge@gmail.com> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +from __future__ import unicode_literals + +from deluge.decorators import overrides +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.utils.column import torrent_data_fields +from deluge.ui.console.widgets.fields import CheckedPlusInput, IntSpinInput +from deluge.ui.console.widgets.popup import InputPopup, MessagePopup + +COLUMN_VIEW_HELP_STR = """ +Control column visibilty with the following actions: + +{!info!}'+'{!normal!} - {|indent_pos:|}Increase column width +{!info!}'-'{!normal!} - {|indent_pos:|}Decrease column width + +{!info!}'CTRL+up'{!normal!} - {|indent_pos:|} Move column left +{!info!}'CTRL+down'{!normal!} - {|indent_pos:|} Move column right +""" + +column_pref_names = [ + 'queue', + 'name', + 'size', + 'downloaded', + 'uploaded', + 'remaining', + 'state', + 'progress', + 'seeds', + 'peers', + 'seeds_peers_ratio', + 'download_speed', + 'upload_speed', + 'max_download_speed', + 'max_upload_speed', + 'eta', + 'ratio', + 'avail', + 'time_added', + 'completed_time', + 'last_seen_complete', + 'tracker', + 'download_location', + 'active_time', + 'seeding_time', + 'finished_time', + 'time_since_transfer', + 'shared', + 'owner', +] + + +class ColumnAndWidth(CheckedPlusInput): + def __init__(self, parent, name, message, child, on_width_func, **kwargs): + CheckedPlusInput.__init__(self, parent, name, message, child, **kwargs) + self.on_width_func = on_width_func + + @overrides(CheckedPlusInput) + def handle_read(self, c): + if c in [ord('+'), ord('-')]: + val = self.child.get_value() + change = 1 if chr(c) == '+' else -1 + self.child.set_value(val + change, validate=True) + self.on_width_func(self.name, self.child.get_value()) + return util.ReadState.CHANGED + return CheckedPlusInput.handle_read(self, c) + + +class TorrentViewColumns(InputPopup): + def __init__(self, torrentlist): + self.torrentlist = torrentlist + self.torrentview = torrentlist.torrentview + + title = 'Visible columns (Esc to exit)' + InputPopup.__init__( + self, + torrentlist, + title, + close_cb=self._do_set_column_visibility, + immediate_action=True, + height_req=len(column_pref_names) - 5, + width_req=max(len(col) for col in column_pref_names + [title]) + 14, + border_off_west=1, + allow_rearrange=True, + ) + + msg_fmt = '%-25s' + self.add_header((msg_fmt % _('Columns')) + ' ' + _('Width'), space_below=True) + + for colpref_name in column_pref_names: + col = self.torrentview.config['torrentview']['columns'][colpref_name] + width_spin = IntSpinInput( + self, + colpref_name + '_ width', + '', + self.move, + col['width'], + min_val=-1, + max_val=99, + fmt='%2d', + ) + + def on_width_func(name, width): + self.torrentview.config['torrentview']['columns'][name]['width'] = width + + self._add_input( + ColumnAndWidth( + self, + colpref_name, + torrent_data_fields[colpref_name]['name'], + width_spin, + on_width_func, + checked=col['visible'], + checked_char='*', + msg_fmt=msg_fmt, + show_usage_hints=False, + child_always_visible=True, + ) + ) + + def _do_set_column_visibility( + self, data=None, state_changed=True, close=True, **kwargs + ): + if close: + self.torrentlist.pop_popup() + return + elif not state_changed: + return + + for key, value in data.items(): + self.torrentview.config['torrentview']['columns'][key]['visible'] = value[ + 'value' + ] + self.torrentview.config['torrentview']['columns'][key]['order'] = value[ + 'order' + ] + + self.torrentview.config.save() + self.torrentview.on_config_changed() + self.torrentlist.refresh([]) + + @overrides(InputPopup) + def handle_read(self, c): + if c == ord('h'): + popup = MessagePopup( + self.torrentlist, + 'Help', + COLUMN_VIEW_HELP_STR, + width_req=70, + border_off_west=1, + ) + self.torrentlist.push_popup(popup) + return util.ReadState.READ + return InputPopup.handle_read(self, c) |