diff options
Diffstat (limited to 'deluge/ui/console/utils')
-rw-r--r-- | deluge/ui/console/utils/__init__.py | 0 | ||||
-rw-r--r-- | deluge/ui/console/utils/colors.py | 323 | ||||
-rw-r--r-- | deluge/ui/console/utils/column.py | 74 | ||||
-rw-r--r-- | deluge/ui/console/utils/common.py | 20 | ||||
-rw-r--r-- | deluge/ui/console/utils/config.py | 118 | ||||
-rw-r--r-- | deluge/ui/console/utils/curses_util.py | 62 | ||||
-rw-r--r-- | deluge/ui/console/utils/format_utils.py | 350 |
7 files changed, 947 insertions, 0 deletions
diff --git a/deluge/ui/console/utils/__init__.py b/deluge/ui/console/utils/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/deluge/ui/console/utils/__init__.py diff --git a/deluge/ui/console/utils/colors.py b/deluge/ui/console/utils/colors.py new file mode 100644 index 0000000..cc414fe --- /dev/null +++ b/deluge/ui/console/utils/colors.py @@ -0,0 +1,323 @@ +# +# Copyright (C) 2009 Andrew Resch <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 re + +from deluge.ui.console.utils import format_utils + +try: + import curses +except ImportError: + pass + +log = logging.getLogger(__name__) + +colors = [ + 'COLOR_BLACK', + 'COLOR_BLUE', + 'COLOR_CYAN', + 'COLOR_GREEN', + 'COLOR_MAGENTA', + 'COLOR_RED', + 'COLOR_WHITE', + 'COLOR_YELLOW', +] + +# {(fg, bg): pair_number, ...} +color_pairs = {('white', 'black'): 0} # Special case, can't be changed + +# Some default color schemes +schemes = { + 'input': ('white', 'black'), + 'normal': ('white', 'black'), + 'status': ('yellow', 'blue', 'bold'), + 'info': ('white', 'black', 'bold'), + 'error': ('red', 'black', 'bold'), + 'success': ('green', 'black', 'bold'), + 'event': ('magenta', 'black', 'bold'), + 'selected': ('black', 'white', 'bold'), + 'marked': ('white', 'blue', 'bold'), + 'selectedmarked': ('blue', 'white', 'bold'), + 'header': ('green', 'black', 'bold'), + 'filterstatus': ('green', 'blue', 'bold'), +} + +# Colors for various torrent states +state_color = { + 'Seeding': '{!blue,black,bold!}', + 'Downloading': '{!green,black,bold!}', + 'Paused': '{!white,black!}', + 'Checking': '{!green,black!}', + 'Queued': '{!yellow,black!}', + 'Error': '{!red,black,bold!}', + 'Moving': '{!green,black,bold!}', +} + +type_color = { + bool: '{!yellow,black,bold!}', + int: '{!green,black,bold!}', + float: '{!green,black,bold!}', + str: '{!cyan,black,bold!}', + list: '{!magenta,black,bold!}', + dict: '{!white,black,bold!}', +} + +tab_char = '\t' +color_tag_start = '{!' +color_tag_end = '!}' + + +def get_color_pair(fg, bg): + return color_pairs[(fg, bg)] + + +def init_colors(): + curses.start_color() + + # We want to redefine white/black as it makes underlining work for some terminals + # but can also fail on others, so we try/except + + def define_pair(counter, fg_name, bg_name, fg, bg): + try: + curses.init_pair(counter, fg, bg) + color_pairs[(fg_name, bg_name)] = counter + counter += 1 + except (curses.error, ValueError) as ex: + log.debug(f'Color pair {fg_name} {bg_name} not available: {ex}') + return counter + + # Create the color_pairs dict + counter = 1 + for fg in colors: + for bg in colors: + counter = define_pair( + counter, + fg[6:].lower(), + bg[6:].lower(), + getattr(curses, fg), + getattr(curses, bg), + ) + + counter = define_pair(counter, 'white', 'grey', curses.COLOR_WHITE, 241) + counter = define_pair(counter, 'black', 'whitegrey', curses.COLOR_BLACK, 249) + counter = define_pair(counter, 'magentadark', 'white', 99, curses.COLOR_WHITE) + + +class BadColorString(Exception): + pass + + +def check_tag_count(string): + """Raise BadColorString if color tag open/close not equal.""" + if string.count(color_tag_start) != string.count(color_tag_end): + raise BadColorString('Number of {! is not equal to number of !}') + + +def replace_tabs(line): + """ + Returns a string with tabs replaced with spaces. + + """ + for i in range(line.count(tab_char)): + tab_length = 8 - (len(line[: line.find(tab_char)]) % 8) + line = line.replace(tab_char, b' ' * tab_length, 1) + return line + + +def strip_colors(line): + """ + Returns a string with the color formatting removed. + + """ + check_tag_count(line) + + # Remove all the color tags + while line.find(color_tag_start) != -1: + tag_start = line.find(color_tag_start) + tag_end = line.find(color_tag_end) + 2 + line = line[:tag_start] + line[tag_end:] + + return line + + +def get_line_length(line): + """ + Returns the string length without the color formatting. + + """ + # Remove all the color tags + line = strip_colors(line) + + # Replace tabs with the appropriate amount of spaces + line = replace_tabs(line) + return len(line) + + +def get_line_width(line): + """ + Get width of string considering double width characters + + """ + # Remove all the color tags + line = strip_colors(line) + + # Replace tabs with the appropriate amount of spaces + line = replace_tabs(line) + return format_utils.strwidth(line) + + +def parse_color_string(string): + """Parses a string and returns a list of 2-tuples (color, string). + + Args: + string (str): The string to parse. + """ + check_tag_count(string) + + ret = [] + last_color_attr = None + # Keep track of where the strings + while string.find(color_tag_start) != -1: + begin = string.find(color_tag_start) + if begin > 0: + ret.append( + ( + curses.color_pair( + color_pairs[(schemes['input'][0], schemes['input'][1])] + ), + string[:begin], + ) + ) + + end = string.find(color_tag_end) + if end == -1: + raise BadColorString('Missing closing "!}"') + + # Get a list of attributes in the bracketed section + attrs = string[begin + 2 : end].split(',') + + if len(attrs) == 1 and not attrs[0].strip(' '): + raise BadColorString('No description in {! !}') + + def apply_attrs(cp, attrs): + # This function applies any additional attributes as necessary + for attr in attrs: + if attr == 'ignore': + continue + mode = '+' + if attr[0] in ['+', '-']: + mode = attr[0] + attr = attr[1:] + if mode == '+': + cp |= getattr(curses, 'A_' + attr.upper()) + else: + cp ^= getattr(curses, 'A_' + attr.upper()) + return cp + + # Check for a builtin type first + if attrs[0] in schemes: + pair = (schemes[attrs[0]][0], schemes[attrs[0]][1]) + if pair not in color_pairs: + log.debug('Color pair does not exist: %s, attrs: %s', pair, attrs) + pair = ('white', 'black') + # Get the color pair number + color_pair = curses.color_pair(color_pairs[pair]) + color_pair = apply_attrs(color_pair, schemes[attrs[0]][2:]) + last_color_attr = color_pair + else: + attrlist = ['blink', 'bold', 'dim', 'reverse', 'standout', 'underline'] + + if attrs[0][0] in ['+', '-']: + # Color is not given, so use last color + if last_color_attr is None: + raise BadColorString( + 'No color value given when no previous color was used!: %s' + % (attrs[0]) + ) + color_pair = last_color_attr + for i, attr in enumerate(attrs): + if attr[1:] not in attrlist: + raise BadColorString('Bad attribute value!: %s' % (attr)) + else: + # This is a custom color scheme + fg = attrs[0] + bg = 'black' # Default to 'black' if no bg is chosen + if len(attrs) > 1: + bg = attrs[1] + try: + pair = (fg, bg) + if pair not in color_pairs: + # Color pair missing, this could be because the + # terminal settings allows no colors. If background is white, we + # assume this means selection, and use "white", "black" + reverse + # To have white background and black foreground + log.debug('Color pair does not exist: %s', pair) + if pair[1] == 'white': + if attrs[2] == 'ignore': + attrs[2] = 'reverse' + else: + attrs.append('reverse') + pair = ('white', 'black') + color_pair = curses.color_pair(color_pairs[pair]) + last_color_attr = color_pair + attrs = attrs[2:] # Remove colors + except KeyError: + raise BadColorString(f'Bad color value in tag: {fg},{bg}') + # Check for additional attributes and OR them to the color_pair + color_pair = apply_attrs(color_pair, attrs) + last_color_attr = color_pair + # We need to find the text now, so lets try to find another {! and if + # there isn't one, then it's the rest of the string + next_begin = string.find(color_tag_start, end) + + if next_begin == -1: + ret.append((color_pair, replace_tabs(string[end + 2 :]))) + break + else: + ret.append((color_pair, replace_tabs(string[end + 2 : next_begin]))) + string = string[next_begin:] + + if not ret: + # There was no color scheme so we add it with a 0 for white on black + ret = [(0, string)] + return ret + + +class ConsoleColorFormatter: + """ + Format help in a way suited to deluge CmdLine mode - colors, format, indentation... + """ + + replace_dict = { + '<torrent-id>': '{!green!}%s{!input!}', + '<torrent>': '{!green!}%s{!input!}', + '<command>': '{!green!}%s{!input!}', + '<state>': '{!yellow!}%s{!input!}', + '\\.\\.\\.': '{!yellow!}%s{!input!}', + '\\s\\*\\s': '{!blue!}%s{!input!}', + '(?<![\\-a-z])(-[a-zA-Z0-9])': '{!red!}%s{!input!}', + # "(\-[a-zA-Z0-9])": "{!red!}%s{!input!}", + '--[_\\-a-zA-Z0-9]+': '{!green!}%s{!input!}', + '(\\[|\\])': '{!info!}%s{!input!}', + '<tab>': '{!white!}%s{!input!}', + '[_A-Z]{3,}': '{!cyan!}%s{!input!}', + '<key>': '{!cyan!}%s{!input!}', + '<value>': '{!cyan!}%s{!input!}', + 'usage:': '{!info!}%s{!input!}', + '<download-folder>': '{!yellow!}%s{!input!}', + '<torrent-file>': '{!green!}%s{!input!}', + } + + def format_colors(self, string): + def r(repl): + return lambda s: repl % s.group() + + for key, replacement in self.replace_dict.items(): + string = re.sub(key, r(replacement), string) + return string diff --git a/deluge/ui/console/utils/column.py b/deluge/ui/console/utils/column.py new file mode 100644 index 0000000..ecbe04b --- /dev/null +++ b/deluge/ui/console/utils/column.py @@ -0,0 +1,74 @@ +# +# Copyright (C) 2011 Nick Lanham <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 copy +import logging + +import deluge.common +from deluge.i18n import setup_translation +from deluge.ui.common import TORRENT_DATA_FIELD +from deluge.ui.console.utils import format_utils + +setup_translation() + +log = logging.getLogger(__name__) + +torrent_data_fields = copy.deepcopy(TORRENT_DATA_FIELD) + +formatters = { + 'queue': format_utils.format_queue, + 'name': lambda a, b: b, + 'state': None, + 'tracker': None, + 'download_location': None, + 'owner': None, + 'progress_state': format_utils.format_progress, + 'progress': format_utils.format_progress, + 'size': format_utils.format_size, + 'downloaded': format_utils.format_size, + 'uploaded': format_utils.format_size, + 'remaining': format_utils.format_size, + 'ratio': format_utils.format_float, + 'avail': format_utils.format_float, + 'seeds_peers_ratio': format_utils.format_float, + 'download_speed': format_utils.format_speed, + 'upload_speed': format_utils.format_speed, + 'max_download_speed': format_utils.format_speed, + 'max_upload_speed': format_utils.format_speed, + 'peers': format_utils.format_seeds_peers, + 'seeds': format_utils.format_seeds_peers, + 'time_added': deluge.common.fdate, + 'seeding_time': format_utils.format_time, + 'active_time': format_utils.format_time, + 'time_since_transfer': format_utils.format_date_dash, + 'finished_time': deluge.common.ftime, + 'last_seen_complete': format_utils.format_date_never, + 'completed_time': format_utils.format_date_dash, + 'eta': format_utils.format_time, + 'pieces': format_utils.format_pieces, +} + +for data_field in torrent_data_fields: + torrent_data_fields[data_field]['formatter'] = formatters.get(data_field, str) + + +def get_column_value(name, state): + col = torrent_data_fields[name] + + if col['formatter']: + args = [state[key] for key in col['status']] + return col['formatter'](*args) + else: + return state[col['status'][0]] + + +def get_required_fields(cols): + fields = [] + for col in cols: + fields.extend(torrent_data_fields[col]['status']) + return fields diff --git a/deluge/ui/console/utils/common.py b/deluge/ui/console/utils/common.py new file mode 100644 index 0000000..fdc88c4 --- /dev/null +++ b/deluge/ui/console/utils/common.py @@ -0,0 +1,20 @@ +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +TORRENT_OPTIONS = { + 'max_download_speed': float, + 'max_upload_speed': float, + 'max_connections': int, + 'max_upload_slots': int, + 'prioritize_first_last': bool, + 'is_auto_managed': bool, + 'stop_at_ratio': bool, + 'stop_ratio': float, + 'remove_at_ratio': bool, + 'move_completed': bool, + 'move_completed_path': str, + 'super_seeding': bool, +} diff --git a/deluge/ui/console/utils/config.py b/deluge/ui/console/utils/config.py new file mode 100644 index 0000000..debb52a --- /dev/null +++ b/deluge/ui/console/utils/config.py @@ -0,0 +1,118 @@ +def migrate_1_to_2(config): + """Create better structure by moving most settings out of dict root + and into sub categories. Some keys are also renamed to be consistent + with other UIs. + """ + + def move_key(source, dest, source_key, dest_key=None): + if dest_key is None: + dest_key = source_key + + dest[dest_key] = source[source_key] + del source[source_key] + + # These are moved to 'torrentview' sub dict + for k in [ + 'sort_primary', + 'sort_secondary', + 'move_selection', + 'separate_complete', + ]: + move_key(config, config['torrentview'], k) + + # These are moved to 'addtorrents' sub dict + for k in [ + 'show_misc_files', + 'show_hidden_folders', + 'sort_column', + 'reverse_sort', + 'last_path', + ]: + move_key(config, config['addtorrents'], 'addtorrents_%s' % k, dest_key=k) + + # These are moved to 'cmdline' sub dict + for k in [ + 'ignore_duplicate_lines', + 'torrents_per_tab_press', + 'third_tab_lists_all', + ]: + move_key(config, config['cmdline'], k) + + move_key( + config, + config['cmdline'], + 'save_legacy_history', + dest_key='save_command_history', + ) + + # Add key for localization + config['language'] = '' + + # Migrate column settings + columns = [ + 'queue', + 'size', + 'state', + 'progress', + 'seeds', + 'peers', + 'downspeed', + 'upspeed', + 'eta', + 'ratio', + 'avail', + 'added', + 'tracker', + 'savepath', + 'downloaded', + 'uploaded', + 'remaining', + 'owner', + 'downloading_time', + 'seeding_time', + 'completed', + 'seeds_peers_ratio', + 'complete_seen', + 'down_limit', + 'up_limit', + 'shared', + 'name', + ] + column_name_mapping = { + 'downspeed': 'download_speed', + 'upspeed': 'upload_speed', + 'added': 'time_added', + 'savepath': 'download_location', + 'completed': 'completed_time', + 'complete_seen': 'last_seen_complete', + 'down_limit': 'max_download_speed', + 'up_limit': 'max_upload_speed', + 'downloading_time': 'active_time', + } + + from deluge.ui.console.modes.torrentlist.torrentview import default_columns + + # These are moved to 'torrentview.columns' sub dict + for k in columns: + column_name = column_name_mapping.get(k, k) + config['torrentview']['columns'][column_name] = {} + if k == 'name': + config['torrentview']['columns'][column_name]['visible'] = True + else: + move_key( + config, + config['torrentview']['columns'][column_name], + 'show_%s' % k, + dest_key='visible', + ) + move_key( + config, + config['torrentview']['columns'][column_name], + '%s_width' % k, + dest_key='width', + ) + config['torrentview']['columns'][column_name]['order'] = default_columns[ + column_name + ]['order'] + + return config diff --git a/deluge/ui/console/utils/curses_util.py b/deluge/ui/console/utils/curses_util.py new file mode 100644 index 0000000..50b0444 --- /dev/null +++ b/deluge/ui/console/utils/curses_util.py @@ -0,0 +1,62 @@ +# +# Copyright (C) 2016 bendikro <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. +# + +try: + import curses +except ImportError: + pass + +KEY_BELL = 7 # CTRL-/ ^G (curses.keyname(KEY_BELL) == "^G") +KEY_TAB = 9 +KEY_ENTER2 = 10 +KEY_ESC = 27 +KEY_SPACE = 32 +KEY_BACKSPACE2 = 127 + +KEY_ALT_AND_ARROW_UP = 564 +KEY_ALT_AND_ARROW_DOWN = 523 + +KEY_ALT_AND_KEY_PPAGE = 553 +KEY_ALT_AND_KEY_NPAGE = 548 + +KEY_CTRL_AND_ARROW_UP = 566 +KEY_CTRL_AND_ARROW_DOWN = 525 + + +def is_printable_chr(c): + return c >= 32 and c <= 126 + + +def is_int_chr(c): + return c > 47 and c < 58 + + +class Curser: + INVISIBLE = 0 + NORMAL = 1 + VERY_VISIBLE = 2 + + +def safe_curs_set(visibility): + """ + Args: + visibility(int): 0, 1, or 2, for invisible, normal, or very visible + + curses.curs_set fails on monochrome terminals so use this + to ignore errors + """ + try: + curses.curs_set(visibility) + except curses.error: + pass + + +class ReadState: + IGNORED = 0 + READ = 1 + CHANGED = 2 diff --git a/deluge/ui/console/utils/format_utils.py b/deluge/ui/console/utils/format_utils.py new file mode 100644 index 0000000..50ec191 --- /dev/null +++ b/deluge/ui/console/utils/format_utils.py @@ -0,0 +1,350 @@ +# +# Copyright (C) 2011 Nick Lanham <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 re +from collections import deque +from unicodedata import east_asian_width, normalize + +import deluge.common +from deluge.ui.common import FILE_PRIORITY + + +def format_size(size): + return deluge.common.fsize(size, shortform=True) + + +def format_speed(speed): + if speed > 0: + return deluge.common.fspeed(speed, shortform=True) + else: + return '-' + + +def format_time(time): + if time > 0: + return deluge.common.ftime(time) + elif time == 0: + return '-' + else: + return '∞' + + +def format_date_dash(time): + if time > 0: + return deluge.common.fdate(time, date_only=True) + else: + return '-' + + +def format_date_never(time): + if time > 0: + return deluge.common.fdate(time, date_only=True) + else: + return 'Never' + + +def format_float(x): + if x < 0: + return '-' + else: + return '%.3f' % x + + +def format_seeds_peers(num, total): + return '%d (%d)' % (num, total) + + +def format_progress(value): + return ('%.2f' % value).rstrip('0').rstrip('.') + '%' + + +def f_progressbar(progress, width): + """ + Returns a string of a progress bar. + + :param progress: float, a value between 0-100 + + :returns: str, a progress bar based on width + + """ + + w = width - 2 # we use a [] for the beginning and end + s = '[' + p = int(round((progress / 100) * w)) + s += '#' * p + s += '-' * (w - p) + s += ']' + return s + + +def f_seedrank_dash(seed_rank, seeding_time): + """Display value if seeding otherwise dash""" + + if seeding_time > 0: + if seed_rank >= 1000: + return '%ik' % (seed_rank // 1000) + else: + return str(seed_rank) + else: + return '-' + + +def ftotal_sized(first, second): + return '{} ({})'.format( + deluge.common.fsize(first, shortform=True), + deluge.common.fsize(second, shortform=True), + ) + + +def format_pieces(num, size): + return '%d (%s)' % (num, deluge.common.fsize(size, shortform=True)) + + +def format_priority(prio): + if prio == -2: + return '[Mixed]' + elif prio < 0: + return '-' + return FILE_PRIORITY[prio] + + +def format_queue(qnum): + if qnum < 0: + return '' + return '%d' % (qnum + 1) + + +def trim_string(string, w, have_dbls): + if w <= 0: + return '' + elif w == 1: + return ' ' + elif have_dbls: + # have to do this the slow way + chrs = [] + width = 4 + idx = 0 + while width < w: + chrs.append(string[idx]) + if east_asian_width(string[idx]) in 'WF': + width += 2 + else: + width += 1 + idx += 1 + if width != w: + chrs.pop() + chrs.append('.') + return '%s ' % (''.join(chrs)) + else: + return '%s ' % (string[0 : w - 1]) + + +def format_column(col, lim): + try: + # might have some double width chars + col = normalize('NFC', col) + dbls = sum(east_asian_width(c) in 'WF' for c in col) + except TypeError: + dbls = 0 + + size = len(col) + dbls + if size >= lim - 1: + return trim_string(col, lim, dbls > 0) + else: + return '{}{}'.format(col, ' ' * (lim - size)) + + +def format_row(row, column_widths): + return ''.join( + [format_column(row[i], column_widths[i]) for i in range(0, len(row))] + ) + + +_strip_re = re.compile(r'\{!.*?!\}') +_format_code = re.compile(r'\{\|(.*)\|\}') + + +def remove_formatting(string): + return re.sub(_strip_re, '', string) + + +def shorten_hash(tid, space_left, min_width=13, placeholder='...'): + """Shorten the supplied torrent infohash by removing chars from the middle. + + Use a placeholder to indicate shortened. + If unable to shorten will justify so entire tid is on the next line. + + """ + tid = tid.strip() + if space_left >= min_width: + mid = len(tid) // 2 + trim, remain = divmod(len(tid) + len(placeholder) - space_left, 2) + return tid[0 : mid - trim] + placeholder + tid[mid + trim + remain :] + else: + # Justity the tid so it is completely on the next line. + return tid.rjust(len(tid) + space_left) + + +def wrap_string(string, width, min_lines=0, strip_colors=True): + """ + Wrap a string to fit in a particular width. Returns a list of output lines. + + :param string: str, the string to wrap + :param width: int, the maximum width of a line of text + :param min_lines: int, extra lines will be added so the output tuple contains at least min_lines lines + :param strip_colors: boolean, if True, text in {!!} blocks will not be considered as adding to the + width of the line. They will still be present in the output. + """ + ret = [] + s1 = string.split('\n') + indent = '' + + def insert_clr(s, offset, mtchs, clrs): + end_pos = offset + len(s) + while mtchs and (mtchs[0] <= end_pos) and (mtchs[0] >= offset): + mtc = mtchs.popleft() - offset + clr = clrs.popleft() + end_pos += len(clr) + s = f'{s[:mtc]}{clr}{s[mtc:]}' + return s + + for s in s1: + offset = 0 + indent = '' + m = _format_code.search(remove_formatting(s)) + if m: + if m.group(1).startswith('indent:'): + indent = m.group(1)[len('indent:') :] + elif m.group(1).startswith('indent_pos:'): + begin = m.start(0) + indent = ' ' * begin + s = _format_code.sub('', s) + + if strip_colors: + mtchs = deque() + clrs = deque() + for m in _strip_re.finditer(s): + mtchs.append(m.start()) + clrs.append(m.group()) + cstr = _strip_re.sub('', s) + else: + cstr = s + + def append_indent(line, string, offset): + """Prepends indent to string if specified""" + if indent and offset != 0: + string = indent + string + line.append(string) + + while cstr: + # max with for a line. If indent is specified, we account for this + max_width = width - (len(indent) if offset != 0 else 0) + if len(cstr) < max_width: + break + sidx = cstr.rfind(' ', 0, max_width - 1) + sidx += 1 + if sidx > 0: + if strip_colors: + to_app = cstr[0:sidx] + to_app = insert_clr(to_app, offset, mtchs, clrs) + append_indent(ret, to_app, offset) + offset += len(to_app) + else: + append_indent(ret, cstr[0:sidx], offset) + cstr = cstr[sidx:] + if not cstr: + cstr = None + break + else: + # can't find a reasonable split, just split at width + if strip_colors: + to_app = cstr[0:width] + to_app = insert_clr(to_app, offset, mtchs, clrs) + append_indent(ret, to_app, offset) + offset += len(to_app) + else: + append_indent(ret, cstr[0:width], offset) + cstr = cstr[width:] + if not cstr: + cstr = None + break + if cstr is not None: + to_append = cstr + if strip_colors: + to_append = insert_clr(cstr, offset, mtchs, clrs) + append_indent(ret, to_append, offset) + + if min_lines > 0: + for i in range(len(ret), min_lines): + ret.append(' ') + + # Carry colors over to the next line + last_color_string = '' + for i, line in enumerate(ret): + if i != 0: + ret[i] = f'{last_color_string}{ret[i]}' + + colors = re.findall('\\{![^!]+!\\}', line) + if colors: + last_color_string = colors[-1] + + return ret + + +def strwidth(string): + """ + Measure width of a string considering asian double width characters + """ + return sum(1 + (east_asian_width(char) in ['W', 'F']) for char in string) + + +def pad_string(string, length, character=' ', side='right'): + """ + Pad string with specified character to desired length, considering double width characters. + """ + w = strwidth(string) + diff = length - w + if side == 'left': + return f'{character * diff}{string}' + elif side == 'right': + return f'{string}{character * diff}' + + +def delete_alt_backspace(input_text, input_cursor, sep_chars=' *?!._~-#$^;\'"/'): + """ + Remove text from input_text on ALT+backspace + Stop removing when countering any of the sep chars + """ + deleted = 0 + seg_start = input_text[:input_cursor] + seg_end = input_text[input_cursor:] + none_space_deleted = False # Track if any none-space characters have been deleted + + while seg_start and input_cursor > 0: + if (not seg_start) or (input_cursor == 0): + break + if deleted and seg_start[-1] in sep_chars: + if seg_start[-1] == ' ': + if seg_start[-2] == ' ' or none_space_deleted is False: + # Continue as long as: + # * next char is also a space + # * no none-space characters have been deleted + pass + else: + break + else: + break + + if not none_space_deleted: + none_space_deleted = seg_start[-1] != ' ' + seg_start = seg_start[:-1] + deleted += 1 + input_cursor -= 1 + + input_text = seg_start + seg_end + return input_text, input_cursor |