From 3138ae4aadbf8d235956148d12ec8002cdbdae58 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 26 May 2026 07:55:16 -0400 Subject: [PATCH] fix(screen): preserve tab stops across window resizes Previously, every window resize reinitialised the tab stops to the default of every 8 columns, discarding any stops set via HTS or cleared via TBC. ECMA-48 only treats RIS, DECSTR, and DECCOLM as events that reset tab stops, and other terminal emulators all preserve user-set stops across an interactive resize. Copy the surviving prefix of the previous tab stops into the freshly allocated array on both main and alt screens. Newly added columns when growing the window keep the default every 8 columns pattern. Also point the active tabstops pointer at the alt screen's array when a resize happens while the alt screen is active, instead of unconditionally resetting it to the main screen's array. Signed-off-by: Ayman Bagabas --- docs/changelog.rst | 2 ++ kitty/screen.c | 24 +++++++++++++++++------- kitty_tests/screen.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 77a71dd6d..174da49ce 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -176,6 +176,8 @@ Detailed list of changes 0.47.1 [future] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- Preserve user-set tab stops across window resizes (previously they were reset to every 8 columns on every resize) + - Fix a regression in the previous release that caused :ac:`copy_or_noop` to stop working correctly (:pull:`10041`) - macOS: Fix a regression in the previous release that caused URLs to be quoted when dropping into shells (:iss:`10054`) diff --git a/kitty/screen.c b/kitty/screen.c index 34e016194..4f84d3df5 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -575,16 +575,26 @@ screen_resize(Screen *self, unsigned int lines, unsigned int columns) { grman_resize(self->alt_grman, self->lines, lines, self->columns, columns, num_content_lines_before, num_content_lines_after); #undef setup_cursor /* printf("\nold_size: (%u, %u) new_size: (%u, %u)\n", self->columns, self->lines, columns, lines); */ + index_type old_columns_for_tabs = self->columns; self->lines = lines; self->columns = columns; self->margin_top = 0; self->margin_bottom = self->lines - 1; - PyMem_Free(self->main_tabstops); - self->main_tabstops = PyMem_Calloc(2*self->columns, sizeof(bool)); - if (self->main_tabstops == NULL) { PyErr_NoMemory(); return false; } - self->alt_tabstops = self->main_tabstops + self->columns; - self->tabstops = self->main_tabstops; - init_tabstops(self->main_tabstops, self->columns); - init_tabstops(self->alt_tabstops, self->columns); + bool *old_tabstops = self->main_tabstops; + bool *new_tabstops = PyMem_Calloc(2 * self->columns, sizeof(bool)); + if (new_tabstops == NULL) { PyErr_NoMemory(); return false; } + bool *new_main = new_tabstops; + bool *new_alt = new_tabstops + self->columns; + init_tabstops(new_main, self->columns); + init_tabstops(new_alt, self->columns); + if (old_tabstops && old_columns_for_tabs) { + index_type to_copy = MIN(old_columns_for_tabs, self->columns); + memcpy(new_main, old_tabstops, to_copy * sizeof(bool)); + memcpy(new_alt, old_tabstops + old_columns_for_tabs, to_copy * sizeof(bool)); + } + PyMem_Free(old_tabstops); + self->main_tabstops = new_main; + self->alt_tabstops = new_alt; + self->tabstops = is_main ? self->main_tabstops : self->alt_tabstops; self->is_dirty = true; clear_all_selections(self); self->last_visited_prompt.is_set = false; diff --git a/kitty_tests/screen.py b/kitty_tests/screen.py index b58c5d9a3..38d265147 100644 --- a/kitty_tests/screen.py +++ b/kitty_tests/screen.py @@ -531,6 +531,34 @@ class TestScreen(BaseTest): s.draw('aaaX\tbbbb') self.ae(str(s.line(0)) + str(s.line(1)), 'aaaXbbbb') + # Custom tab stops survive a window resize + s = self.create_screen(cols=20, lines=2) + s.clear_tab_stop(3) # clear all + s.cursor_position(1, 5) + s.set_tab_stop() # stop at column index 4 + s.cursor_position(1, 13) + s.set_tab_stop() # stop at column index 12 + # Grow: existing stops preserved, new columns get default every-8 stops + s.resize(s.lines, 30) + s.cursor_position(1, 1) + s.tab(); self.ae(s.cursor.x, 4) + s.tab(); self.ae(s.cursor.x, 12) + s.tab(); self.ae(s.cursor.x, 24) # default stop in newly added columns + # Shrink: stops within new width are preserved + s.resize(s.lines, 15) + s.cursor_position(1, 1) + s.tab(); self.ae(s.cursor.x, 4) + s.tab(); self.ae(s.cursor.x, 12) + # Resize on alt screen also preserves alt-screen tab stops + s = self.create_screen(cols=20, lines=2) + parse_bytes(s, b'\x1b[?1049h') # switch to alt screen + s.clear_tab_stop(3) + s.cursor_position(1, 5) + s.set_tab_stop() + s.resize(s.lines, 30) + s.cursor_position(1, 1) + s.tab(); self.ae(s.cursor.x, 4) + def test_backspace(self): s = self.create_screen() q = 'a'*s.columns