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)