diff --git a/docs/changelog.rst b/docs/changelog.rst index 9dfcb6ac6..3ae782a7f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -93,6 +93,9 @@ Detailed list of changes - A new action to clear the screen up to the line containing the cursor, see :ac:`clear_terminal` +- A new action :ac:`copy_ansi_to_clipboard` to copy the current selection with ANSI formatting codes + (:iss:`4665`) + - macOS: Fix a regression in the previous release that broke switching input sources by keyboard (:iss:`4621`) diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index ca72397f4..c6e48b885 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -1085,7 +1085,7 @@ class Screen: def has_selection(self) -> bool: pass - def text_for_selection(self) -> Tuple[str, ...]: + def text_for_selection(self, ansi: bool, strip_trailing_spaces: bool) -> Tuple[str, ...]: pass def is_rectangle_select(self) -> bool: diff --git a/kitty/rc/get_text.py b/kitty/rc/get_text.py index 44bef085e..47dddb24a 100644 --- a/kitty/rc/get_text.py +++ b/kitty/rc/get_text.py @@ -43,8 +43,7 @@ requires :ref:`shell_integration` to be enabled. --ansi type=bool-set By default, only plain text is returned. If you specify this flag, the text will -include the formatting escape codes for colors/bold/italic/etc. Note that when -getting the current selection, the result is always plain text. +include the formatting escape codes for colors/bold/italic/etc. --add-cursor @@ -83,7 +82,7 @@ Clear the selection in the matched window, if any from kitty.window import CommandOutput window = self.windows_for_match_payload(boss, window, payload_get)[0] if payload_get('extent') == 'selection': - ans = window.text_for_selection() + ans = window.text_for_selection(as_ansi=payload_get('ansi')) elif payload_get('extent') == 'first_cmd_output_on_screen': ans = window.cmd_output( CommandOutput.first_on_screen, diff --git a/kitty/screen.c b/kitty/screen.c index bab959ce5..f9b5411d6 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -2403,8 +2403,25 @@ screen_apply_selection(Screen *self, void *address, size_t size) { self->url_ranges.last_rendered_count = self->url_ranges.count; } +static index_type +limit_without_trailing_whitespace(const Line *line, index_type limit) { + if (!limit) return limit; + if (limit > line->xnum) limit = line->xnum; + while (limit > 0) { + CPUCell *cell = line->cpu_cells + limit - 1; + if (cell->cc_idx[0]) break; + switch(cell->ch) { + case ' ': case '\t': case '\n': case '\r': case 0: break; + default: + return limit; + } + limit--; + } + return limit; +} + static PyObject* -text_for_range(Screen *self, const Selection *sel, bool insert_newlines) { +text_for_range(Screen *self, const Selection *sel, bool insert_newlines, bool strip_trailing_whitespace) { IterationData idata; iteration_data(self, sel, &idata, -self->historybuf->count, false); int limit = MIN((int)self->lines, idata.y_limit); @@ -2414,13 +2431,68 @@ text_for_range(Screen *self, const Selection *sel, bool insert_newlines) { Line *line = range_line_(self, y); XRange xr = xrange_for_iteration(&idata, y, line); char leading_char = (i > 0 && insert_newlines && !line->attrs.continued) ? '\n' : 0; - PyObject *text = unicode_in_range(line, xr.x, xr.x_limit, true, leading_char, false); + index_type x_limit = xr.x_limit; + if (strip_trailing_whitespace) { + index_type new_limit = limit_without_trailing_whitespace(line, x_limit); + if (new_limit != x_limit) { + x_limit = new_limit; + if (!x_limit) { + PyObject *text = PyUnicode_FromString("\n"); + if (text == NULL) { Py_DECREF(ans); return PyErr_NoMemory(); } + PyTuple_SET_ITEM(ans, i, text); + continue; + } + } + } + PyObject *text = unicode_in_range(line, xr.x, x_limit, true, leading_char, false); if (text == NULL) { Py_DECREF(ans); return PyErr_NoMemory(); } PyTuple_SET_ITEM(ans, i, text); } return ans; } +static PyObject* +ansi_for_range(Screen *self, const Selection *sel, bool insert_newlines, bool strip_trailing_whitespace) { + IterationData idata; + iteration_data(self, sel, &idata, -self->historybuf->count, false); + int limit = MIN((int)self->lines, idata.y_limit); + DECREF_AFTER_FUNCTION PyObject *ans = PyTuple_New(limit - idata.y + 1); + DECREF_AFTER_FUNCTION PyObject *nl = PyUnicode_FromString("\n"); + if (!ans || !nl) return NULL; + ANSIBuf output = {0}; + const GPUCell *prev_cell = NULL; + for (int i = 0, y = idata.y; y < limit; y++, i++) { + Line *line = range_line_(self, y); + XRange xr = xrange_for_iteration(&idata, y, line); + output.len = 0; + if (i > 0 && insert_newlines && !line->attrs.continued) { + ensure_space_for(&output, buf, Py_UCS4, output.len + 1, capacity, 2048, false); + output.buf[output.len++] = '\n'; + } + index_type x_limit = xr.x_limit; + if (strip_trailing_whitespace) { + index_type new_limit = limit_without_trailing_whitespace(line, x_limit); + if (new_limit != x_limit) { + x_limit = new_limit; + if (!x_limit) { + PyTuple_SET_ITEM(ans, i, nl); + continue; + } + } + } + line_as_ansi(line, &output, &prev_cell, xr.x, x_limit); + PyObject *t = PyUnicode_FromKindAndData(PyUnicode_4BYTE_KIND, output.buf, output.len); + if (!t) return NULL; + PyTuple_SET_ITEM(ans, i, t); + } + PyObject *t = PyUnicode_FromFormat("%s%s", "\x1b[m", output.active_hyperlink_id ? "\x1b]8;;\x1b\\" : ""); + if (!t) return NULL; + PyTuple_SET_ITEM(ans, PyTuple_GET_SIZE(ans) - 1, t); + Py_INCREF(ans); + return ans; +} + + static hyperlink_id_type hyperlink_id_for_range(Screen *self, const Selection *sel) { IterationData idata; @@ -2456,7 +2528,7 @@ current_url_text(Screen *self, PyObject *args UNUSED) { for (size_t i = 0; i < self->url_ranges.count; i++) { Selection *s = self->url_ranges.items + i; if (!is_selection_empty(s)) { - PyObject *temp = text_for_range(self, s, false); + PyObject *temp = text_for_range(self, s, false, false); if (!temp) goto error; PyObject *text = PyUnicode_Join(empty_string, temp); Py_CLEAR(temp); @@ -3089,10 +3161,10 @@ copy_colors_from(Screen *self, Screen *other) { } static PyObject* -text_for_selections(Screen *self, Selections *selections) { +text_for_selections(Screen *self, Selections *selections, bool ansi, bool strip_trailing_whitespace) { PyObject *lines = NULL; for (size_t i = 0; i < selections->count; i++) { - PyObject *temp = text_for_range(self, selections->items + i, true); + PyObject *temp = ansi ? ansi_for_range(self, selections->items +i, true, strip_trailing_whitespace) : text_for_range(self, selections->items + i, true, strip_trailing_whitespace); if (temp) { if (lines) { lines = extend_tuple(lines, temp); @@ -3106,13 +3178,17 @@ text_for_selections(Screen *self, Selections *selections) { } static PyObject* -text_for_selection(Screen *self, PyObject *a UNUSED) { - return text_for_selections(self, &self->selections); +text_for_selection(Screen *self, PyObject *args) { + int ansi = 0, strip_trailing_whitespace = 0; + if (!PyArg_ParseTuple(args, "|pp", &ansi, &strip_trailing_whitespace)) return NULL; + return text_for_selections(self, &self->selections, ansi, strip_trailing_whitespace); } static PyObject* -text_for_marked_url(Screen *self, PyObject *a UNUSED) { - return text_for_selections(self, &self->url_ranges); +text_for_marked_url(Screen *self, PyObject *args) { + int ansi = 0, strip_trailing_whitespace = 0; + if (!PyArg_ParseTuple(args, "|pp", &ansi, &strip_trailing_whitespace)) return NULL; + return text_for_selections(self, &self->url_ranges, ansi, strip_trailing_whitespace); } @@ -3853,8 +3929,8 @@ static PyMethodDef methods[] = { MND(detect_url, METH_VARARGS) MND(rescale_images, METH_NOARGS) MND(current_key_encoding_flags, METH_NOARGS) - MND(text_for_selection, METH_NOARGS) - MND(text_for_marked_url, METH_NOARGS) + MND(text_for_selection, METH_VARARGS) + MND(text_for_marked_url, METH_VARARGS) MND(is_rectangle_select, METH_NOARGS) MND(scroll, METH_VARARGS) MND(scroll_to_prompt, METH_VARARGS) diff --git a/kitty/window.py b/kitty/window.py index b002b2863..a0650ef3f 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -1026,12 +1026,10 @@ class Window: self.show_cmd_output(CommandOutput.last_visited, 'Clicked command output') # }}} - def text_for_selection(self) -> str: - lines = self.screen.text_for_selection() + def text_for_selection(self, as_ansi: bool = False) -> str: sts = get_options().strip_trailing_spaces - if sts == 'always' or ( - sts == 'smart' and not self.screen.is_rectangle_select()): - return ''.join((ln.rstrip() or '\n') for ln in lines) + strip_trailing_spaces = sts == 'always' or (sts == 'smart' and not self.screen.is_rectangle_select()) + lines = self.screen.text_for_selection(as_ansi, strip_trailing_spaces) return ''.join(lines) def call_watchers(self, which: Iterable[Watcher], data: Dict[str, Any]) -> None: @@ -1161,6 +1159,12 @@ class Window: if text: set_clipboard_string(text) + @ac('cp', 'Copy the selected text from the active window to the clipboard with ANSI formatting codes') + def copy_ansi_to_clipboard(self) -> None: + text = self.text_for_selection(as_ansi=True) + if text: + set_clipboard_string(text) + def encoded_key(self, key_event: KeyEvent) -> bytes: return encode_key_for_tty( key=key_event.key, shifted_key=key_event.shifted_key, alternate_key=key_event.alternate_key, diff --git a/kitty_tests/screen.py b/kitty_tests/screen.py index d39d1d0d9..7fb7c7ffa 100644 --- a/kitty_tests/screen.py +++ b/kitty_tests/screen.py @@ -530,6 +530,28 @@ class TestScreen(BaseTest): s.scroll(2, True) self.ae(s.text_for_selection(), expected) s.reset() + s.draw('ab cd') + s.start_selection(0, 0) + s.update_selection(1, 3) + self.ae(s.text_for_selection(), ('ab ', 'cd')) + self.ae(s.text_for_selection(False, True), ('ab', 'cd')) + s.reset() + s.draw('ab cd') + s.start_selection(0, 0) + s.update_selection(3, 4) + self.ae(s.text_for_selection(), ('ab ', ' ', 'cd')) + self.ae(s.text_for_selection(False, True), ('ab', '\n', 'cd')) + s.reset() + s.draw('a') + s.select_graphic_rendition(32) + s.draw('b') + s.select_graphic_rendition(39) + s.draw('c xy') + s.start_selection(0, 0) + s.update_selection(1, 3) + self.ae(s.text_for_selection(), ('abc ', 'xy')) + self.ae(s.text_for_selection(True), ('a\x1b[32mb\x1b[39mc ', 'xy', '\x1b[m')) + self.ae(s.text_for_selection(True, True), ('a\x1b[32mb\x1b[39mc', 'xy', '\x1b[m')) def test_soft_hyphen(self): s = self.create_screen()