summaryrefslogtreecommitdiffstats
path: root/deluge/ui/console/utils
diff options
context:
space:
mode:
Diffstat (limited to 'deluge/ui/console/utils')
-rw-r--r--deluge/ui/console/utils/__init__.py0
-rw-r--r--deluge/ui/console/utils/colors.py326
-rw-r--r--deluge/ui/console/utils/column.py77
-rw-r--r--deluge/ui/console/utils/common.py23
-rw-r--r--deluge/ui/console/utils/curses_util.py65
-rw-r--r--deluge/ui/console/utils/format_utils.py353
6 files changed, 844 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..587c1f3
--- /dev/null
+++ b/deluge/ui/console/utils/colors.py
@@ -0,0 +1,326 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2009 Andrew Resch <andrewresch@gmail.com>
+#
+# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with
+# the additional special exception to link portions of this program with the OpenSSL library.
+# See LICENSE for more details.
+#
+
+from __future__ import unicode_literals
+
+import logging
+import 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 as ex:
+ log.warning('Error: %s', 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('Bad color value in tag: %s,%s' % (fg, bg))
+ # Check for additional attributes and OR them to the color_pair
+ color_pair = apply_attrs(color_pair, attrs)
+ last_color_attr = color_pair
+ # We need to find the text now, so lets try to find another {! and if
+ # there isn't one, then it's the rest of the string
+ next_begin = 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(object):
+ """
+ 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..d932159
--- /dev/null
+++ b/deluge/ui/console/utils/column.py
@@ -0,0 +1,77 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2011 Nick Lanham <nick@afternight.org>
+#
+# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with
+# the additional special exception to link portions of this program with the OpenSSL library.
+# See LICENSE for more details.
+#
+
+from __future__ import unicode_literals
+
+import 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..df1c079
--- /dev/null
+++ b/deluge/ui/console/utils/common.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with
+# the additional special exception to link portions of this program with the OpenSSL library.
+# See LICENSE for more details.
+#
+
+from __future__ import unicode_literals
+
+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/curses_util.py b/deluge/ui/console/utils/curses_util.py
new file mode 100644
index 0000000..a0cd6dc
--- /dev/null
+++ b/deluge/ui/console/utils/curses_util.py
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2016 bendikro <bro.devel+deluge@gmail.com>
+#
+# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with
+# the additional special exception to link portions of this program with the OpenSSL library.
+# See LICENSE for more details.
+#
+
+from __future__ import unicode_literals
+
+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(object):
+ INVISIBLE = 0
+ NORMAL = 1
+ VERY_VISIBLE = 2
+
+
+def safe_curs_set(visibility):
+ """
+ Args:
+ visibility(int): 0, 1, or 2, for invisible, normal, or very visible
+
+ curses.curs_set fails on monochrome terminals so use this
+ to ignore errors
+ """
+ try:
+ curses.curs_set(visibility)
+ except curses.error:
+ pass
+
+
+class ReadState(object):
+ IGNORED = 0
+ READ = 1
+ CHANGED = 2
diff --git a/deluge/ui/console/utils/format_utils.py b/deluge/ui/console/utils/format_utils.py
new file mode 100644
index 0000000..029fb20
--- /dev/null
+++ b/deluge/ui/console/utils/format_utils.py
@@ -0,0 +1,353 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2011 Nick Lanham <nick@afternight.org>
+#
+# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with
+# the additional special exception to link portions of this program with the OpenSSL library.
+# See LICENSE for more details.
+#
+
+from __future__ import unicode_literals
+
+import 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 '%s (%s)' % (
+ 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 '%s%s' % (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 = '%s%s%s' % (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(l, string, offset):
+ """Prepends indent to string if specified"""
+ if indent and offset != 0:
+ string = indent + string
+ l.append(string)
+
+ while cstr:
+ # max with for a line. If indent is specified, we account for this
+ max_width = width - (len(indent) if offset != 0 else 0)
+ if len(cstr) < max_width:
+ break
+ sidx = cstr.rfind(' ', 0, max_width - 1)
+ sidx += 1
+ if sidx > 0:
+ if strip_colors:
+ to_app = cstr[0:sidx]
+ to_app = insert_clr(to_app, offset, mtchs, clrs)
+ 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] = '%s%s' % (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 '%s%s' % (character * diff, string)
+ elif side == 'right':
+ return '%s%s' % (string, character * diff)
+
+
+def delete_alt_backspace(input_text, input_cursor, sep_chars=' *?!._~-#$^;\'"/'):
+ """
+ Remove text from input_text on ALT+backspace
+ Stop removing when countering any of the sep chars
+ """
+ deleted = 0
+ seg_start = input_text[:input_cursor]
+ seg_end = input_text[input_cursor:]
+ none_space_deleted = False # Track if any none-space characters have been deleted
+
+ while seg_start and input_cursor > 0:
+ if (not seg_start) or (input_cursor == 0):
+ break
+ if deleted and seg_start[-1] in sep_chars:
+ if seg_start[-1] == ' ':
+ if seg_start[-2] == ' ' or none_space_deleted is False:
+ # Continue as long as:
+ # * next char is also a space
+ # * no none-space characters have been deleted
+ pass
+ else:
+ break
+ else:
+ break
+
+ if not none_space_deleted:
+ none_space_deleted = seg_start[-1] != ' '
+ seg_start = seg_start[:-1]
+ deleted += 1
+ input_cursor -= 1
+
+ input_text = seg_start + seg_end
+ return input_text, input_cursor