Fix quadratic algorithms in previous PR

This commit is contained in:
Kovid Goyal 2026-08-02 21:14:03 +05:30
parent fe81090243
commit 70bdd4e96e
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
2 changed files with 68 additions and 18 deletions

View file

@ -373,12 +373,12 @@ def apply_title_template(draw_data: DrawData, tab: TabBarData, index: int, max_t
def wrap_title(title: str, width: int) -> str:
'''Insert newlines into title so that no line is wider than width cells.
"""Insert newlines into title so that no line is wider than width cells.
SGR escapes are zero width, so they never count towards the width and are
left attached to the text that follows them. Splitting happens on grapheme
boundaries so that a wrap can never land inside a multi-codepoint grapheme.
'''
"""
if width < 1:
return title
lines = []
@ -390,23 +390,42 @@ def wrap_title(title: str, width: int) -> str:
if part.startswith('\x1b'):
cur += part
continue
for grapheme in split_into_graphemes(part):
w = max(0, wcswidth(grapheme))
if cur_width and cur_width + w > width:
lines.append(cur)
cur, cur_width = '', 0
cur += grapheme
cur_width += w
part_width = max(0, wcswidth(part))
if cur_width + part_width <= width:
cur += part
cur_width += part_width
else:
ginfo = tuple((max(0, wcswidth(g)), len(g)) for g in split_into_graphemes(part))
n = len(ginfo)
i = 0
pos = 0
while i < n:
j, batch_w, batch_len = i, 0, 0
while j < n:
w, glen = ginfo[j]
if cur_width + batch_w and cur_width + batch_w + w > width:
break
batch_w += w
batch_len += glen
j += 1
if j > i:
cur += part[pos : pos + batch_len]
cur_width += batch_w
pos += batch_len
i = j
if i < n:
lines.append(cur)
cur, cur_width = '', 0
lines.append(cur)
return '\n'.join(lines)
def truncate_line(line: str, width: int) -> str:
'''Append an ellipsis to line, trimming it so the result fits in width cells.
"""Append an ellipsis to line, trimming it so the result fits in width cells.
Used to mark a title as having had content dropped, so the ellipsis is added
even when line already fits.
'''
"""
if width < 1:
return line
out, cur_width = '', 0
@ -416,12 +435,17 @@ def truncate_line(line: str, width: int) -> str:
if part.startswith('\x1b'):
out += part
continue
for grapheme in split_into_graphemes(part):
w = max(0, wcswidth(grapheme))
if cur_width + w > width - 1:
return out + ''
out += grapheme
cur_width += w
part_width = max(0, wcswidth(part))
if cur_width + part_width <= width - 1:
out += part
cur_width += part_width
else:
for grapheme in split_into_graphemes(part):
w = max(0, wcswidth(grapheme))
if cur_width + w > width - 1:
return out + ''
out += grapheme
cur_width += w
return out + ''
@ -1019,7 +1043,7 @@ class TabBar:
wrap_width = max_tab_length if self.tab_title_wrap < 0 else min(self.tab_title_wrap, max_tab_length)
def draw_one(i: int, t: TabBarData, row: int, for_layout: bool, height: int = 0) -> int:
'Draw tab i at row, returning the number of lines it occupies'
"Draw tab i at row, returning the number of lines it occupies"
bg = as_rgb(self.draw_data.tab_bg(t))
s.cursor.bg = bg
s.cursor.fg = as_rgb(self.draw_data.tab_fg(t))

View file

@ -238,6 +238,20 @@ class TestTabBar(BaseTest):
self.ae(wrap_title('日本語', 4).split('\n'), ['日本', ''])
# a width of zero means no wrapping
self.ae(wrap_title('abc', 0), 'abc')
# fast path: a part that fits entirely is taken without grapheme splitting
self.ae(wrap_title('abc def', 10).split('\n'), ['abc def'])
# fast path: part fits exactly at the boundary
self.ae(wrap_title('abcd', 4).split('\n'), ['abcd'])
# fast path breaks down correctly when the second part overflows
self.ae(wrap_title('abc defgh', 6).split('\n'), ['abc de', 'fgh'])
# SGR escape followed by text that fits entirely (fast path with leading escape)
self.ae(wrap_title('\x1b[1mhello', 10).split('\n'), ['\x1b[1mhello'])
# mixed: first part fits (fast path), second part must be split
self.ae(wrap_title('\x1b[1mab\x1b[0m cdefgh', 4).split('\n'), ['\x1b[1mab\x1b[0m c', 'defg', 'h'])
# wide chars: part fits entirely (fast path)
self.ae(wrap_title('日本', 4).split('\n'), ['日本'])
# wide chars: part fits on new line after breaking
self.ae(wrap_title('ab日本語', 4).split('\n'), ['ab日', '本語'])
def test_truncate_line(self) -> None:
self.ae(truncate_line('abcdef', 4), 'abc…')
@ -246,6 +260,18 @@ class TestTabBar(BaseTest):
# itself would have fit
self.ae(truncate_line('ab', 4), 'ab…')
self.ae(truncate_line('abcd', 4), 'abc…')
# fast path: entire part fits within width-1 cells, ellipsis appended
self.ae(truncate_line('abc', 6), 'abc…')
# fast path: part exactly fills width-1 cells
self.ae(truncate_line('abcde', 6), 'abcde…')
# fast path taken for first part, overflow detected in second part
self.ae(truncate_line('abc' + 'xyz', 5), 'abcx…')
# SGR escapes are zero width, so they never consume cells (fast path still taken)
self.ae(truncate_line('\x1b[1mabc\x1b[0m', 6), '\x1b[1mabc\x1b[0m…')
# wide chars: fast path fits, ellipsis appended
self.ae(truncate_line('日本', 6), '日本…')
# empty line still gets ellipsis
self.ae(truncate_line('', 4), '')
def test_tab_title_wrap_option(self) -> None:
for off in ('no', 'No', 'n', 'false', 'none', '0'):