diff --git a/docs/changelog.rst b/docs/changelog.rst index b18ef82b2..b0a6cb318 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -234,6 +234,8 @@ Detailed list of changes - macOS: Disable macOS one time code autofill popups (:pull:`10250`) +- Add support for DECSTR soft screen reset escape code (:iss:`10263`) + 0.47.4 [2026-06-15] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/kitty/screen.c b/kitty/screen.c index cf5fff944..ae6e041d3 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -173,13 +173,13 @@ new_screen_object(PyTypeObject *type, PyObject *args, PyObject UNUSED *kwds) { static Line* range_line_(Screen *self, int y); -void -screen_reset(Screen *self) { +static void +do_screen_reset(Screen *self, bool is_hard_reset) { screen_pause_rendering(self, false, 0); self->dnd_chunking.active = false; self->extra_cursors.count = 0; zero_at_ptr(&self->extra_cursors.color); self->extra_cursors.dirty = true; self->main_pointer_shape_stack.count = 0; self->alternate_pointer_shape_stack.count = 0; - if (self->linebuf == self->alt_linebuf) screen_toggle_screen_buffer(self, true, true); + if (is_hard_reset && self->linebuf == self->alt_linebuf) screen_toggle_screen_buffer(self, true, true); if (screen_is_overlay_active(self)) { deactivate_overlay_line(self); // Cancel IME composition @@ -197,11 +197,13 @@ screen_reset(Screen *self) { self->last_graphic_char = 0; self->main_savepoint.is_valid = false; self->alt_savepoint.is_valid = false; - linebuf_clear(self->linebuf, BLANK_CHAR); - historybuf_clear(self->historybuf); - clear_hyperlink_pool(self->hyperlink_pool); - grman_clear(self->main_grman, false, self->cell_size); // dont delete images in scrollback - grman_clear(self->alt_grman, true, self->cell_size); + if (is_hard_reset) { + linebuf_clear(self->linebuf, BLANK_CHAR); + historybuf_clear(self->historybuf); + clear_hyperlink_pool(self->hyperlink_pool); + grman_clear(self->main_grman, false, self->cell_size); // dont delete images in scrollback + grman_clear(self->alt_grman, true, self->cell_size); + } self->modes = empty_modes; self->saved_modes = empty_modes; self->active_hyperlink_id = 0; @@ -218,7 +220,17 @@ screen_reset(Screen *self) { screen_cursor_position(self, 1, 1); set_dynamic_color(self, 111, NULL); // does default_bg_changed processing colorprofile_reset(self->color_profile); - CALLBACK("on_reset", NULL) + CALLBACK("on_reset", "O", is_hard_reset ? Py_True : Py_False); +} + +void +screen_reset(Screen *self) { do_screen_reset(self, true); } + +void +screen_soft_reset(Screen *self) { + index_type x = self->cursor->x, y = self->cursor->y; + do_screen_reset(self, false); + self->cursor->x = x; self->cursor->y = y; } void diff --git a/kitty/screen.h b/kitty/screen.h index 964fabfc5..cddfcbb67 100644 --- a/kitty/screen.h +++ b/kitty/screen.h @@ -242,6 +242,7 @@ void screen_scroll(Screen *self, unsigned int count); void screen_reverse_scroll(Screen *self, unsigned int count); void screen_reverse_scroll_and_fill_from_scrollback(Screen *self, unsigned int count); void screen_reset(Screen *self); +void screen_soft_reset(Screen *self); void screen_set_tab_stop(Screen *self); void screen_tab(Screen *self); void screen_backtab(Screen *self, unsigned int); diff --git a/kitty/vt-parser.c b/kitty/vt-parser.c index 012b8f6f2..6f7fe49bc 100644 --- a/kitty/vt-parser.c +++ b/kitty/vt-parser.c @@ -1380,11 +1380,19 @@ dispatch_csi(PS *self) { } break; case DECSTR: - if (end_modifier == '$') { - // DECRQM - CALL_CSI_HANDLER1P(report_mode_status, 0, '?'); - } else { - REPORT_ERROR("Unknown DECSTR CSI sequence with start and end modifiers: '%c' '%c'", start_modifier, end_modifier); + switch (end_modifier) { + case '$': // DECRQM + CALL_CSI_HANDLER1P(report_mode_status, 0, '?'); break; + case '!': // Soft reset + if (num_params) { + REPORT_ERROR("DECSTR escape code with parameters is invalid"); + } else { + REPORT_COMMAND(screen_soft_reset); + screen_soft_reset(self->screen); + } + break; + default: + REPORT_ERROR("Unknown CSI p sequence with start and end modifiers: '%c' '%c'", start_modifier, end_modifier); break; } break; case 'm': diff --git a/kitty/window.py b/kitty/window.py index 794d3bb7d..ff47fc309 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -1499,7 +1499,7 @@ class Window: b |= b << 8 self.screen.send_escape_code_to_child(ESC_OSC, f'{code};rgb:{r:04x}/{g:04x}/{b:04x}') - def on_reset(self) -> None: + def on_reset(self, is_hard_reset: bool = True) -> None: from .progress import ProgressState if self.progress.state is not ProgressState.unset: self.progress.update(0) # unset diff --git a/kitty_tests/__init__.py b/kitty_tests/__init__.py index 8c2c4a602..9da10a04e 100644 --- a/kitty_tests/__init__.py +++ b/kitty_tests/__init__.py @@ -60,7 +60,7 @@ class Callbacks: def notify_child_of_resize(self): self.num_of_resize_events += 1 - def on_reset(self) -> None: + def on_reset(self, is_hard_reset: bool = True) -> None: if self.pty is not None: self.pty.reset_termios_state() diff --git a/kitty_tests/screen.py b/kitty_tests/screen.py index f3d8f9647..c54344865 100644 --- a/kitty_tests/screen.py +++ b/kitty_tests/screen.py @@ -1864,6 +1864,57 @@ class TestScreen(BaseTest): sc(2, 1, 2, 3, slot=slot) sc(5, 13, slot=slot) + def test_soft_reset(self): + SOFT_RESET = b'\x1b[!p' # DECSTR sequence + + # Screen content is preserved (unlike hard reset) + s = self.create_screen() + s.draw('hello') + parse_bytes(s, SOFT_RESET) + self.ae(str(s.line(0)), 'hello') + + # Hard reset clears screen content; soft reset does not + s = self.create_screen() + s.draw('hello') + s.reset() + self.ae(str(s.line(0)), '') + + # Cursor SGR attributes are cleared + s = self.create_screen() + s.select_graphic_rendition(1) # bold + s.select_graphic_rendition(31) # red fg + self.assertTrue(s.cursor.bold) + self.assertNotEqual(s.cursor.fg, 0) + parse_bytes(s, SOFT_RESET) + self.assertFalse(s.cursor.bold) + self.ae(s.cursor.fg, 0) + + # Cursor position is preserved + s = self.create_screen() + s.cursor_position(3, 4) + self.ae((s.cursor.y, s.cursor.x), (2, 3)) + parse_bytes(s, SOFT_RESET) + self.ae((s.cursor.y, s.cursor.x), (2, 3)) + + # Insert mode (IRM) is cleared + s = self.create_screen() + s.draw('abcde') + s.set_mode(IRM) + parse_bytes(s, SOFT_RESET) + # without IRM, drawing overwrites + s.cursor.x = 0 + s.draw('X') + self.ae(str(s.line(0)), 'Xbcde') + + # Alternate screen is NOT exited on soft reset + s = self.create_screen() + parse_bytes(s, b'\x1b[?1049h') # enter alternate screen + self.assertFalse(s.is_main_linebuf()) + parse_bytes(s, SOFT_RESET) + self.assertFalse(s.is_main_linebuf()) + s.reset() + self.assertTrue(s.is_main_linebuf()) + def detect_url(self, scale=1): s = self.create_screen(cols=30 * scale)