@@ -61,6 +61,11 @@ class TextWrapper:
6161 Truncate wrapped lines.
6262 placeholder (default: ' [...]')
6363 Append to the last line of truncated text.
64+ text_len (default: len)
65+ Callable returning the visible width of a string. Override the
66+ default to account for characters that are not one column wide,
67+ such as zero-width or double-width characters, or invisible ANSI
68+ escape sequences. It should return a non-negative integer.
6469 """
6570
6671 unicode_whitespace_trans = dict .fromkeys (map (ord , _whitespace ), ord (' ' ))
@@ -122,7 +127,8 @@ def __init__(self,
122127 tabsize = 8 ,
123128 * ,
124129 max_lines = None ,
125- placeholder = ' [...]' ):
130+ placeholder = ' [...]' ,
131+ text_len = len ):
126132 self .width = width
127133 self .initial_indent = initial_indent
128134 self .subsequent_indent = subsequent_indent
@@ -135,6 +141,7 @@ def __init__(self,
135141 self .tabsize = tabsize
136142 self .max_lines = max_lines
137143 self .placeholder = placeholder
144+ self .text_len = text_len
138145
139146
140147 # -- Private methods -----------------------------------------------
@@ -194,6 +201,28 @@ def _fix_sentence_endings(self, chunks):
194201 else :
195202 i += 1
196203
204+ def _truncate_to_width (self , text , width ):
205+ """_truncate_to_width(text : string, width : int) -> string
206+
207+ Return the longest prefix of *text* whose visible width, as measured
208+ by ``self.text_len``, does not exceed *width*. With a custom text_len the
209+ number of characters that fit need not equal *width*, so an over-long
210+ word cannot be broken by slicing at the column count. At least one
211+ character is always kept so that wrapping makes progress.
212+ """
213+ # Fast path for the default len(): the width is the number of
214+ # characters, so the prefix can be sliced directly.
215+ if self .text_len is len :
216+ return text [: max (width , 1 )]
217+ if self .text_len (text ) <= width :
218+ return text
219+ cut = 1
220+ for i in range (1 , len (text ) + 1 ):
221+ if self .text_len (text [:i ]) > width :
222+ break
223+ cut = i
224+ return text [:cut ]
225+
197226 def _handle_long_word (self , reversed_chunks , cur_line , cur_len , width ):
198227 """_handle_long_word(chunks : [string],
199228 cur_line : [string],
@@ -212,9 +241,10 @@ def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width):
212241 # If we're allowed to break long words, then do so: put as much
213242 # of the next chunk onto the current line as will fit.
214243 if self .break_long_words and space_left > 0 :
215- end = space_left
216244 chunk = reversed_chunks [- 1 ]
217- if self .break_on_hyphens and len (chunk ) > space_left :
245+ # Keep as many leading characters as fit in the visible width.
246+ end = len (self ._truncate_to_width (chunk , space_left ))
247+ if self .break_on_hyphens and self .text_len (chunk ) > space_left :
218248 # break after last hyphen, but only if there are
219249 # non-hyphens before it
220250 hyphen = chunk .rfind ('-' , 0 , space_left )
@@ -256,7 +286,10 @@ def _wrap_chunks(self, chunks):
256286 indent = self .subsequent_indent
257287 else :
258288 indent = self .initial_indent
259- if len (indent ) + len (self .placeholder .lstrip ()) > self .width :
289+ if (
290+ self .text_len (indent ) + self .text_len (self .placeholder .lstrip ())
291+ > self .width
292+ ):
260293 raise ValueError ("placeholder too large for max width" )
261294
262295 # Arrange in reverse order so items can be efficiently popped
@@ -277,15 +310,15 @@ def _wrap_chunks(self, chunks):
277310 indent = self .initial_indent
278311
279312 # Maximum width for this line.
280- width = self .width - len (indent )
313+ width = self .width - self . text_len (indent )
281314
282315 # First chunk on line is whitespace -- drop it, unless this
283316 # is the very beginning of the text (ie. no lines started yet).
284317 if self .drop_whitespace and chunks [- 1 ].strip () == '' and lines :
285318 del chunks [- 1 ]
286319
287320 while chunks :
288- l = len (chunks [- 1 ])
321+ l = self . text_len (chunks [- 1 ])
289322
290323 # Can at least squeeze this chunk onto the current line.
291324 if cur_len + l <= width :
@@ -298,13 +331,13 @@ def _wrap_chunks(self, chunks):
298331
299332 # The current line is full, and the next chunk is too big to
300333 # fit on *any* line (not just this one).
301- if chunks and len (chunks [- 1 ]) > width :
334+ if chunks and self . text_len (chunks [- 1 ]) > width :
302335 self ._handle_long_word (chunks , cur_line , cur_len , width )
303- cur_len = sum (map (len , cur_line ))
336+ cur_len = sum (map (self . text_len , cur_line ))
304337
305338 # If the last chunk on this line is all whitespace, drop it.
306339 if self .drop_whitespace and cur_line and cur_line [- 1 ].strip () == '' :
307- cur_len -= len (cur_line [- 1 ])
340+ cur_len -= self . text_len (cur_line [- 1 ])
308341 del cur_line [- 1 ]
309342
310343 if cur_line :
@@ -320,17 +353,20 @@ def _wrap_chunks(self, chunks):
320353 else :
321354 while cur_line :
322355 if (cur_line [- 1 ].strip () and
323- cur_len + len (self .placeholder ) <= width ):
356+ cur_len + self . text_len (self .placeholder ) <= width ):
324357 cur_line .append (self .placeholder )
325358 lines .append (indent + '' .join (cur_line ))
326359 break
327- cur_len -= len (cur_line [- 1 ])
360+ cur_len -= self . text_len (cur_line [- 1 ])
328361 del cur_line [- 1 ]
329362 else :
330363 if lines :
331364 prev_line = lines [- 1 ].rstrip ()
332- if (len (prev_line ) + len (self .placeholder ) <=
333- self .width ):
365+ if (
366+ self .text_len (prev_line )
367+ + self .text_len (self .placeholder )
368+ <= self .width
369+ ):
334370 lines [- 1 ] = prev_line + self .placeholder
335371 break
336372 lines .append (indent + self .placeholder .lstrip ())
0 commit comments