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
: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`)

View File

@ -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:

View File

@ -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,

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;
}
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)

View File

@ -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,

View File

@ -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()