| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188 |
- 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)
|