Files

189 lines
6.1 KiB
Python

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)