A new action copy_ansi_to_clipboard to copy the current selection with ANSI formatting codes

Fixes #4665
This commit is contained in:
Kovid Goyal 2022-02-10 12:20:19 +05:30
parent ce8b0cf748
commit 1170cf474f
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
6 changed files with 124 additions and 20 deletions

View File

@ -93,6 +93,9 @@ Detailed list of changes
- A new action to clear the screen up to the line containing the cursor, see - A new action to clear the screen up to the line containing the cursor, see
:ac:`clear_terminal` :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 - macOS: Fix a regression in the previous release that broke switching input
sources by keyboard (:iss:`4621`) sources by keyboard (:iss:`4621`)

View File

@ -1085,7 +1085,7 @@ class Screen:
def has_selection(self) -> bool: def has_selection(self) -> bool:
pass pass
def text_for_selection(self) -> Tuple[str, ...]: def text_for_selection(self, ansi: bool, strip_trailing_spaces: bool) -> Tuple[str, ...]:
pass pass
def is_rectangle_select(self) -> bool: def is_rectangle_select(self) -> bool:

View File

@ -43,8 +43,7 @@ requires :ref:`shell_integration` to be enabled.
--ansi --ansi
type=bool-set type=bool-set
By default, only plain text is returned. If you specify this flag, the text will 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 include the formatting escape codes for colors/bold/italic/etc.
getting the current selection, the result is always plain text.
--add-cursor --add-cursor
@ -83,7 +82,7 @@ Clear the selection in the matched window, if any
from kitty.window import CommandOutput from kitty.window import CommandOutput
window = self.windows_for_match_payload(boss, window, payload_get)[0] window = self.windows_for_match_payload(boss, window, payload_get)[0]
if payload_get('extent') == 'selection': 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': elif payload_get('extent') == 'first_cmd_output_on_screen':
ans = window.cmd_output( ans = window.cmd_output(
CommandOutput.first_on_screen, CommandOutput.first_on_screen,

View File

@ -2403,8 +2403,25 @@ screen_apply_selection(Screen *self, void *address, size_t size) {
self->url_ranges.last_rendered_count = self->url_ranges.count; 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* 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; IterationData idata;
iteration_data(self, sel, &idata, -self->historybuf->count, false); iteration_data(self, sel, &idata, -self->historybuf->count, false);
int limit = MIN((int)self->lines, idata.y_limit); 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); Line *line = range_line_(self, y);
XRange xr = xrange_for_iteration(&idata, y, line); XRange xr = xrange_for_iteration(&idata, y, line);
char leading_char = (i > 0 && insert_newlines && !line->attrs.continued) ? '\n' : 0; 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(); } if (text == NULL) { Py_DECREF(ans); return PyErr_NoMemory(); }
PyTuple_SET_ITEM(ans, i, text); PyTuple_SET_ITEM(ans, i, text);
} }
return ans; 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 static hyperlink_id_type
hyperlink_id_for_range(Screen *self, const Selection *sel) { hyperlink_id_for_range(Screen *self, const Selection *sel) {
IterationData idata; 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++) { for (size_t i = 0; i < self->url_ranges.count; i++) {
Selection *s = self->url_ranges.items + i; Selection *s = self->url_ranges.items + i;
if (!is_selection_empty(s)) { 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; if (!temp) goto error;
PyObject *text = PyUnicode_Join(empty_string, temp); PyObject *text = PyUnicode_Join(empty_string, temp);
Py_CLEAR(temp); Py_CLEAR(temp);
@ -3089,10 +3161,10 @@ copy_colors_from(Screen *self, Screen *other) {
} }
static PyObject* 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; PyObject *lines = NULL;
for (size_t i = 0; i < selections->count; i++) { 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 (temp) {
if (lines) { if (lines) {
lines = extend_tuple(lines, temp); lines = extend_tuple(lines, temp);
@ -3106,13 +3178,17 @@ text_for_selections(Screen *self, Selections *selections) {
} }
static PyObject* static PyObject*
text_for_selection(Screen *self, PyObject *a UNUSED) { text_for_selection(Screen *self, PyObject *args) {
return text_for_selections(self, &self->selections); 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* static PyObject*
text_for_marked_url(Screen *self, PyObject *a UNUSED) { text_for_marked_url(Screen *self, PyObject *args) {
return text_for_selections(self, &self->url_ranges); 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(detect_url, METH_VARARGS)
MND(rescale_images, METH_NOARGS) MND(rescale_images, METH_NOARGS)
MND(current_key_encoding_flags, METH_NOARGS) MND(current_key_encoding_flags, METH_NOARGS)
MND(text_for_selection, METH_NOARGS) MND(text_for_selection, METH_VARARGS)
MND(text_for_marked_url, METH_NOARGS) MND(text_for_marked_url, METH_VARARGS)
MND(is_rectangle_select, METH_NOARGS) MND(is_rectangle_select, METH_NOARGS)
MND(scroll, METH_VARARGS) MND(scroll, METH_VARARGS)
MND(scroll_to_prompt, METH_VARARGS) MND(scroll_to_prompt, METH_VARARGS)

View File

@ -1026,12 +1026,10 @@ class Window:
self.show_cmd_output(CommandOutput.last_visited, 'Clicked command output') self.show_cmd_output(CommandOutput.last_visited, 'Clicked command output')
# }}} # }}}
def text_for_selection(self) -> str: def text_for_selection(self, as_ansi: bool = False) -> str:
lines = self.screen.text_for_selection()
sts = get_options().strip_trailing_spaces sts = get_options().strip_trailing_spaces
if sts == 'always' or ( strip_trailing_spaces = sts == 'always' or (sts == 'smart' and not self.screen.is_rectangle_select())
sts == 'smart' and not self.screen.is_rectangle_select()): lines = self.screen.text_for_selection(as_ansi, strip_trailing_spaces)
return ''.join((ln.rstrip() or '\n') for ln in lines)
return ''.join(lines) return ''.join(lines)
def call_watchers(self, which: Iterable[Watcher], data: Dict[str, Any]) -> None: def call_watchers(self, which: Iterable[Watcher], data: Dict[str, Any]) -> None:
@ -1161,6 +1159,12 @@ class Window:
if text: if text:
set_clipboard_string(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: def encoded_key(self, key_event: KeyEvent) -> bytes:
return encode_key_for_tty( return encode_key_for_tty(
key=key_event.key, shifted_key=key_event.shifted_key, alternate_key=key_event.alternate_key, key=key_event.key, shifted_key=key_event.shifted_key, alternate_key=key_event.alternate_key,

View File

@ -530,6 +530,28 @@ class TestScreen(BaseTest):
s.scroll(2, True) s.scroll(2, True)
self.ae(s.text_for_selection(), expected) self.ae(s.text_for_selection(), expected)
s.reset() 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): def test_soft_hyphen(self):
s = self.create_screen() s = self.create_screen()