diff --git a/kitty/tab_bar.py b/kitty/tab_bar.py index 1b79860e9..acb41f472 100644 --- a/kitty/tab_bar.py +++ b/kitty/tab_bar.py @@ -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)) diff --git a/kitty_tests/tab_bar.py b/kitty_tests/tab_bar.py index e0a937248..a1c78819b 100644 --- a/kitty_tests/tab_bar.py +++ b/kitty_tests/tab_bar.py @@ -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'):