diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-10 21:38:38 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-10 21:38:38 +0000 |
commit | 2e2851dc13d73352530dd4495c7e05603b2e520d (patch) | |
tree | 622b9cd8e5d32091c9aa9e4937b533975a40356c /deluge/ui/console/widgets | |
parent | Initial commit. (diff) | |
download | deluge-2e2851dc13d73352530dd4495c7e05603b2e520d.tar.xz deluge-2e2851dc13d73352530dd4495c7e05603b2e520d.zip |
Adding upstream version 2.1.2~dev0+20240219.upstream/2.1.2_dev0+20240219upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'deluge/ui/console/widgets')
-rw-r--r-- | deluge/ui/console/widgets/__init__.py | 5 | ||||
-rw-r--r-- | deluge/ui/console/widgets/fields.py | 1202 | ||||
-rw-r--r-- | deluge/ui/console/widgets/inputpane.py | 394 | ||||
-rw-r--r-- | deluge/ui/console/widgets/popup.py | 398 | ||||
-rw-r--r-- | deluge/ui/console/widgets/sidebar.py | 79 | ||||
-rw-r--r-- | deluge/ui/console/widgets/statusbars.py | 124 | ||||
-rw-r--r-- | deluge/ui/console/widgets/window.py | 182 |
7 files changed, 2384 insertions, 0 deletions
diff --git a/deluge/ui/console/widgets/__init__.py b/deluge/ui/console/widgets/__init__.py new file mode 100644 index 0000000..bc88a3b --- /dev/null +++ b/deluge/ui/console/widgets/__init__.py @@ -0,0 +1,5 @@ +from deluge.ui.console.widgets.inputpane import BaseInputPane +from deluge.ui.console.widgets.statusbars import StatusBars +from deluge.ui.console.widgets.window import BaseWindow + +__all__ = ['BaseInputPane', 'StatusBars', 'BaseWindow'] diff --git a/deluge/ui/console/widgets/fields.py b/deluge/ui/console/widgets/fields.py new file mode 100644 index 0000000..d8d892d --- /dev/null +++ b/deluge/ui/console/widgets/fields.py @@ -0,0 +1,1202 @@ +# +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# 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 + +from deluge.decorators import overrides +from deluge.ui.console.modes.basemode import InputKeyHandler +from deluge.ui.console.utils import colors +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.utils.format_utils import ( + delete_alt_backspace, + remove_formatting, + wrap_string, +) + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) + + +class BaseField(InputKeyHandler): + def __init__(self, parent=None, name=None, selectable=True, **kwargs): + super().__init__() + self.name = name + self.parent = parent + self.fmt_keys = {} + self.set_fmt_key('font', 'ignore', kwargs) + self.set_fmt_key('color', 'white,black', kwargs) + self.set_fmt_key('color_end', 'white,black', kwargs) + self.set_fmt_key('color_active', 'black,white', kwargs) + self.set_fmt_key('color_unfocused', 'color', kwargs) + self.set_fmt_key('color_unfocused_active', 'black,whitegrey', kwargs) + self.set_fmt_key('font_active', 'font', kwargs) + self.set_fmt_key('font_unfocused', 'font', kwargs) + self.set_fmt_key('font_unfocused_active', 'font_active', kwargs) + self.default_col = kwargs.get('col', -1) + self._selectable = selectable + self.value = None + + def selectable(self): + return self.has_input() and not self.depend_skip() and self._selectable + + def set_fmt_key(self, key, default, kwargsdict=None): + value = self.fmt_keys.get(default, default) + if kwargsdict: + value = kwargsdict.get(key, value) + self.fmt_keys[key] = value + + def get_fmt_keys(self, focused, active, **kwargs): + color_key = kwargs.get('color_key', 'color') + font_key = 'font' + if not focused: + color_key += '_unfocused' + font_key += '_unfocused' + if active: + color_key += '_active' + font_key += '_active' + return color_key, font_key + + def build_fmt_string(self, focused, active, value_key='msg', **kwargs): + color_key, font_key = self.get_fmt_keys(focused, active, **kwargs) + return '{{!%({})s,%({})s!}}%({})s{{!%({})s!}}'.format( + color_key, + font_key, + value_key, + 'color_end', + ) + + def depend_skip(self): + return False + + def has_input(self): + return True + + @overrides(InputKeyHandler) + def handle_read(self, c): + return util.ReadState.IGNORED + + def render(self, screen, row, **kwargs): + return 0 + + @property + def height(self): + return 1 + + def set_value(self, value): + self.value = value + + def get_value(self): + return self.value + + +class NoInputField(BaseField): + @overrides(BaseField) + def has_input(self): + return False + + +class InputField(BaseField): + def __init__(self, parent, name, message, format_default=None, **kwargs): + BaseField.__init__(self, parent=parent, name=name, **kwargs) + self.format_default = format_default + self.message = None + self.set_message(message) + + depend = None + + @overrides(BaseField) + def handle_read(self, c): + if c in [curses.KEY_ENTER, util.KEY_ENTER2, util.KEY_BACKSPACE2, 113]: + return util.ReadState.READ + return util.ReadState.IGNORED + + def set_message(self, msg): + changed = self.message != msg + self.message = msg + return changed + + def set_depend(self, i, inverse=False): + if not isinstance(i, CheckedInput): + raise Exception('Can only depend on CheckedInputs') + self.depend = i + self.inverse = inverse + + def depend_skip(self): + if not self.depend: + return False + if self.inverse: + return self.depend.checked + else: + return not self.depend.checked + + +class Header(NoInputField): + def __init__(self, parent, header, space_above, space_below, **kwargs): + if 'name' not in kwargs: + kwargs['name'] = header + NoInputField.__init__(self, parent=parent, **kwargs) + self.header = '{!white,black,bold!}%s' % header + self.space_above = space_above + self.space_below = space_below + + @overrides(BaseField) + def render(self, screen, row, col=0, **kwargs): + rows = 1 + if self.space_above: + row += 1 + rows += 1 + self.parent.add_string(row, self.header, scr=screen, col=col, pad=False) + if self.space_below: + rows += 1 + return rows + + @property + def height(self): + return 1 + int(self.space_above) + int(self.space_below) + + +class InfoField(NoInputField): + def __init__(self, parent, name, label, value, **kwargs): + NoInputField.__init__(self, parent=parent, name=name, **kwargs) + self.label = label + self.value = value + self.txt = f'{label} {value}' + + @overrides(BaseField) + def render(self, screen, row, col=0, **kwargs): + self.parent.add_string(row, self.txt, scr=screen, col=col, pad=False) + return 1 + + @overrides(BaseField) + def set_value(self, v): + self.value = v + if isinstance(v, float): + self.txt = f'{self.label} {self.value:.2f}' + else: + self.txt = f'{self.label} {self.value}' + + +class CheckedInput(InputField): + def __init__( + self, + parent, + name, + message, + checked=False, + checked_char='X', + unchecked_char=' ', + checkbox_format='[%s] ', + **kwargs, + ): + InputField.__init__(self, parent, name, message, **kwargs) + self.set_value(checked) + self.fmt_keys.update( + { + 'msg': message, + 'checkbox_format': checkbox_format, + 'unchecked_char': unchecked_char, + 'checked_char': checked_char, + } + ) + self.set_fmt_key('font_checked', 'font', kwargs) + self.set_fmt_key('font_unfocused_checked', 'font_checked', kwargs) + self.set_fmt_key('font_active_checked', 'font_active', kwargs) + self.set_fmt_key('font_unfocused_active_checked', 'font_active_checked', kwargs) + self.set_fmt_key('color_checked', 'color', kwargs) + self.set_fmt_key('color_active_checked', 'color_active', kwargs) + self.set_fmt_key('color_unfocused_checked', 'color_checked', kwargs) + self.set_fmt_key( + 'color_unfocused_active_checked', 'color_unfocused_active', kwargs + ) + + @property + def checked(self): + return self.value + + @overrides(BaseField) + def get_fmt_keys(self, focused, active, **kwargs): + color_key, font_key = super().get_fmt_keys(focused, active, **kwargs) + if self.checked: + color_key += '_checked' + font_key += '_checked' + return color_key, font_key + + def build_msg_string(self, focused, active): + fmt_str = self.build_fmt_string(focused, active) + char = self.fmt_keys['checked_char' if self.checked else 'unchecked_char'] + chk_box = '' + try: + chk_box = self.fmt_keys['checkbox_format'] % char + except KeyError: + pass + msg = fmt_str % self.fmt_keys + return chk_box + msg + + @overrides(InputField) + def render(self, screen, row, col=0, **kwargs): + string = self.build_msg_string(kwargs.get('focused'), kwargs.get('active')) + + self.parent.add_string(row, string, scr=screen, col=col, pad=False) + return 1 + + @overrides(InputField) + def handle_read(self, c): + if c == util.KEY_SPACE: + self.set_value(not self.checked) + return util.ReadState.CHANGED + return util.ReadState.IGNORED + + @overrides(InputField) + def set_message(self, msg): + changed = InputField.set_message(self, msg) + if 'msg' in self.fmt_keys and self.fmt_keys['msg'] != msg: + changed = True + self.fmt_keys.update({'msg': msg}) + + return changed + + +class CheckedPlusInput(CheckedInput): + def __init__( + self, + parent, + name, + message, + child, + child_always_visible=False, + show_usage_hints=True, + msg_fmt='%s ', + **kwargs, + ): + CheckedInput.__init__(self, parent, name, message, **kwargs) + self.child = child + self.child_active = False + self.show_usage_hints = show_usage_hints + self.msg_fmt = msg_fmt + self.child_always_visible = child_always_visible + + @property + def height(self): + return max(2 if self.show_usage_hints else 1, self.child.height) + + @overrides(CheckedInput) + def render( + self, screen, row, width=None, active=False, focused=False, col=0, **kwargs + ): + isact = active and not self.child_active + CheckedInput.render( + self, screen, row, width=width, active=isact, focused=focused, col=col + ) + rows = 1 + if self.show_usage_hints and ( + self.child_always_visible or (active and self.checked) + ): + msg = '(esc to leave)' if self.child_active else '(right arrow to edit)' + self.parent.add_string(row + 1, msg, scr=screen, col=col, pad=False) + rows += 1 + + msglen = len( + self.msg_fmt % colors.strip_colors(self.build_msg_string(focused, active)) + ) + # show child + if self.checked or self.child_always_visible: + crows = self.child.render( + screen, + row, + width=width - msglen, + active=self.child_active and active, + col=col + msglen, + cursor_offset=msglen, + ) + rows = max(rows, crows) + else: + self.parent.add_string( + row, + '(enable to view/edit value)', + scr=screen, + col=col + msglen, + pad=False, + ) + return rows + + @overrides(CheckedInput) + def handle_read(self, c): + if self.child_active: + if c == util.KEY_ESC: # leave child on esc + self.child_active = False + return util.ReadState.READ + # pass keys through to child + return self.child.handle_read(c) + else: + if c == util.KEY_SPACE: + self.set_value(not self.checked) + return util.ReadState.CHANGED + if (self.checked or self.child_always_visible) and c == curses.KEY_RIGHT: + self.child_active = True + return util.ReadState.READ + return util.ReadState.IGNORED + + def get_child(self): + return self.child + + +class IntSpinInput(InputField): + def __init__( + self, + parent, + name, + message, + move_func, + value, + min_val=None, + max_val=None, + inc_amt=1, + incr_large=10, + strict_validation=False, + fmt='%d', + **kwargs, + ): + InputField.__init__(self, parent, name, message, **kwargs) + self.convert_func = int + self.fmt = fmt + self.valstr = str(value) + self.default_str = self.valstr + self.set_value(value) + self.default_value = self.value + self.last_valid_value = self.value + self.last_active = False + self.cursor = len(self.valstr) + self.cursoff = ( + colors.get_line_width(self.message) + 3 + ) # + 4 for the " [ " in the rendered string + self.move_func = move_func + self.strict_validation = strict_validation + self.min_val = min_val + self.max_val = max_val + self.inc_amt = inc_amt + self.incr_large = incr_large + + def validate_value(self, value, on_invalid=None): + if (self.min_val is not None) and value < self.min_val: + value = on_invalid if on_invalid else self.min_val + if (self.max_val is not None) and value > self.max_val: + value = on_invalid if on_invalid else self.max_val + return value + + @overrides(InputField) + def render( + self, screen, row, active=False, focused=True, col=0, cursor_offset=0, **kwargs + ): + if active: + self.last_active = True + elif self.last_active: + self.set_value( + self.valstr, validate=True, value_on_fail=self.last_valid_value + ) + self.last_active = False + + fmt_str = self.build_fmt_string(focused, active, value_key='value') + value_format = '%(msg)s {!input!}' + if not self.valstr: + value_format += '[ ]' + elif self.format_default and self.valstr == self.default_str: + value_format += '[ {!magenta,black!}%(value)s{!input!} ]' + else: + value_format += '[ ' + fmt_str + ' ]' + + self.parent.add_string( + row, + value_format + % dict({'msg': self.message, 'value': '%s' % self.valstr}, **self.fmt_keys), + scr=screen, + col=col, + pad=False, + ) + if active: + if focused: + util.safe_curs_set(util.Curser.NORMAL) + self.move_func(row, self.cursor + self.cursoff + cursor_offset) + else: + util.safe_curs_set(util.Curser.INVISIBLE) + return 1 + + @overrides(InputField) + def handle_read(self, c): + if c == util.KEY_SPACE: + return util.ReadState.READ + elif c == curses.KEY_PPAGE: + self.set_value(self.value + self.inc_amt, validate=True) + elif c == curses.KEY_NPAGE: + self.set_value(self.value - self.inc_amt, validate=True) + elif c == util.KEY_ALT_AND_KEY_PPAGE: + self.set_value(self.value + self.incr_large, validate=True) + elif c == util.KEY_ALT_AND_KEY_NPAGE: + self.set_value(self.value - self.incr_large, validate=True) + elif c == curses.KEY_LEFT: + self.cursor = max(0, self.cursor - 1) + elif c == curses.KEY_RIGHT: + self.cursor = min(len(self.valstr), self.cursor + 1) + elif c == curses.KEY_HOME: + self.cursor = 0 + elif c == curses.KEY_END: + self.cursor = len(self.valstr) + elif c == curses.KEY_BACKSPACE or c == util.KEY_BACKSPACE2: + if self.valstr and self.cursor > 0: + new_val = self.valstr[: self.cursor - 1] + self.valstr[self.cursor :] + self.set_value( + new_val, + validate=False, + cursor=self.cursor - 1, + cursor_on_fail=True, + value_on_fail=self.valstr if self.strict_validation else None, + ) + elif c == curses.KEY_DC: # Del + if self.valstr and self.cursor <= len(self.valstr): + if self.cursor == 0: + new_val = self.valstr[1:] + else: + new_val = ( + self.valstr[: self.cursor] + self.valstr[self.cursor + 1 :] + ) + self.set_value( + new_val, + validate=False, + cursor=False, + value_on_fail=self.valstr if self.strict_validation else None, + cursor_on_fail=True, + ) + elif c == ord('-'): # minus + self.set_value( + self.value - 1, + validate=True, + cursor=True, + cursor_on_fail=True, + value_on_fail=self.value, + on_invalid=self.value, + ) + elif c == ord('+'): # plus + self.set_value( + self.value + 1, + validate=True, + cursor=True, + cursor_on_fail=True, + value_on_fail=self.value, + on_invalid=self.value, + ) + elif util.is_int_chr(c): + if self.strict_validation: + new_val = ( + self.valstr[: self.cursor - 1] + + chr(c) + + self.valstr[self.cursor - 1 :] + ) + self.set_value( + new_val, + validate=True, + cursor=self.cursor + 1, + value_on_fail=self.valstr, + on_invalid=self.value, + ) + else: + minus_place = self.valstr.find('-') + if self.cursor > minus_place: + new_val = ( + self.valstr[: self.cursor] + chr(c) + self.valstr[self.cursor :] + ) + self.set_value( + new_val, + validate=True, + cursor=self.cursor + 1, + on_invalid=self.value, + ) + else: + return util.ReadState.IGNORED + return util.ReadState.READ + + @overrides(BaseField) + def set_value( + self, + val, + cursor=True, + validate=False, + cursor_on_fail=False, + value_on_fail=None, + on_invalid=None, + ): + value = None + try: + value = self.convert_func(val) + if validate: + validated = self.validate_value(value, on_invalid) + if validated != value: + # Value was not valid, so use validated value instead. + # Also set cursor according to validated value + cursor = True + value = validated + + new_valstr = self.fmt % value + if new_valstr == self.valstr: + # If string has not change, keep cursor + cursor = False + self.valstr = new_valstr + self.last_valid_value = self.value = value + except ValueError: + if value_on_fail is not None: + self.set_value( + value_on_fail, + cursor=cursor, + cursor_on_fail=cursor_on_fail, + validate=validate, + on_invalid=on_invalid, + ) + return + self.value = None + self.valstr = val + if cursor_on_fail: + self.cursor = cursor + except TypeError: + import traceback + + log.warning('TypeError: %s', ''.join(traceback.format_exc())) + else: + if cursor is True: + self.cursor = len(self.valstr) + elif cursor is not False: + self.cursor = cursor + + +class FloatSpinInput(IntSpinInput): + def __init__(self, parent, message, name, move_func, value, precision=1, **kwargs): + self.precision = precision + IntSpinInput.__init__(self, parent, message, name, move_func, value, **kwargs) + self.fmt = '%%.%df' % precision + self.convert_func = lambda valstr: round(float(valstr), self.precision) + self.set_value(value) + self.cursor = len(self.valstr) + + @overrides(IntSpinInput) + def handle_read(self, c): + if c == ord('.'): + minus_place = self.valstr.find('-') + if self.cursor <= minus_place: + return util.ReadState.READ + point_place = self.valstr.find('.') + if point_place >= 0: + return util.ReadState.READ + new_val = self.valstr[: self.cursor] + chr(c) + self.valstr[self.cursor :] + self.set_value(new_val, validate=True, cursor=self.cursor + 1) + else: + return IntSpinInput.handle_read(self, c) + + +class SelectInput(InputField): + def __init__( + self, + parent, + name, + message, + opts, + vals, + active_index, + active_default=False, + require_select_action=True, + **kwargs, + ): + InputField.__init__(self, parent, name, message, **kwargs) + self.opts = opts + self.vals = vals + self.active_index = active_index + self.selected_index = active_index + self.default_option = active_index if active_default else None + self.require_select_action = require_select_action + self.fmt_keys.update({'font_active': 'bold'}) + font_selected = kwargs.get('font_selected', 'bold,underline') + + self.set_fmt_key('font_selected', font_selected, kwargs) + self.set_fmt_key('font_active_selected', 'font_selected', kwargs) + self.set_fmt_key('font_unfocused_selected', 'font_selected', kwargs) + self.set_fmt_key( + 'font_unfocused_active_selected', 'font_active_selected', kwargs + ) + + self.set_fmt_key('color_selected', 'color', kwargs) + self.set_fmt_key('color_active_selected', 'color_active', kwargs) + self.set_fmt_key('color_unfocused_selected', 'color_selected', kwargs) + self.set_fmt_key( + 'color_unfocused_active_selected', 'color_unfocused_active', kwargs + ) + self.set_fmt_key('color_default_value', 'magenta,black', kwargs) + + self.set_fmt_key('color_default_value', 'magenta,black') + self.set_fmt_key('color_default_value_active', 'magentadark,white') + self.set_fmt_key('color_default_value_selected', 'color_default_value', kwargs) + self.set_fmt_key('color_default_value_unfocused', 'color_default_value', kwargs) + self.set_fmt_key( + 'color_default_value_unfocused_selected', + 'color_default_value_selected', + kwargs, + ) + self.set_fmt_key('color_default_value_active_selected', 'magentadark,white') + self.set_fmt_key( + 'color_default_value_unfocused_active_selected', + 'color_unfocused_active', + kwargs, + ) + + @property + def height(self): + return 1 + bool(self.message) + + @overrides(BaseField) + def get_fmt_keys(self, focused, active, selected=False, **kwargs): + color_key, font_key = super().get_fmt_keys(focused, active, **kwargs) + if selected: + color_key += '_selected' + font_key += '_selected' + return color_key, font_key + + @overrides(InputField) + def render(self, screen, row, active=False, focused=True, col=0, **kwargs): + if self.message: + self.parent.add_string(row, self.message, scr=screen, col=col, pad=False) + row += 1 + + off = col + 1 + for i, opt in enumerate(self.opts): + self.fmt_keys['msg'] = opt + fmt_args = {'selected': i == self.selected_index} + if i == self.default_option: + fmt_args['color_key'] = 'color_default_value' + fmt = self.build_fmt_string( + focused, (i == self.active_index) and active, **fmt_args + ) + string = '[%s]' % (fmt % self.fmt_keys) + self.parent.add_string(row, string, scr=screen, col=off, pad=False) + off += len(opt) + 3 + if self.message: + return 2 + else: + return 1 + + @overrides(InputField) + def handle_read(self, c): + if c == curses.KEY_LEFT: + self.active_index = max(0, self.active_index - 1) + if not self.require_select_action: + self.selected_index = self.active_index + elif c == curses.KEY_RIGHT: + self.active_index = min(len(self.opts) - 1, self.active_index + 1) + if not self.require_select_action: + self.selected_index = self.active_index + elif c == ord(' '): + if self.require_select_action: + self.selected_index = self.active_index + else: + return util.ReadState.IGNORED + return util.ReadState.READ + + @overrides(BaseField) + def get_value(self): + return self.vals[self.selected_index] + + @overrides(BaseField) + def set_value(self, value): + for i, val in enumerate(self.vals): + if value == val: + self.selected_index = i + return + raise Exception('Invalid value for SelectInput') + + +class TextInput(InputField): + def __init__( + self, + parent, + name, + message, + move_func, + width, + value, + complete=False, + activate_input=False, + **kwargs, + ): + InputField.__init__(self, parent, name, message, **kwargs) + self.move_func = move_func + self._width = width + self.value = value if value else '' + self.default_value = value + self.complete = complete + self.tab_count = 0 + self.cursor = len(self.value) + self.opts = None + self.opt_off = 0 + self.value_offset = 0 + self.activate_input = activate_input # Wether input must be activated + self.input_active = not self.activate_input + + @property + def width(self): + return self._width + + @property + def height(self): + return 1 + bool(self.message) + + def calculate_textfield_value(self, width, cursor_offset): + cursor_width = width + + if self.cursor > (cursor_width - 1): + c_pos_abs = self.cursor - cursor_width + if cursor_width <= (self.cursor - self.value_offset): + new_cur = c_pos_abs + 1 + self.value_offset = new_cur + else: + if self.cursor >= len(self.value): + c_pos_abs = len(self.value) - cursor_width + new_cur = c_pos_abs + 1 + self.value_offset = new_cur + vstr = self.value[self.value_offset :] + + if len(vstr) > cursor_width: + vstr = vstr[:cursor_width] + vstr = vstr.ljust(cursor_width) + else: + if len(self.value) <= cursor_width: + self.value_offset = 0 + vstr = self.value.ljust(cursor_width) + else: + self.value_offset = min(self.value_offset, self.cursor) + vstr = self.value[self.value_offset :] + if len(vstr) > cursor_width: + vstr = vstr[:cursor_width] + vstr = vstr.ljust(cursor_width) + + return vstr + + def calculate_cursor_pos(self, width, col): + cursor_width = width + x_pos = self.cursor + col + + if (self.cursor + col - self.value_offset) > cursor_width: + x_pos += self.value_offset + else: + x_pos -= self.value_offset + + return min(width - 1 + col, x_pos) + + @overrides(InputField) + def render( + self, + screen, + row, + width=None, + active=False, + focused=True, + col=0, + cursor_offset=0, + **kwargs, + ): + if not self.value and not active and len(self.default_value) != 0: + self.value = self.default_value + self.cursor = len(self.value) + + if self.message: + self.parent.add_string(row, self.message, scr=screen, col=col, pad=False) + row += 1 + + vstr = self.calculate_textfield_value(width, cursor_offset) + + if active: + if self.opts: + self.parent.add_string( + row + 1, self.opts[self.opt_off :], scr=screen, col=col, pad=False + ) + + if focused and self.input_active: + util.safe_curs_set( + util.Curser.NORMAL + ) # Make cursor visible when text field is focused + x_pos = self.calculate_cursor_pos(width, col) + self.move_func(row, x_pos) + + fmt = '{!black,white,bold!}%s' + if ( + self.format_default + and len(self.value) != 0 + and self.value == self.default_value + ): + fmt = '{!magenta,white!}%s' + if not active or not focused or self.input_active: + fmt = '{!white,grey,bold!}%s' + + self.parent.add_string( + row, fmt % vstr, scr=screen, col=col, pad=False, trim=False + ) + return self.height + + @overrides(BaseField) + def set_value(self, val): + self.value = val + self.cursor = len(self.value) + + @overrides(InputField) + def handle_read(self, c): + """ + Return False when key was swallowed, i.e. we recognised + the key and no further action by other components should + be performed. + """ + if self.activate_input: + if not self.input_active: + if c in [ + curses.KEY_LEFT, + curses.KEY_RIGHT, + curses.KEY_HOME, + curses.KEY_END, + curses.KEY_ENTER, + util.KEY_ENTER2, + ]: + self.input_active = True + return util.ReadState.READ + else: + return util.ReadState.IGNORED + elif c == util.KEY_ESC: + self.input_active = False + return util.ReadState.READ + + if c == util.KEY_TAB and self.complete: + # Keep track of tab hit count to know when it's double-hit + self.tab_count += 1 + if self.tab_count > 1: + second_hit = True + self.tab_count = 0 + else: + second_hit = False + + # We only call the tab completer function if we're at the end of + # the input string on the cursor is on a space + if self.cursor == len(self.value) or self.value[self.cursor] == ' ': + if self.opts: + prev = self.opt_off + self.opt_off += self.width - 3 + # now find previous double space, best guess at a split point + # in future could keep opts unjoined to get this really right + self.opt_off = self.opts.rfind(' ', 0, self.opt_off) + 2 + if ( + second_hit and self.opt_off == prev + ): # double tap and we're at the end + self.opt_off = 0 + else: + opts = self.do_complete(self.value) + if len(opts) == 1: # only one option, just complete it + self.value = opts[0] + self.cursor = len(opts[0]) + self.tab_count = 0 + elif len(opts) > 1: + prefix = os.path.commonprefix(opts) + if prefix: + self.value = prefix + self.cursor = len(prefix) + + if ( + len(opts) > 1 and second_hit + ): # display multiple options on second tab hit + sp = self.value.rfind(os.sep) + 1 + self.opts = ' '.join([o[sp:] for o in opts]) + + # Cursor movement + elif c == curses.KEY_LEFT: + self.cursor = max(0, self.cursor - 1) + elif c == curses.KEY_RIGHT: + self.cursor = min(len(self.value), self.cursor + 1) + elif c == curses.KEY_HOME: + self.cursor = 0 + elif c == curses.KEY_END: + self.cursor = len(self.value) + + # Delete a character in the input string based on cursor position + elif c == curses.KEY_BACKSPACE or c == util.KEY_BACKSPACE2: + if self.value and self.cursor > 0: + self.value = self.value[: self.cursor - 1] + self.value[self.cursor :] + self.cursor -= 1 + elif c == [util.KEY_ESC, util.KEY_BACKSPACE2] or c == [ + util.KEY_ESC, + curses.KEY_BACKSPACE, + ]: + self.value, self.cursor = delete_alt_backspace(self.value, self.cursor) + elif c == curses.KEY_DC: + if self.value and self.cursor < len(self.value): + self.value = self.value[: self.cursor] + self.value[self.cursor + 1 :] + elif c > 31 and c < 256: + # Emulate getwch + stroke = chr(c) + uchar = stroke + while not uchar: + try: + uchar = stroke.decode(self.parent.encoding) + except UnicodeDecodeError: + c = self.parent.parent.stdscr.getch() + stroke += chr(c) + if uchar: + if self.cursor == len(self.value): + self.value += uchar + else: + # Insert into string + self.value = ( + self.value[: self.cursor] + uchar + self.value[self.cursor :] + ) + # Move the cursor forward + self.cursor += 1 + + else: + self.opts = None + self.opt_off = 0 + self.tab_count = 0 + return util.ReadState.IGNORED + return util.ReadState.READ + + def do_complete(self, line): + line = os.path.abspath(os.path.expanduser(line)) + ret = [] + if os.path.exists(line): + # This is a correct path, check to see if it's a directory + if os.path.isdir(line): + # Directory, so we need to show contents of directory + for f in os.listdir(line): + # Skip hidden + if f.startswith('.'): + continue + f = os.path.join(line, f) + if os.path.isdir(f): + f += os.sep + ret.append(f) + else: + # This is a file, but we could be looking for another file that + # shares a common prefix. + for f in os.listdir(os.path.dirname(line)): + if f.startswith(os.path.split(line)[1]): + ret.append(os.path.join(os.path.dirname(line), f)) + else: + # This path does not exist, so lets do a listdir on it's parent + # and find any matches. + ret = [] + if os.path.isdir(os.path.dirname(line)): + for f in os.listdir(os.path.dirname(line)): + if f.startswith(os.path.split(line)[1]): + p = os.path.join(os.path.dirname(line), f) + + if os.path.isdir(p): + p += os.sep + ret.append(p) + return ret + + +class ComboInput(InputField): + def __init__( + self, parent, name, message, choices, default=None, searchable=True, **kwargs + ): + InputField.__init__(self, parent, name, message, **kwargs) + self.choices = choices + self.default = default + self.set_value(default) + max_width = 0 + for c in choices: + max_width = max(max_width, len(c[1])) + self.choices_width = max_width + self.searchable = searchable + + @overrides(BaseField) + def render(self, screen, row, col=0, **kwargs): + fmt_str = self.build_fmt_string(kwargs.get('focused'), kwargs.get('active')) + string = '%s: [%10s]' % (self.message, fmt_str % self.fmt_keys) + self.parent.add_string(row, string, scr=screen, col=col, pad=False) + return 1 + + def _lang_selected(self, selected, *args, **kwargs): + if selected is not None: + self.set_value(selected) + self.parent.pop_popup() + + @overrides(InputField) + def handle_read(self, c): + if c in [util.KEY_SPACE, curses.KEY_ENTER, util.KEY_ENTER2]: + + def search_handler(key): + """Handle keyboard input to seach the list""" + if not util.is_printable_chr(key): + return + selected = select_popup.current_selection() + + def select_in_range(begin, end): + for i in range(begin, end): + val = select_popup.inputs[i].get_value() + if val.lower().startswith(chr(key)): + select_popup.set_selection(i) + return True + return False + + # First search downwards + if not select_in_range(selected + 1, len(select_popup.inputs)): + # No match, so start at beginning + select_in_range(0, selected) + + from deluge.ui.console.widgets.popup import ( # Must import here + SelectablePopup, + ) + + select_popup = SelectablePopup( + self.parent, + ' %s ' % _('Select Language'), + self._lang_selected, + input_cb=search_handler if self.searchable else None, + border_off_west=1, + active_wrap=False, + width_req=self.choices_width + 12, + ) + for choice in self.choices: + args = {'data': choice[0]} + select_popup.add_line( + choice[0], + choice[1], + selectable=True, + selected=choice[0] == self.get_value(), + **args, + ) + self.parent.push_popup(select_popup) + return util.ReadState.CHANGED + return util.ReadState.IGNORED + + @overrides(BaseField) + def set_value(self, val): + self.value = val + msg = None + for c in self.choices: + if c[0] == val: + msg = c[1] + break + if msg is None: + log.warning( + 'Setting value "%s" found nothing in choices: %s', val, self.choices + ) + self.fmt_keys.update({'msg': msg}) + + +class TextField(BaseField): + def __init__(self, parent, name, value, selectable=True, value_fmt='%s', **kwargs): + BaseField.__init__( + self, parent=parent, name=name, selectable=selectable, **kwargs + ) + self.value = value + self.value_fmt = value_fmt + self.set_value(value) + + @overrides(BaseField) + def set_value(self, value): + self.value = value + self.txt = self.value_fmt % (value) + + @overrides(BaseField) + def has_input(self): + return True + + @overrides(BaseField) + def render(self, screen, row, active=False, focused=False, col=0, **kwargs): + util.safe_curs_set( + util.Curser.INVISIBLE + ) # Make cursor invisible when text field is active + fmt = self.build_fmt_string(focused, active) + self.fmt_keys['msg'] = self.txt + string = fmt % self.fmt_keys + self.parent.add_string(row, string, scr=screen, col=col, pad=False, trim=False) + return 1 + + +class TextArea(TextField): + def __init__(self, parent, name, value, value_fmt='%s', **kwargs): + TextField.__init__( + self, parent, name, value, selectable=False, value_fmt=value_fmt, **kwargs + ) + + @overrides(TextField) + def render(self, screen, row, col=0, **kwargs): + util.safe_curs_set( + util.Curser.INVISIBLE + ) # Make cursor invisible when text field is active + color = '{!white,black!}' + lines = wrap_string(self.txt, self.parent.width - 3, 3, True) + + for i, line in enumerate(lines): + self.parent.add_string( + row + i, + f'{color}{line}', + scr=screen, + col=col, + pad=False, + trim=False, + ) + return len(lines) + + @property + def height(self): + lines = wrap_string(self.txt, self.parent.width - 3, 3, True) + return len(lines) + + @overrides(TextField) + def has_input(self): + return False + + +class DividerField(NoInputField): + def __init__( + self, + parent, + name, + value, + selectable=False, + fill_width=True, + value_fmt='%s', + **kwargs, + ): + NoInputField.__init__( + self, parent=parent, name=name, selectable=selectable, **kwargs + ) + self.value = value + self.value_fmt = value_fmt + self.set_value(value) + self.fill_width = fill_width + + @overrides(BaseField) + def set_value(self, value): + self.value = value + self.txt = self.value_fmt % (value) + + @overrides(BaseField) + def render( + self, screen, row, active=False, focused=False, col=0, width=None, **kwargs + ): + util.safe_curs_set( + util.Curser.INVISIBLE + ) # Make cursor invisible when text field is active + fmt = self.build_fmt_string(focused, active) + self.fmt_keys['msg'] = self.txt + if self.fill_width: + self.fmt_keys['msg'] = '' + string_len = len(remove_formatting(fmt % self.fmt_keys)) + fill_len = width - string_len - (len(self.txt) - 1) + self.fmt_keys['msg'] = self.txt * fill_len + string = fmt % self.fmt_keys + self.parent.add_string(row, string, scr=screen, col=col, pad=False, trim=False) + return 1 diff --git a/deluge/ui/console/widgets/inputpane.py b/deluge/ui/console/widgets/inputpane.py new file mode 100644 index 0000000..d8d2175 --- /dev/null +++ b/deluge/ui/console/widgets/inputpane.py @@ -0,0 +1,394 @@ +# +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# 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 + +from deluge.decorators import overrides +from deluge.ui.console.modes.basemode import InputKeyHandler, move_cursor +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.widgets.fields import ( + CheckedInput, + CheckedPlusInput, + ComboInput, + DividerField, + FloatSpinInput, + Header, + InfoField, + IntSpinInput, + NoInputField, + SelectInput, + TextArea, + TextField, + TextInput, +) + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) + + +class BaseInputPane(InputKeyHandler): + def __init__( + self, + mode, + allow_rearrange=False, + immediate_action=False, + set_first_input_active=True, + border_off_west=0, + border_off_north=0, + border_off_east=0, + border_off_south=0, + active_wrap=False, + **kwargs + ): + InputKeyHandler.__init__(self) + self.inputs = [] + self.mode = mode + self.active_input = 0 + self.set_first_input_active = set_first_input_active + self.allow_rearrange = allow_rearrange + self.immediate_action = immediate_action + self.move_active_many = 4 + self.active_wrap = active_wrap + self.lineoff = 0 + self.border_off_west = border_off_west + self.border_off_north = border_off_north + self.border_off_east = border_off_east + self.border_off_south = border_off_south + self.last_lineoff_move = 0 + + if not hasattr(self, 'visible_content_pane_height'): + log.error( + 'The class "%s" does not have the attribute "%s" required by super class "%s"', + self.__class__.__name__, + 'visible_content_pane_height', + BaseInputPane.__name__, + ) + raise AttributeError('visible_content_pane_height') + + @property + def visible_content_pane_width(self): + return self.mode.width + + def add_spaces(self, num): + string = '' + for i in range(num): + string += '\n' + + self.add_text_area('space %d' % len(self.inputs), string) + + def add_text(self, string): + self.add_text_area('', string) + + def move(self, r, c): + self._cursor_row = r + self._cursor_col = c + + def get_input(self, name): + for e in self.inputs: + if e.name == name: + return e + + def _add_input(self, input_element): + for e in self.inputs: + if isinstance(e, NoInputField): + continue + if e.name == input_element.name: + import traceback + + log.warning( + 'Input element with name "%s" already exists in input pane (%s):\n%s', + input_element.name, + e, + ''.join(traceback.format_stack(limit=5)), + ) + return + + self.inputs.append(input_element) + if self.set_first_input_active and input_element.selectable(): + self.active_input = len(self.inputs) - 1 + self.set_first_input_active = False + return input_element + + def add_header(self, header, space_above=False, space_below=False, **kwargs): + return self._add_input(Header(self, header, space_above, space_below, **kwargs)) + + def add_info_field(self, name, label, value): + return self._add_input(InfoField(self, name, label, value)) + + def add_text_field(self, name, message, selectable=True, col='+1', **kwargs): + return self._add_input( + TextField(self, name, message, selectable=selectable, col=col, **kwargs) + ) + + def add_text_area(self, name, message, **kwargs): + return self._add_input(TextArea(self, name, message, **kwargs)) + + def add_divider_field(self, name, message, **kwargs): + return self._add_input(DividerField(self, name, message, **kwargs)) + + def add_text_input(self, name, message, value='', col='+1', **kwargs): + """ + Add a text input field + + :param message: string to display above the input field + :param name: name of the field, for the return callback + :param value: initial value of the field + :param complete: should completion be run when tab is hit and this field is active + """ + return self._add_input( + TextInput( + self, + name, + message, + self.move, + self.visible_content_pane_width, + value, + col=col, + **kwargs + ) + ) + + def add_select_input(self, name, message, opts, vals, default_index=0, **kwargs): + return self._add_input( + SelectInput(self, name, message, opts, vals, default_index, **kwargs) + ) + + def add_checked_input(self, name, message, checked=False, col='+1', **kwargs): + return self._add_input( + CheckedInput(self, name, message, checked=checked, col=col, **kwargs) + ) + + def add_checkedplus_input( + self, name, message, child, checked=False, col='+1', **kwargs + ): + return self._add_input( + CheckedPlusInput( + self, name, message, child, checked=checked, col=col, **kwargs + ) + ) + + def add_float_spin_input(self, name, message, value=0.0, col='+1', **kwargs): + return self._add_input( + FloatSpinInput(self, name, message, self.move, value, col=col, **kwargs) + ) + + def add_int_spin_input(self, name, message, value=0, col='+1', **kwargs): + return self._add_input( + IntSpinInput(self, name, message, self.move, value, col=col, **kwargs) + ) + + def add_combo_input(self, name, message, choices, col='+1', **kwargs): + return self._add_input( + ComboInput(self, name, message, choices, col=col, **kwargs) + ) + + @overrides(InputKeyHandler) + def handle_read(self, c): + if not self.inputs: # no inputs added yet + return util.ReadState.IGNORED + ret = self.inputs[self.active_input].handle_read(c) + if ret != util.ReadState.IGNORED: + if self.immediate_action: + self.immediate_action_cb( + state_changed=False if ret == util.ReadState.READ else True + ) + return ret + + ret = util.ReadState.READ + + if c == curses.KEY_UP: + self.move_active_up(1) + elif c == curses.KEY_DOWN: + self.move_active_down(1) + elif c == curses.KEY_HOME: + self.move_active_up(len(self.inputs)) + elif c == curses.KEY_END: + self.move_active_down(len(self.inputs)) + elif c == curses.KEY_PPAGE: + self.move_active_up(self.move_active_many) + elif c == curses.KEY_NPAGE: + self.move_active_down(self.move_active_many) + elif c == util.KEY_ALT_AND_ARROW_UP: + self.lineoff = max(self.lineoff - 1, 0) + elif c == util.KEY_ALT_AND_ARROW_DOWN: + tot_height = self.get_content_height() + self.lineoff = min( + self.lineoff + 1, tot_height - self.visible_content_pane_height + ) + elif c == util.KEY_CTRL_AND_ARROW_UP: + if not self.allow_rearrange: + return ret + val = self.inputs.pop(self.active_input) + self.active_input -= 1 + self.inputs.insert(self.active_input, val) + if self.immediate_action: + self.immediate_action_cb(state_changed=True) + elif c == util.KEY_CTRL_AND_ARROW_DOWN: + if not self.allow_rearrange: + return ret + val = self.inputs.pop(self.active_input) + self.active_input += 1 + self.inputs.insert(self.active_input, val) + if self.immediate_action: + self.immediate_action_cb(state_changed=True) + else: + ret = util.ReadState.IGNORED + return ret + + def get_values(self): + vals = {} + for i, ipt in enumerate(self.inputs): + if not ipt.has_input(): + continue + vals[ipt.name] = { + 'value': ipt.get_value(), + 'order': i, + 'active': self.active_input == i, + } + return vals + + def immediate_action_cb(self, state_changed=True): + pass + + def move_active(self, direction, amount): + """ + direction == -1: Up + direction == 1: Down + + """ + self.last_lineoff_move = direction * amount + + if direction > 0: + if self.active_wrap: + limit = self.active_input - 1 + if limit < 0: + limit = len(self.inputs) + limit + else: + limit = len(self.inputs) - 1 + else: + limit = 0 + if self.active_wrap: + limit = self.active_input + 1 + + def next_move(nc, direction, limit): + next_index = nc + while next_index != limit: + next_index += direction + if direction > 0: + next_index %= len(self.inputs) + elif next_index < 0: + next_index = len(self.inputs) + next_index + + if self.inputs[next_index].selectable(): + return next_index + if next_index == limit: + return nc + return nc + + next_sel = self.active_input + for a in range(amount): + cur_sel = next_sel + next_sel = next_move(next_sel, direction, limit) + if cur_sel == next_sel: + tot_height = ( + self.get_content_height() + + self.border_off_north + + self.border_off_south + ) + if direction > 0: + self.lineoff = min( + self.lineoff + 1, tot_height - self.visible_content_pane_height + ) + else: + self.lineoff = max(self.lineoff - 1, 0) + + if next_sel is not None: + self.active_input = next_sel + + def move_active_up(self, amount): + self.move_active(-1, amount) + if self.immediate_action: + self.immediate_action_cb(state_changed=False) + + def move_active_down(self, amount): + self.move_active(1, amount) + if self.immediate_action: + self.immediate_action_cb(state_changed=False) + + def get_content_height(self): + height = 0 + for i, ipt in enumerate(self.inputs): + if ipt.depend_skip(): + continue + height += ipt.height + return height + + def ensure_active_visible(self): + start_row = 0 + end_row = self.border_off_north + for i, ipt in enumerate(self.inputs): + if ipt.depend_skip(): + continue + start_row = end_row + end_row += ipt.height + if i != self.active_input or not ipt.has_input(): + continue + height = self.visible_content_pane_height + if end_row > height + self.lineoff: + self.lineoff += end_row - ( + height + self.lineoff + ) # Correct result depends on paranthesis + elif start_row < self.lineoff: + self.lineoff -= self.lineoff - start_row + break + + def render_inputs(self, focused=False): + self._cursor_row = -1 + self._cursor_col = -1 + util.safe_curs_set(util.Curser.INVISIBLE) + + self.ensure_active_visible() + + crow = self.border_off_north + for i, ipt in enumerate(self.inputs): + if ipt.depend_skip(): + continue + col = self.border_off_west + field_width = self.width - self.border_off_east - self.border_off_west + cursor_offset = self.border_off_west + + if ipt.default_col != -1: + default_col = int(ipt.default_col) + if isinstance(ipt.default_col, ''.__class__) and ipt.default_col[0] in [ + '+', + '-', + ]: + col += default_col + cursor_offset += default_col + field_width -= default_col # Increase to col must be reflected here + else: + col = default_col + crow += ipt.render( + self.screen, + crow, + width=field_width, + active=i == self.active_input, + focused=focused, + col=col, + cursor_offset=cursor_offset, + ) + + if self._cursor_row >= 0: + util.safe_curs_set(util.Curser.VERY_VISIBLE) + move_cursor(self.screen, self._cursor_row, self._cursor_col) diff --git a/deluge/ui/console/widgets/popup.py b/deluge/ui/console/widgets/popup.py new file mode 100644 index 0000000..07d667d --- /dev/null +++ b/deluge/ui/console/widgets/popup.py @@ -0,0 +1,398 @@ +# +# Copyright (C) 2011 Nick Lanham <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 +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.utils import format_utils +from deluge.ui.console.widgets import BaseInputPane, BaseWindow + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) + + +class ALIGN: + TOP_LEFT = 1 + TOP_CENTER = 2 + TOP_RIGHT = 3 + MIDDLE_LEFT = 4 + MIDDLE_CENTER = 5 + MIDDLE_RIGHT = 6 + BOTTOM_LEFT = 7 + BOTTOM_CENTER = 8 + BOTTOM_RIGHT = 9 + DEFAULT = MIDDLE_CENTER + + +class PopupsHandler: + def __init__(self): + self._popups = [] + + @property + def popup(self): + if self._popups: + return self._popups[-1] + return None + + def push_popup(self, pu, clear=False): + if clear: + self._popups = [] + self._popups.append(pu) + + def pop_popup(self): + if self.popup: + return self._popups.pop() + + def report_message(self, title, message): + self.push_popup(MessagePopup(self, title, message)) + + +class Popup(BaseWindow, InputKeyHandler): + def __init__( + self, + parent_mode, + title, + width_req=0, + height_req=0, + align=ALIGN.DEFAULT, + close_cb=None, + encoding=None, + base_popup=None, + **kwargs + ): + """ + Init a new popup. The default constructor will handle sizing and borders and the like. + + Args: + parent_mode (basemode subclass): The mode which the popup will be drawn over + title (str): the title of the popup window + width_req (int or float): An integer value will be used as the width of the popup in character. + A float value will indicate the requested ratio in relation to the + parents screen width. + height_req (int or float): An integer value will be used as the height of the popup in character. + A float value will indicate the requested ratio in relation to the + parents screen height. + align (ALIGN): The alignment controlling the position of the popup on the screen. + close_cb (func): Function to be called when the popup is closed + encoding (str): The terminal encoding + base_popup (Popup): A popup used to inherit width_req and height_req if not explicitly specified. + + Note: The parent mode is responsible for calling refresh on any popups it wants to show. + This should be called as the last thing in the parents refresh method. + + The parent *must* also call read_input on the popup instead of/in addition to + running its own read_input code if it wants to have the popup handle user input. + + Popups have two methods that must be implemented: + + refresh(self) - draw the popup window to screen. this default mode simply draws a bordered window + with the supplied title to the screen + + read_input(self) - handle user input to the popup. + + """ + InputKeyHandler.__init__(self) + self.parent = parent_mode + self.close_cb = close_cb + self.height_req = height_req + self.width_req = width_req + self.align = align + if base_popup: + if not self.width_req: + self.width_req = base_popup.width_req + if not self.height_req: + self.height_req = base_popup.height_req + + hr, wr, posy, posx = self.calculate_size() + BaseWindow.__init__(self, title, wr, hr, encoding=None) + self.move_window(posy, posx) + self._closed = False + + @overrides(BaseWindow) + def refresh(self): + self.screen.erase() + height = self.get_content_height() + self.ensure_content_pane_height( + height + self.border_off_north + self.border_off_south + ) + BaseInputPane.render_inputs(self, focused=True) + BaseWindow.refresh(self) + + def calculate_size(self): + if isinstance(self.height_req, float) and 0.0 < self.height_req <= 1.0: + height = int((self.parent.rows - 2) * self.height_req) + else: + height = self.height_req + + if isinstance(self.width_req, float) and 0.0 < self.width_req <= 1.0: + width = int((self.parent.cols - 2) * self.width_req) + else: + width = self.width_req + + # Height + if height == 0: + height = int(self.parent.rows / 2) + elif height == -1: + height = self.parent.rows - 2 + elif height > self.parent.rows - 2: + height = self.parent.rows - 2 + + # Width + if width == 0: + width = int(self.parent.cols / 2) + elif width == -1: + width = self.parent.cols + elif width >= self.parent.cols: + width = self.parent.cols + + if self.align in [ALIGN.TOP_CENTER, ALIGN.TOP_LEFT, ALIGN.TOP_RIGHT]: + begin_y = 1 + elif self.align in [ALIGN.MIDDLE_CENTER, ALIGN.MIDDLE_LEFT, ALIGN.MIDDLE_RIGHT]: + begin_y = (self.parent.rows / 2) - (height / 2) + elif self.align in [ALIGN.BOTTOM_CENTER, ALIGN.BOTTOM_LEFT, ALIGN.BOTTOM_RIGHT]: + begin_y = self.parent.rows - height - 1 + + if self.align in [ALIGN.TOP_LEFT, ALIGN.MIDDLE_LEFT, ALIGN.BOTTOM_LEFT]: + begin_x = 0 + elif self.align in [ALIGN.TOP_CENTER, ALIGN.MIDDLE_CENTER, ALIGN.BOTTOM_CENTER]: + begin_x = (self.parent.cols / 2) - (width / 2) + elif self.align in [ALIGN.TOP_RIGHT, ALIGN.MIDDLE_RIGHT, ALIGN.BOTTOM_RIGHT]: + begin_x = self.parent.cols - width + + return height, width, begin_y, begin_x + + def handle_resize(self): + height, width, begin_y, begin_x = self.calculate_size() + self.resize_window(height, width) + self.move_window(begin_y, begin_x) + + def closed(self): + return self._closed + + def close(self, *args, **kwargs): + self._closed = True + if kwargs.get('call_cb', True): + self._call_close_cb(*args) + self.panel.hide() + + def _call_close_cb(self, *args, **kwargs): + if self.close_cb: + self.close_cb(*args, base_popup=self, **kwargs) + + @overrides(InputKeyHandler) + def handle_read(self, c): + if c == util.KEY_ESC: # close on esc, no action + self.close(None) + return util.ReadState.READ + return util.ReadState.IGNORED + + +class SelectablePopup(BaseInputPane, Popup): + """ + A popup which will let the user select from some of the lines that are added. + """ + + def __init__( + self, + parent_mode, + title, + selection_cb, + close_cb=None, + input_cb=None, + allow_rearrange=False, + immediate_action=False, + **kwargs + ): + """ + Args: + parent_mode (basemode subclass): The mode which the popup will be drawn over + title (str): the title of the popup window + selection_cb (func): Function to be called on selection + close_cb (func, optional): Function to be called when the popup is closed + input_cb (func, optional): Function to be called on every keyboard input + allow_rearrange (bool): Allow rearranging the selectable value + immediate_action (bool): If immediate_action_cb should be called for every action + kwargs (dict): Arguments passed to Popup + + """ + Popup.__init__(self, parent_mode, title, close_cb=close_cb, **kwargs) + kwargs.update( + {'allow_rearrange': allow_rearrange, 'immediate_action': immediate_action} + ) + BaseInputPane.__init__(self, self, **kwargs) + self.selection_cb = selection_cb + self.input_cb = input_cb + self.hotkeys = {} + self.cb_arg = {} + self.cb_args = kwargs.get('cb_args', {}) + if 'base_popup' not in self.cb_args: + self.cb_args['base_popup'] = self + + @property + @overrides(BaseWindow) + def visible_content_pane_height(self): + """We want to use the Popup property""" + return Popup.visible_content_pane_height.fget(self) + + def current_selection(self): + """Returns a tuple of (selected index, selected data).""" + return self.active_input + + def set_selection(self, index): + """Set a selected index""" + self.active_input = min(index, len(self.inputs) - 1) + + def add_line( + self, + name, + string, + use_underline=True, + cb_arg=None, + foreground=None, + selectable=True, + selected=False, + **kwargs + ): + hotkey = None + self.cb_arg[name] = cb_arg + if use_underline: + udx = string.find('_') + if udx >= 0: + hotkey = string[udx].lower() + string = ( + string[:udx] + + '{!+underline!}' + + string[udx + 1] + + '{!-underline!}' + + string[udx + 2 :] + ) + + kwargs['selectable'] = selectable + if foreground: + kwargs['color_active'] = '%s,white' % foreground + kwargs['color'] = '%s,black' % foreground + + field = self.add_text_field(name, string, **kwargs) + if hotkey: + self.hotkeys[hotkey] = field + + if selected: + self.set_selection(len(self.inputs) - 1) + + @overrides(Popup, BaseInputPane) + def handle_read(self, c): + if c in [curses.KEY_ENTER, util.KEY_ENTER2]: + for k, v in self.get_values().items(): + if v['active']: + if self.selection_cb(k, **dict(self.cb_args, data=self.cb_arg)): + self.close(None) + return util.ReadState.READ + else: + ret = BaseInputPane.handle_read(self, c) + if ret != util.ReadState.IGNORED: + return ret + ret = Popup.handle_read(self, c) + if ret != util.ReadState.IGNORED: + if self.selection_cb(None): + self.close(None) + return ret + + if self.input_cb: + self.input_cb(c) + + self.refresh() + return util.ReadState.IGNORED + + def add_divider(self, message=None, char='-', fill_width=True, color='white'): + if message is not None: + fill_width = False + else: + message = char + self.add_divider_field('', message, selectable=False, fill_width=fill_width) + + +class MessagePopup(Popup, BaseInputPane): + """ + Popup that just displays a message + """ + + def __init__( + self, + parent_mode, + title, + message, + align=ALIGN.DEFAULT, + height_req=0.75, + width_req=0.5, + **kwargs + ): + self.message = message + Popup.__init__( + self, + parent_mode, + title, + align=align, + height_req=height_req, + width_req=width_req, + ) + BaseInputPane.__init__(self, self, immediate_action=True, **kwargs) + lns = format_utils.wrap_string(self.message, self.width - 3, 3, True) + + if isinstance(self.height_req, float): + self.height_req = min(len(lns) + 2, int(parent_mode.rows * self.height_req)) + + self.handle_resize() + self.no_refresh = False + self.add_text_area('TextMessage', message) + + @overrides(Popup, BaseInputPane) + def handle_read(self, c): + ret = BaseInputPane.handle_read(self, c) + if ret != util.ReadState.IGNORED: + return ret + return Popup.handle_read(self, c) + + +class InputPopup(Popup, BaseInputPane): + def __init__(self, parent_mode, title, **kwargs): + Popup.__init__(self, parent_mode, title, **kwargs) + BaseInputPane.__init__(self, self, **kwargs) + # We need to replicate some things in order to wrap our inputs + self.encoding = parent_mode.encoding + + def _handle_callback(self, state_changed=True, close=True): + self._call_close_cb(self.get_values(), state_changed=state_changed, close=close) + + @overrides(BaseInputPane) + def immediate_action_cb(self, state_changed=True): + self._handle_callback(state_changed=state_changed, close=False) + + @overrides(Popup, BaseInputPane) + def handle_read(self, c): + ret = BaseInputPane.handle_read(self, c) + if ret != util.ReadState.IGNORED: + return ret + + if c in [curses.KEY_ENTER, util.KEY_ENTER2]: + if self.close_cb: + self._handle_callback(state_changed=False, close=False) + util.safe_curs_set(util.Curser.INVISIBLE) + return util.ReadState.READ + elif c == util.KEY_ESC: # close on esc, no action + self._handle_callback(state_changed=False, close=True) + self.close(None) + return util.ReadState.READ + + self.refresh() + return util.ReadState.READ diff --git a/deluge/ui/console/widgets/sidebar.py b/deluge/ui/console/widgets/sidebar.py new file mode 100644 index 0000000..4015a13 --- /dev/null +++ b/deluge/ui/console/widgets/sidebar.py @@ -0,0 +1,79 @@ +# +# Copyright (C) 2016 bendikro <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.decorators import overrides +from deluge.ui.console.modes.basemode import add_string +from deluge.ui.console.utils import curses_util as util +from deluge.ui.console.widgets import BaseInputPane, BaseWindow + +log = logging.getLogger(__name__) + + +class Sidebar(BaseInputPane, BaseWindow): + """Base sidebar widget that handles choosing a selected widget + with Up/Down arrows. + + Shows the different states of the torrents and allows to filter the + torrents based on state. + + """ + + def __init__( + self, torrentlist, width, height, title=None, allow_resize=False, **kwargs + ): + BaseWindow.__init__(self, title, width, height, posy=1) + BaseInputPane.__init__(self, self, immediate_action=True, **kwargs) + self.parent = torrentlist + self.focused = False + self.allow_resize = allow_resize + + def set_focused(self, focused): + self.focused = focused + + def has_focus(self): + return self.focused and not self.hidden() + + @overrides(BaseInputPane) + def handle_read(self, c): + if c == curses.KEY_UP: + self.move_active_up(1) + elif c == curses.KEY_DOWN: + self.move_active_down(1) + elif self.allow_resize and c in [ord('+'), ord('-')]: + width = self.visible_content_pane_width + (1 if c == ord('+') else -1) + self.on_resize(width) + else: + return BaseInputPane.handle_read(self, c) + return util.ReadState.READ + + def on_resize(self, width): + self.resize_window(self.height, width) + + @overrides(BaseWindow) + def refresh(self): + height = self.get_content_height() + self.ensure_content_pane_height( + height + self.border_off_north + self.border_off_south + ) + BaseInputPane.render_inputs(self, focused=self.has_focus()) + BaseWindow.refresh(self) + + def _refresh(self): + self.screen.erase() + height = self.get_content_height() + self.ensure_content_pane_height( + height + self.border_off_north + self.border_off_south + ) + BaseInputPane.render_inputs(self, focused=True) + BaseWindow.refresh(self) + + def add_string(self, row, string, scr=None, **kwargs): + add_string(row, string, self.screen, self.parent.encoding, **kwargs) diff --git a/deluge/ui/console/widgets/statusbars.py b/deluge/ui/console/widgets/statusbars.py new file mode 100644 index 0000000..1b91737 --- /dev/null +++ b/deluge/ui/console/widgets/statusbars.py @@ -0,0 +1,124 @@ +# +# Copyright (C) 2009 Andrew Resch <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 deluge.common +import deluge.component as component +from deluge.ui.client import client + +DEFAULT_DAEMON_PORT = 58846 + + +class StatusBars(component.Component): + def __init__(self): + component.Component.__init__(self, 'StatusBars', 2, depend=['CoreConfig']) + self.config = component.get('CoreConfig') + + # Hold some values we get from the core + self.connections = 0 + self.download = '' + self.upload = '' + self.dht = 0 + self.external_ip = '' + + # Default values + self.topbar = '{!status!}Deluge %s Console - ' % deluge.common.get_version() + self.bottombar = '{!status!}C: %s' % self.connections + + def start(self): + self.update() + + def update(self): + def on_get_session_status(status): + self.upload = deluge.common.fsize(status['payload_upload_rate']) + self.download = deluge.common.fsize(status['payload_download_rate']) + self.connections = status['peer.num_peers_connected'] + if 'dht_nodes' in status: + self.dht = status['dht.dht_nodes'] + + self.update_statusbars() + + def on_get_external_ip(external_ip): + self.external_ip = external_ip + + keys = [ + 'peer.num_peers_connected', + 'payload_upload_rate', + 'payload_download_rate', + ] + + if self.config['dht']: + keys.append('dht.dht_nodes') + + client.core.get_session_status(keys).addCallback(on_get_session_status) + client.core.get_external_ip().addCallback(on_get_external_ip) + + def update_statusbars(self): + # Update the topbar string + self.topbar = '{!status!}Deluge %s Console - ' % deluge.common.get_version() + + if client.connected(): + info = client.connection_info() + connection_info = '' + + # Client name + if info[2] == 'localclient': + connection_info += '{!white,blue!}%s' + else: + connection_info += '{!green,blue,bold!}%s' + + # Hostname + if info[0] == '127.0.0.1': + connection_info += '{!white,blue,bold!}@{!white,blue!}%s' + else: + connection_info += '{!white,blue,bold!}@{!red,blue,bold!}%s' + + # Port + if info[1] == DEFAULT_DAEMON_PORT: + connection_info += '{!white,blue!}:%s' + else: + connection_info += '{!status!}:%s' + + # Change color back to normal, just in case + connection_info += '{!status!}' + + self.topbar += connection_info % (info[2], info[0], info[1]) + else: + self.topbar += 'Not Connected' + + # Update the bottombar string + self.bottombar = '{!status!}C: {!white,blue!}%s{!status!}' % self.connections + + if self.config['max_connections_global'] > -1: + self.bottombar += ' (%s)' % self.config['max_connections_global'] + + if self.download != '0.0 KiB': + self.bottombar += ' D: {!magenta,blue,bold!}%s{!status!}' % self.download + else: + self.bottombar += ' D: {!white,blue!}%s{!status!}' % self.download + + if self.config['max_download_speed'] > -1: + self.bottombar += ( + ' (%s ' % self.config['max_download_speed'] + _('KiB/s') + ')' + ) + + if self.upload != '0.0 KiB': + self.bottombar += ' U: {!green,blue,bold!}%s{!status!}' % self.upload + else: + self.bottombar += ' U: {!white,blue!}%s{!status!}' % self.upload + + if self.config['max_upload_speed'] > -1: + self.bottombar += ( + ' (%s ' % self.config['max_upload_speed'] + _('KiB/s') + ')' + ) + + if self.config['dht']: + self.bottombar += ' ' + _('DHT') + ': {!white,blue!}%s{!status!}' % self.dht + + self.bottombar += ' ' + _('IP {!white,blue!}%s{!status!}') % ( + self.external_ip if self.external_ip else _('n/a') + ) diff --git a/deluge/ui/console/widgets/window.py b/deluge/ui/console/widgets/window.py new file mode 100644 index 0000000..77aff88 --- /dev/null +++ b/deluge/ui/console/widgets/window.py @@ -0,0 +1,182 @@ +# +# Copyright (C) 2011 Nick Lanham <nick@afternight.org> +# 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 + +from deluge.ui.console.modes.basemode import add_string, mkpad, mkpanel +from deluge.ui.console.utils.colors import get_color_pair + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) + + +class BaseWindow: + """ + BaseWindow creates a curses screen to be used for showing panels and popup dialogs + """ + + def __init__(self, title, width, height, posy=0, posx=0, encoding=None): + """ + Args: + title (str): The title of the panel + width (int): Width of the panel + height (int): Height of the panel + posy (int): Position of the panel's first row relative to the terminal screen + posx (int): Position of the panel's first column relative to the terminal screen + encoding (str): Terminal encoding + """ + self.title = title + self.posy, self.posx = posy, posx + if encoding is None: + from deluge import component + + encoding = component.get('ConsoleUI').encoding + self.encoding = encoding + + self.panel = mkpanel(curses.COLOR_GREEN, height, width, posy, posx) + self.outer_screen = self.panel.window() + self.outer_screen.bkgdset(0, curses.COLOR_RED) + by, bx = self.outer_screen.getbegyx() + self.screen = mkpad(get_color_pair('white', 'black'), height - 1, width - 2) + self._height, self._width = self.outer_screen.getmaxyx() + + @property + def height(self): + return self._height + + @property + def width(self): + return self._width + + def add_string(self, row, string, scr=None, **kwargs): + scr = scr if scr else self.screen + add_string(row, string, scr, self.encoding, **kwargs) + + def hide(self): + self.panel.hide() + + def show(self): + self.panel.show() + + def hidden(self): + return self.panel.hidden() + + def set_title(self, title): + self.title = title + + @property + def visible_content_pane_size(self): + y, x = self.outer_screen.getmaxyx() + return (y - 2, x - 2) + + @property + def visible_content_pane_height(self): + y, x = self.visible_content_pane_size + return y + + @property + def visible_content_pane_width(self): + y, x = self.visible_content_pane_size + return x + + def getmaxyx(self): + return self.screen.getmaxyx() + + def resize_window(self, rows, cols): + self.outer_screen.resize(rows, cols) + self.screen.resize(rows - 2, cols - 2) + self._height, self._width = rows, cols + + def move_window(self, posy, posx): + posy = int(posy) + posx = int(posx) + self.outer_screen.mvwin(posy, posx) + self.posy = posy + self.posx = posx + self._height, self._width = self.screen.getmaxyx() + + def ensure_content_pane_height(self, height): + max_y, max_x = self.screen.getmaxyx() + if max_y < height: + self.screen.resize(height, max_x) + + def draw_scroll_indicator(self, screen): + content_height = self.get_content_height() + if content_height <= self.visible_content_pane_height: + return + + percent_scroll = float(self.lineoff) / ( + content_height - self.visible_content_pane_height + ) + indicator_row = int(self.visible_content_pane_height * percent_scroll) + 1 + + # Never greater than height + indicator_row = min(indicator_row, self.visible_content_pane_height) + indicator_col = self.width + 1 + + add_string( + indicator_row, + '{!red,black,bold!}#', + screen, + self.encoding, + col=indicator_col, + pad=False, + trim=False, + ) + + def refresh(self): + height, width = self.visible_content_pane_size + self.outer_screen.erase() + self.outer_screen.border(0, 0, 0, 0) + + if self.title: + toff = max(1, (self.width // 2) - (len(self.title) // 2)) + self.add_string( + 0, + '{!white,black,bold!}%s' % self.title, + scr=self.outer_screen, + col=toff, + pad=False, + ) + + self.draw_scroll_indicator(self.outer_screen) + self.outer_screen.noutrefresh() + + try: + # pminrow, pmincol, sminrow, smincol, smaxrow, smaxcol + # the p arguments refer to the upper left corner of the pad region to be displayed and + # the s arguments define a clipping box on the screen within which the pad region is to be displayed. + pminrow = self.lineoff + pmincol = 0 + sminrow = self.posy + 1 + smincol = self.posx + 1 + smaxrow = height + self.posy + smaxcol = width + self.posx + self.screen.noutrefresh( + pminrow, pmincol, sminrow, smincol, smaxrow, smaxcol + ) + except curses.error as ex: + import traceback + + log.warning( + 'Error on screen.noutrefresh(%s, %s, %s, %s, %s, %s) Error: %s\nStack: %s', + pminrow, + pmincol, + sminrow, + smincol, + smaxrow, + smaxcol, + ex, + ''.join(traceback.format_stack()), + ) |