File size: 6,270 Bytes
22c0749 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 | from __future__ import annotations
import collections.abc as cabc
import textwrap
from contextlib import contextmanager
from ._compat import _ansi_re
from ._compat import term_len
def _truncate_visible(text: str, n: int) -> str:
"""Return the longest prefix of ``text`` containing at most ``n`` visible
characters.
ANSI escape sequences inside the prefix are kept intact and do not count
toward the visible width. A cut is never placed inside an escape sequence.
"""
if n <= 0:
return ""
visible = 0
i = 0
cut = 0
end = len(text)
while i < end:
m = _ansi_re.match(text, i)
if m is not None:
i = m.end()
continue
visible += 1
i += 1
cut = i
if visible >= n:
break
return text[:cut]
class TextWrapper(textwrap.TextWrapper):
"""``textwrap.TextWrapper`` variant that measures widths by visible
character count.
ANSI escape sequences embedded in chunks, indents, or the placeholder are
excluded from the width budget. Without this, styled help text (a styled
``Usage:`` prefix, a colorized option name, ...) would be wrapped earlier
than its visible length warrants and tokens would split mid-word.
"""
def _handle_long_word(
self,
reversed_chunks: list[str],
cur_line: list[str],
cur_len: int,
width: int,
) -> None:
space_left = max(width - cur_len, 1)
if self.break_long_words:
last = reversed_chunks[-1]
cut = _truncate_visible(last, space_left)
res = last[len(cut) :]
cur_line.append(cut)
reversed_chunks[-1] = res
elif not cur_line:
cur_line.append(reversed_chunks.pop())
def _wrap_chunks(self, chunks: list[str]) -> list[str]:
"""Wrap chunks counting widths in visible characters.
Mirrors the algorithm of :meth:`textwrap.TextWrapper._wrap_chunks`
with every width measurement routed through
:func:`click._compat.term_len` instead of :func:`len`, so ANSI escape
bytes in chunks, indents, or the placeholder do not inflate the count.
.. seealso::
:class:`textwrap.TextWrapper` in the Python standard library documentation:
https://docs.python.org/3/library/textwrap.html#textwrap.TextWrapper
Reference implementation in CPython:
https://github.com/python/cpython/blob/main/Lib/textwrap.py
"""
lines: list[str] = []
if self.width <= 0:
raise ValueError(f"invalid width {self.width!r} (must be > 0)")
if self.max_lines is not None:
if self.max_lines > 1:
indent = self.subsequent_indent
else:
indent = self.initial_indent
if term_len(indent) + term_len(self.placeholder.lstrip()) > self.width:
raise ValueError("placeholder too large for max width")
chunks.reverse()
while chunks:
cur_line: list[str] = []
cur_len = 0
if lines:
indent = self.subsequent_indent
else:
indent = self.initial_indent
width = self.width - term_len(indent)
if self.drop_whitespace and chunks[-1].strip() == "" and lines:
del chunks[-1]
while chunks:
n = term_len(chunks[-1])
if cur_len + n <= width:
cur_line.append(chunks.pop())
cur_len += n
else:
break
if chunks and term_len(chunks[-1]) > width:
self._handle_long_word(chunks, cur_line, cur_len, width)
cur_len = sum(map(term_len, cur_line))
if self.drop_whitespace and cur_line and cur_line[-1].strip() == "":
cur_len -= term_len(cur_line[-1])
del cur_line[-1]
if cur_line:
if (
self.max_lines is None
or len(lines) + 1 < self.max_lines
or (
not chunks
or self.drop_whitespace
and len(chunks) == 1
and not chunks[0].strip()
)
and cur_len <= width
):
lines.append(indent + "".join(cur_line))
else:
while cur_line:
if (
cur_line[-1].strip()
and cur_len + term_len(self.placeholder) <= width
):
cur_line.append(self.placeholder)
lines.append(indent + "".join(cur_line))
break
cur_len -= term_len(cur_line[-1])
del cur_line[-1]
else:
if lines:
prev_line = lines[-1].rstrip()
if (
term_len(prev_line) + term_len(self.placeholder)
<= self.width
):
lines[-1] = prev_line + self.placeholder
break
lines.append(indent + self.placeholder.lstrip())
break
return lines
@contextmanager
def extra_indent(self, indent: str) -> cabc.Iterator[None]:
old_initial_indent = self.initial_indent
old_subsequent_indent = self.subsequent_indent
self.initial_indent += indent
self.subsequent_indent += indent
try:
yield
finally:
self.initial_indent = old_initial_indent
self.subsequent_indent = old_subsequent_indent
def indent_only(self, text: str) -> str:
rv = []
for idx, line in enumerate(text.splitlines()):
indent = self.initial_indent
if idx > 0:
indent = self.subsequent_indent
rv.append(f"{indent}{line}")
return "\n".join(rv)
|