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