Skip to content

Make TextWrapper ANSI-aware#3420

Open
kdeldycke wants to merge 1 commit into
pallets:mainfrom
kdeldycke:fix-wrap-text-ansi
Open

Make TextWrapper ANSI-aware#3420
kdeldycke wants to merge 1 commit into
pallets:mainfrom
kdeldycke:fix-wrap-text-ansi

Conversation

@kdeldycke
Copy link
Copy Markdown
Collaborator

@kdeldycke kdeldycke commented May 12, 2026

Bug description

click.formatting.wrap_text and the underlying click.formatting.TextWrapper use the Python standard library implementation inherited from textwrap.TextWrapper. But it is not aware of ANSI codes. Which are invisible to the user, but artificially inflate the line limit accounting used to decide when a long line of terminal text is going to be cut and put on a new line.

Solution

To fix this, I copied the textwrap.TextWrapper._wrap_chunks implementation from the standard library and replaced the used of raw len() by our own click._compat.term_len(), which strip ANSI before counting.

Patching it there allow to fix the shared behavior of wrap_text itself, but also HelpFormatter.write_usage, write_text, write_dl, HelpFormatter.write_text and write_paragraph.

Python's own _wrap_chunks is monolithic and too rigid, and couldn't find a clean way of patching it. So my last resort was to brutally copy it and change its code locally.

This is I think the right thing to do in principle, as Click as its own subclass for textwrap.TextWrapper, and already ships with the perfect click._compat.term_len() utility for this task.

Diff with Python's sydlib

For reference, here are the changes I applied compared to the inherited class:

--- cpython 3.14.3 Lib/textwrap.py TextWrapper._wrap_chunks
+++ click src/click/_textwrap.py TextWrapper._wrap_chunks
@@ -1,13 +1,13 @@
-def _wrap_chunks(self, chunks):
+def _wrap_chunks(self, chunks: list[str]) -> list[str]:
     lines = []
     if self.width <= 0:
-        raise ValueError("invalid width %r (must be > 0)" % self.width)
+        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 len(indent) + len(self.placeholder.lstrip()) > self.width:
+        if term_len(indent) + term_len(self.placeholder.lstrip()) > self.width:
             raise ValueError("placeholder too large for max width")

     chunks.reverse()
@@ -22,27 +22,27 @@
         else:
             indent = self.initial_indent

-        width = self.width - len(indent)
+        width = self.width - term_len(indent)

-        if self.drop_whitespace and chunks[-1].strip() == '' and lines:
+        if self.drop_whitespace and chunks[-1].strip() == "" and lines:
             del chunks[-1]

         while chunks:
-            l = len(chunks[-1])
+            n = term_len(chunks[-1])

-            if cur_len + l <= width:
+            if cur_len + n <= width:
                 cur_line.append(chunks.pop())
-                cur_len += l
+                cur_len += n

             else:
                 break

-        if chunks and len(chunks[-1]) > width:
+        if chunks and term_len(chunks[-1]) > width:
             self._handle_long_word(chunks, cur_line, cur_len, width)
-            cur_len = sum(map(len, cur_line))
+            cur_len = sum(map(term_len, cur_line))

-        if self.drop_whitespace and cur_line and cur_line[-1].strip() == '':
-            cur_len -= len(cur_line[-1])
+        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:
@@ -52,20 +52,20 @@
                  self.drop_whitespace and
                  len(chunks) == 1 and
                  not chunks[0].strip()) and cur_len <= width):
-                lines.append(indent + ''.join(cur_line))
+                lines.append(indent + "".join(cur_line))
             else:
                 while cur_line:
                     if (cur_line[-1].strip() and
-                        cur_len + len(self.placeholder) <= width):
+                        cur_len + term_len(self.placeholder) <= width):
                         cur_line.append(self.placeholder)
-                        lines.append(indent + ''.join(cur_line))
+                        lines.append(indent + "".join(cur_line))
                         break
-                    cur_len -= len(cur_line[-1])
+                    cur_len -= term_len(cur_line[-1])
                     del cur_line[-1]
                 else:
                     if lines:
                         prev_line = lines[-1].rstrip()
-                        if (len(prev_line) + len(self.placeholder) <=
+                        if (term_len(prev_line) + term_len(self.placeholder) <=
                                 self.width):
                             lines[-1] = prev_line + self.placeholder
                             break

Note that this is a minimal diff, not the exact code I added in that PR, as Click's linter and type checking impose some alternative formatting.

Context

I uncovered this issue while implementing themes in Click Extra. I had to implement a workaround there: https://github.com/kdeldycke/click-extra/blob/430ccb277bbf5e6f000b9b82d22c03db2b673318/click_extra/colorize.py#L528-L556

@kdeldycke kdeldycke marked this pull request as draft May 12, 2026 15:06
@kdeldycke kdeldycke added bug f:help feature: help text labels May 12, 2026
@kdeldycke kdeldycke added this to the 8.4.0 milestone May 12, 2026
@kdeldycke kdeldycke requested review from Rowlando13 and davidism May 12, 2026 15:46
@kdeldycke kdeldycke marked this pull request as ready for review May 12, 2026 15:46
@kdeldycke kdeldycke force-pushed the fix-wrap-text-ansi branch from 5aa9cf0 to d946074 Compare May 12, 2026 15:51
kdeldycke added a commit to kdeldycke/click-extra that referenced this pull request May 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug f:help feature: help text

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant