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