diff --git a/docs/changelog.rst b/docs/changelog.rst index 5df3f6623..4fe7a2f36 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -200,6 +200,11 @@ Detailed list of changes 0.49.0 [future] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- Allow dragging selected text to other windows or applications with the + :code:`mouse_selection drag_or_normal_select` mouse action, in addition to + its existing support for dragging links. See the + :sc:`configuration examples ` to enable it. + - Support for :doc:`/custom-shaders` for adding various graphical effects (:iss:`10344`) - Various throughput performance improvements for a 15-35% real world improvement depending on workload diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index 64f18c0d3..9ccac6462 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -52,6 +52,7 @@ MAX_CUSTOM_SHADER_GROUPS: int MOUSE_SELECTION_LINE: int MOUSE_SELECTION_EXTEND: int MOUSE_SELECTION_NORMAL: int +MOUSE_SELECTION_DRAG_OR_NORMAL_SELECT: int MOUSE_SELECTION_WORD: int MOUSE_SELECTION_RECTANGLE: int MOUSE_SELECTION_LINE_FROM_BEGIN: int @@ -1090,7 +1091,6 @@ class Screen: def hyperlink_for_id(self, hyperlink_id: int) -> str: ... def erase_last_command(self) -> bool: ... def set_progress(self, state: int, percent: int) -> None: ... - def mark_potential_url_drag(self) -> bool: ... def cursor_at_prompt(self) -> bool: pass diff --git a/kitty/mouse.c b/kitty/mouse.c index 4ebcffee6..e3d19d931 100644 --- a/kitty/mouse.c +++ b/kitty/mouse.c @@ -539,6 +539,12 @@ end_drag(Window *w) { global_state.active_drag_button = -1; w->last_drag_scroll_at = 0; w->scrollbar.is_dragging = false; + if (w->drag_source.potential_drag.type == POTENTIAL_DRAG_SELECTION) { + // A press on selected text that never became a drag is an ordinary click. + screen_start_selection(screen, w->mouse_pos.cell_x, w->mouse_pos.cell_y, w->mouse_pos.in_left_half_of_cell, false, EXTEND_CELL); + } + zero_at_ptr(&w->drag_source.potential_drag); + zero_at_ptr(&w->drag_source.initial_left_press); if (global_state.callback_os_window && get_scrollbar_hit_type(w, global_state.callback_os_window->mouse_x, global_state.callback_os_window->mouse_y) == SCROLLBAR_HIT_NONE) { @@ -650,6 +656,39 @@ distance(double x1, double y1, double x2, double y2) { return sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2)); } +static void +handle_potential_drag(Window *w, int button) { + if (button != GLFW_MOUSE_BUTTON_LEFT || !w->drag_source.initial_left_press.at || OPT(drag_threshold) <= 0) return; + if (distance(w->mouse_pos.global_x, w->mouse_pos.global_y, w->drag_source.initial_left_press.x, w->drag_source.initial_left_press.y) <= OPT(drag_threshold)) + return; + zero_at_ptr(&w->drag_source.initial_left_press); + Screen *screen = w->render_data.screen; + if (w->drag_source.potential_drag.type == POTENTIAL_DRAG_SELECTION) { + zero_at_ptr(&w->drag_source.potential_drag); + // A native drag consumes the release event, including when canceled. Stop + // tracking the local gesture before handing it to the window system. + global_state.active_drag_in_window = 0; + global_state.active_drag_button = -1; + w->click_queues[GLFW_MOUSE_BUTTON_LEFT].length = 0; + set_mouse_cursor_for_screen(screen); + if (screen->callbacks != Py_None) { + PyObject *ret = PyObject_CallMethod(screen->callbacks, "drag_selection", NULL); + if (ret) Py_DECREF(ret); + else PyErr_Print(); + } + } else if (w->drag_source.can_offer) { + drag_offer_start_to_child(w, w->mouse_pos.cell_x, w->mouse_pos.cell_y, (int)w->mouse_pos.global_x, (int)w->mouse_pos.global_y); + debug("Sent drag start event to child\n"); + } else if (w->drag_source.potential_drag.type == POTENTIAL_DRAG_URL) { + w->drag_source.potential_drag.type = POTENTIAL_DRAG_NONE; + screen_detect_url(screen, w->drag_source.potential_drag.x, w->drag_source.potential_drag.y); + if (screen->current_hyperlink_under_mouse.id || screen->current_hyperlink_under_mouse.has_detected_url) { + screen_open_url(screen, "drag_url"); + debug("Started URL drag\n"); + } + } +} + HANDLER(handle_move_event) { modifiers &= ~GLFW_LOCK_MASK; @@ -674,22 +713,7 @@ HANDLER(handle_move_event) { } } } - if (w->drag_source.initial_left_press.at && - distance(w->mouse_pos.global_x, w->mouse_pos.global_y, w->drag_source.initial_left_press.x, w->drag_source.initial_left_press.y) > - OPT(drag_threshold)) { - zero_at_ptr(&w->drag_source.initial_left_press); - if (w->drag_source.can_offer) { - drag_offer_start_to_child(w, w->mouse_pos.cell_x, w->mouse_pos.cell_y, (int)w->mouse_pos.global_x, (int)w->mouse_pos.global_y); - debug("Sent drag start event to child\n"); - } else if (w->drag_source.potential_url_drag.active) { - w->drag_source.potential_url_drag.active = false; - screen_detect_url(screen, w->drag_source.potential_url_drag.x, w->drag_source.potential_url_drag.y); - if (screen->current_hyperlink_under_mouse.id || screen->current_hyperlink_under_mouse.has_detected_url) { - screen_open_url(screen, "drag_url"); - debug("Started URL drag\n"); - } - } - } + handle_potential_drag(w, button); } static void @@ -853,11 +877,23 @@ dispatch_possible_click(Window *w, int button, int modifiers) { } } +static void +prepare_mouse_button(Window *w, int button, bool is_release) { + zero_at_ptr(&w->drag_source.potential_drag); + if (button == GLFW_MOUSE_BUTTON_LEFT) { + if (is_release) zero_at_ptr(&w->drag_source.initial_left_press); + else { + w->drag_source.initial_left_press.x = w->mouse_pos.global_x; + w->drag_source.initial_left_press.y = w->mouse_pos.global_y; + w->drag_source.initial_left_press.at = monotonic(); + } + } +} + HANDLER(handle_button_event) { modifiers &= ~GLFW_LOCK_MASK; OSWindow *osw = global_state.callback_os_window; if (!osw) return; - w->drag_source.potential_url_drag.active = false; Tab *t = osw->tabs + osw->active_tab; bool is_release = !osw->mouse_button_pressed[button]; @@ -871,16 +907,8 @@ HANDLER(handle_button_event) { bool a, b; if (!set_mouse_position(w, &a, &b)) return; - if (button == GLFW_MOUSE_BUTTON_LEFT) { - if (is_release) { - zero_at_ptr(&w->drag_source.initial_left_press); - } else { - osw->shader_anim_event_registry |= (1u << SHADER_ANIM_EVENT_POINTER_LEFT_BUTTON_PRESS); - w->drag_source.initial_left_press.x = w->mouse_pos.global_x; - w->drag_source.initial_left_press.y = w->mouse_pos.global_y; - w->drag_source.initial_left_press.at = monotonic(); - } - } + prepare_mouse_button(w, button, is_release); + if (button == GLFW_MOUSE_BUTTON_LEFT && !is_release) osw->shader_anim_event_registry |= (1u << SHADER_ANIM_EVENT_POINTER_LEFT_BUTTON_PRESS); id_type wid = w->id; if (!dispatch_mouse_event(w, button, is_release ? -1 : 1, modifiers, screen->modes.mouse_tracking_mode != 0)) { if (screen->modes.mouse_tracking_mode != 0) { @@ -1181,6 +1209,7 @@ enter_event(int modifiers, bool cursor_moved) { typedef enum MouseSelectionType { + MOUSE_SELECTION_DRAG_OR_NORMAL_SELECT = -1, MOUSE_SELECTION_NORMAL, MOUSE_SELECTION_EXTEND, MOUSE_SELECTION_RECTANGLE, @@ -1196,6 +1225,7 @@ typedef enum MouseSelectionType { void mouse_selection(Window *w, int code, int button) { + zero_at_ptr(&w->drag_source.potential_drag); global_state.active_drag_in_window = w->id; global_state.active_drag_button = button; Screen *screen = w->render_data.screen; @@ -1209,6 +1239,25 @@ mouse_selection(Window *w, int code, int button) { } switch ((MouseSelectionType)code) { + case MOUSE_SELECTION_DRAG_OR_NORMAL_SELECT: + screen_pause_rendering(screen, false, 0); + if (button == GLFW_MOUSE_BUTTON_LEFT && OPT(drag_threshold) > 0) { + if (!screen->selections.in_progress && screen_is_cell_selected(screen, w->mouse_pos.cell_x, w->mouse_pos.cell_y)) { + // Like iTerm2's mouseDownOnSelection: defer changing the + // selection until we can distinguish a click from a drag. + w->drag_source.potential_drag.type = POTENTIAL_DRAG_SELECTION; + return; + } + if (screen->current_hyperlink_under_mouse.id || screen->current_hyperlink_under_mouse.has_detected_url) { + w->drag_source.potential_drag.type = POTENTIAL_DRAG_URL; + w->drag_source.potential_drag.x = w->mouse_pos.cell_x; + w->drag_source.potential_drag.y = w->mouse_pos.cell_y; + global_state.active_drag_in_window = 0; + global_state.active_drag_button = -1; + return; + } + } + /* fallthrough */ case MOUSE_SELECTION_NORMAL: screen_start_selection(screen, w->mouse_pos.cell_x, w->mouse_pos.cell_y, w->mouse_pos.in_left_half_of_cell, false, EXTEND_CELL); break; @@ -1680,14 +1729,28 @@ send_mock_mouse_event_to_window(PyObject *self UNUSED, PyObject *args) { PyObject *capsule; int button, modifiers, is_release, clear_clicks, in_left_half_of_cell; unsigned int x, y; - if (!PyArg_ParseTuple(args, "O!iipIIpp", &PyCapsule_Type, &capsule, &button, &modifiers, &is_release, &x, &y, &clear_clicks, &in_left_half_of_cell)) + double pixel_x = -1, pixel_y = -1; + if (!PyArg_ParseTuple( + args, + "O!iipIIpp|dd", + &PyCapsule_Type, + &capsule, + &button, + &modifiers, + &is_release, + &x, + &y, + &clear_clicks, + &in_left_half_of_cell, + &pixel_x, + &pixel_y)) return NULL; Window *w = PyCapsule_GetPointer(capsule, "Window"); if (!w) return NULL; if (clear_clicks) clear_click_queue(w, button); bool mouse_cell_changed = x != w->mouse_pos.cell_x || y != w->mouse_pos.cell_y || w->mouse_pos.in_left_half_of_cell != in_left_half_of_cell; - w->mouse_pos.global_x = 10 * x; - w->mouse_pos.global_y = 20 * y; + w->mouse_pos.global_x = pixel_x < 0 ? 10 * x : pixel_x; + w->mouse_pos.global_y = pixel_y < 0 ? 20 * y : pixel_y; w->mouse_pos.cell_x = x; w->mouse_pos.cell_y = y; w->mouse_pos.in_left_half_of_cell = in_left_half_of_cell; @@ -1695,12 +1758,17 @@ send_mock_mouse_event_to_window(PyObject *self UNUSED, PyObject *args) { if (button < 0) { if (button == -2) do_drag_scroll(w, true); else if (button == -3) do_drag_scroll(w, false); - else handle_mouse_movement_in_kitty(w, last_button_pressed, mouse_cell_changed); + else { + if (OPT(detect_urls)) detect_url(w->render_data.screen, x, y); + handle_mouse_movement_in_kitty(w, last_button_pressed, mouse_cell_changed); + handle_potential_drag(w, last_button_pressed); + } } else { if (global_state.active_drag_in_window && is_release && button == global_state.active_drag_button) { end_drag(w); } else { - dispatch_mouse_event(w, button, is_release ? -1 : 1, modifiers, false); + prepare_mouse_button(w, button, is_release); + dispatch_mouse_event(w, button, is_release ? -1 : 1, modifiers, w->render_data.screen->modes.mouse_tracking_mode != NO_TRACKING); if (!is_release) { last_button_pressed = button; add_press(w, button, modifiers); @@ -1753,6 +1821,7 @@ init_mouse(PyObject *module) { PyModule_AddIntMacro(module, RELEASE); PyModule_AddIntMacro(module, DRAG); PyModule_AddIntMacro(module, MOVE); + PyModule_AddIntMacro(module, MOUSE_SELECTION_DRAG_OR_NORMAL_SELECT); PyModule_AddIntMacro(module, MOUSE_SELECTION_NORMAL); PyModule_AddIntMacro(module, MOUSE_SELECTION_EXTEND); PyModule_AddIntMacro(module, MOUSE_SELECTION_RECTANGLE); diff --git a/kitty/options/definition.py b/kitty/options/definition.py index a9f4ddf31..53c2f78c5 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -1277,10 +1277,39 @@ mma( 'Start selecting text', 'start_simple_selection left press ungrabbed mouse_selection normal', long_text=""" -If you would like to drag and drop hyperlinks or detected URLs, instead of -:code:`normal` use :code:`drag_or_normal_select`, then if a hyperlink is under -the mouse it will be dragged based on :opt:`drag_threshold` otherwise a normal -selection will be performed. +By default, pressing the left mouse button starts a new selection. To enable +dragging selected text, hyperlinks or detected URLs, add this to :file:`kitty.conf`:: + + mouse_map left press ungrabbed mouse_selection drag_or_normal_select + +After saving, use :sc:`reload_config_file` if :opt:`auto_reload_config` is disabled. +Select some text and release the mouse button. Then press inside the selection +and drag it to another kitty window (including a split) or an application that +accepts text drops. The text is copied without changing the clipboard. + +For programs that capture mouse events, such as editors with mouse support, +also add the following mapping and hold :kbd:`Shift` when selecting and dragging:: + + mouse_map shift+left press grabbed mouse_selection drag_or_normal_select + +To require :kbd:`Ctrl` for dragging, use these mappings instead of the first +example, keeping ordinary left-button selection unchanged:: + + mouse_map left press ungrabbed mouse_selection normal + mouse_map ctrl+left press ungrabbed mouse_selection drag_or_normal_select + +To disable text and link dragging, replace :code:`drag_or_normal_select` with +:code:`normal` in the mappings you added. Setting :opt:`drag_threshold` to zero +also disables dragging, but affects tab and window dragging as well. + +Dragging starts after the mouse has moved farther than :opt:`drag_threshold`. +The selection takes precedence over links under the mouse. Outside a selection, +links can be dragged as before, and other text can be selected normally. A click +inside the selection clears it, while double and triple clicks still select +words and lines. + +Drops into kitty follow the receiving window's :opt:`paste_actions` settings. +Multiline text or text containing control codes can therefore require confirmation. """, ) diff --git a/kitty/options/utils.py b/kitty/options/utils.py index 82c23e54b..f962ec596 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -489,7 +489,7 @@ def mouse_selection(func: str, rest: str) -> FuncArgsType: 'line_from_point': defines.MOUSE_SELECTION_LINE_FROM_POINT, 'word_and_line_from_point': defines.MOUSE_SELECTION_WORD_AND_LINE_FROM_POINT, 'upto_surrounding_whitespace': defines.MOUSE_SELECTION_UPTO_SURROUNDING_WHITESPACE, - 'drag_or_normal_select': defines.MOUSE_SELECTION_NORMAL - 1, + 'drag_or_normal_select': defines.MOUSE_SELECTION_DRAG_OR_NORMAL_SELECT, } setattr(mouse_selection, 'code_map', cmap) return func, [cmap[rest]] diff --git a/kitty/screen.c b/kitty/screen.c index 7b23f1635..c5b8e9ee4 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -1827,18 +1827,6 @@ screen_dirty_line_graphics(Screen *self, const unsigned int top, const unsigned if (need_to_remove) grman_remove_cell_images(main_buf ? self->main_grman : self->alt_grman, top, bottom); } -static bool -screen_mark_potential_url_drag(Screen *self) { - Window *w; - if ((!self->current_hyperlink_under_mouse.id && !self->current_hyperlink_under_mouse.has_detected_url) || !self->window_id || - !(w = window_for_window_id(self->window_id))) - return false; - w->drag_source.potential_url_drag.active = true; - w->drag_source.potential_url_drag.x = w->mouse_pos.cell_x; - w->drag_source.potential_url_drag.y = w->mouse_pos.cell_y; - return true; -} - void screen_handle_dnd_command(Screen *self, const DnDCommand *cmd_, const uint8_t *payload) { Window *w; @@ -4337,6 +4325,33 @@ screen_has_selection(Screen *self) { return false; } +bool +screen_is_cell_selected(Screen *self, index_type x, index_type y) { + if (x >= self->columns || y >= self->lines) return false; + const int row = (int)y - self->scrolled_by; + Line *line = checked_range_line(self, row); + if (!line) return false; + const CPUCell cell = line->cpu_cells[x]; + // Every cell of a multi-cell character has the same selection highlight. + const int first_row = cell.is_multicell ? row - cell.y : row; + const int last_row = cell.is_multicell ? first_row + cell.scale : row + 1; + const int visible_start = -(int)self->scrolled_by - pixel_scroll_enabled(self); + const int visible_end = (int)self->lines - self->scrolled_by; + for (size_t i = 0; i < self->selections.count; i++) { + IterationData idata; + iteration_data(self->selections.items + i, &idata, self->columns, -self->historybuf->count, 0); + const int start = MAX(visible_start, MAX(first_row, idata.y)), end = MIN(visible_end, MIN(last_row, idata.y_limit)); + for (int r = start; r < end; r++) { + line = checked_range_line(self, r); + if (line) { + const XRange xr = xrange_for_iteration_with_multicells(&idata, r, line); + if (x >= xr.x && x < xr.x_limit) return true; + } + } + } + return false; +} + void screen_apply_selection(Screen *self, void *address_, size_t size) { uint8_t *address = address_; @@ -6800,12 +6815,6 @@ current_selections(Screen *self, PyObject *a UNUSED) { WRAP0(update_only_line_graphics_data) WRAP0(bell) -static PyObject * -mark_potential_url_drag(Screen *self, PyObject *a UNUSED) { - if (screen_mark_potential_url_drag(self)) Py_RETURN_TRUE; - Py_RETURN_FALSE; -} - #define MND(name, args) {#name, (PyCFunction)name, args, #name}, #define MODEFUNC(name) MND(name, METH_NOARGS) MND(set_##name, METH_O) @@ -6984,7 +6993,7 @@ static PyMethodDef methods[] = { MND(paste, METH_O) MND(paste_bytes, METH_O) MND(focus_changed, METH_O) MND(has_focus, METH_NOARGS) MND(has_activity_since_last_focus, METH_NOARGS) MND(copy_colors_from, METH_O) MND(set_marker, METH_VARARGS) MND(marked_cells, METH_NOARGS) MND(scroll_to_next_mark, METH_VARARGS) MND(update_only_line_graphics_data, METH_NOARGS) - MND(bell, METH_NOARGS) MND(mark_potential_url_drag, METH_NOARGS) MND(current_selections, METH_NOARGS){ + MND(bell, METH_NOARGS) MND(current_selections, METH_NOARGS){ "select_graphic_rendition", (PyCFunction)_select_graphic_rendition, METH_VARARGS, ""}, {NULL} /* Sentinel */ diff --git a/kitty/screen.h b/kitty/screen.h index 51c3482ad..db707168a 100644 --- a/kitty/screen.h +++ b/kitty/screen.h @@ -307,6 +307,7 @@ void report_mode_status(Screen *self, unsigned int which, bool); void screen_apply_selection(Screen *self, void *address, size_t size); bool screen_is_selection_dirty(Screen *self); bool screen_has_selection(Screen *); +bool screen_is_cell_selected(Screen *, index_type x, index_type y); bool screen_invert_colors(Screen *self); void screen_update_cell_data(Screen *self, void *address, FONTS_DATA_HANDLE, bool cursor_has_moved); bool screen_is_cursor_visible(const Screen *self); diff --git a/kitty/state.h b/kitty/state.h index 326dcf46e..e9d109eb6 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -361,8 +361,8 @@ typedef struct Window { bool can_offer, is_remote_client; struct { index_type x, y; - bool active; - } potential_url_drag; + enum { POTENTIAL_DRAG_NONE, POTENTIAL_DRAG_URL, POTENTIAL_DRAG_SELECTION } type; + } potential_drag; struct { double x, y; monotonic_t at; diff --git a/kitty/window.py b/kitty/window.py index c7899439e..e12625113 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -32,6 +32,7 @@ from .constants import ( appname, clear_handled_signals, config_dir, + is_macos, kitten_exe, unserialize_launch_flag, wakeup_io_loop, @@ -43,11 +44,11 @@ from .fast_data_types import ( ESC_CSI, ESC_DCS, ESC_OSC, + GLFW_DRAG_OPERATION_COPY, GLFW_MOD_CONTROL, GLFW_PRESS, GLFW_RELEASE, GLFW_REPEAT, - MOUSE_SELECTION_NORMAL, NO_CURSOR_SHAPE, NULL_COLOR_VALUE, SCROLL_FULL, @@ -1435,6 +1436,28 @@ class Window: except OSError as e: log_error(f'Failed to start URL drag: {e}') + def drag_selection(self) -> None: + text = self.text_for_selection() + if not text: + return + # Bound the preview, but always offer the complete selection as plain text. + preview = ' '.join(text[:256].split()) + ('…' if len(text) > 256 else '') + fg = color_as_int(self.screen.color_profile.default_fg) + bg = color_as_int(self.screen.color_profile.default_bg) + width = self.geometry.right - self.geometry.left + pixels, width = draw_single_line_of_text(self.os_window_id, f' {preview} ', 0xFF000000 | fg, 0xFF000000 | bg, width, max_width=True) + thumbnails = ((pixels, width, len(pixels) // (width * 4)),) + data = text.encode('utf-8') + drag_data = {'text/plain': data} + if not is_macos: + # Some receivers interpret bare text/plain as a legacy encoding. + # Cocoa maps text/plain to a native string and makes each MIME type a separate drag item. + drag_data['text/plain;charset=utf-8'] = data + try: + start_drag_with_data(self.os_window_id, drag_data, thumbnails, GLFW_DRAG_OPERATION_COPY) + except OSError as e: + log_error(f'Failed to drag selected text: {e}') + def open_url(self, url: str, hyperlink_id: int, cwd: str | None = None) -> None: boss = get_boss() try: @@ -2026,10 +2049,6 @@ class Window: """, ) def mouse_selection(self, code: int) -> None: - if code == MOUSE_SELECTION_NORMAL - 1: - code = MOUSE_SELECTION_NORMAL - if self.screen.mark_potential_url_drag(): - return mouse_selection(self.os_window_id, self.tab_id, self.id, code, self.current_mouse_event_button) @ac('mouse', 'Paste the current primary selection') diff --git a/kitty_tests/mouse.py b/kitty_tests/mouse.py index c9c7b6e94..ad58236a7 100644 --- a/kitty_tests/mouse.py +++ b/kitty_tests/mouse.py @@ -25,10 +25,12 @@ def send_mouse_event( x=0.0, y=0, clear_click_queue=False, + pixel_x=-1, + pixel_y=-1, ): ix = int(x) in_left_half_of_cell = x - ix < 0.5 - send_mock_mouse_event_to_window(window, button, modifiers, is_release, ix, y, clear_click_queue, in_left_half_of_cell) + send_mock_mouse_event_to_window(window, button, modifiers, is_release, ix, y, clear_click_queue, in_left_half_of_cell, pixel_x, pixel_y) class TestMouse(BaseTest): diff --git a/kitty_tests/selection_drag.py b/kitty_tests/selection_drag.py new file mode 100644 index 000000000..66e6f483f --- /dev/null +++ b/kitty_tests/selection_drag.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python +# License: GPL v3 + +from functools import partial +from types import SimpleNamespace +from unittest.mock import patch + +from kitty.config import load_config +from kitty.fast_data_types import ( + GLFW_DRAG_OPERATION_COPY, + GLFW_MOD_SHIFT, + GLFW_MOUSE_BUTTON_LEFT, + GLFW_MOUSE_BUTTON_RIGHT, + create_mock_window, + mock_mouse_selection, + set_options, +) +from kitty.window import Window + +from .base import BaseTest, draw_multicell +from .mouse import send_mouse_event + +LEFT = GLFW_MOUSE_BUTTON_LEFT + + +class TestSelectionDrag(BaseTest): + def drag_screen(self, *config, cols=40, lines=6): + s = self.create_screen(cols=cols, lines=lines, scrollback=20) + errors = [] + opts = load_config( + overrides=( + 'pixel_scroll no', + 'click_interval 0.5', + 'drag_threshold 5', + 'mouse_map left press ungrabbed mouse_selection drag_or_normal_select', + *config, + ), + accumulate_bad_lines=errors, + ) + self.ae(errors, []) + set_options(opts) + w = create_mock_window(s) + s.callbacks.mouse_selection = lambda code: mock_mouse_selection(w, s.callbacks.current_mouse_button, code) + drags = [] + s.callbacks.drag_selection = lambda: drags.append(''.join(s.text_for_selection())) + return s, partial(send_mouse_event, w), drags + + @staticmethod + def select(s, start=(0, 0), end=(5, 0), rectangle=False): + s.start_selection(*start, rectangle, 0, True) + s.update_selection(*end, True) + + def test_selection_drag_gesture(self): + s, ev, drags = self.drag_screen() + s.draw('alpha beta gamma') + self.select(s) + ev(LEFT, x=1, pixel_x=12, clear_click_queue=True) + self.ae(''.join(s.text_for_selection()), 'alpha') + ev(x=1, pixel_x=16) + ev(x=1, pixel_x=17) # Exactly the threshold is not a drag. + self.ae(drags, []) + self.ae(''.join(s.text_for_selection()), 'alpha') + ev(x=1, pixel_x=18) # A drag can start without leaving the cell. + ev(x=2) + self.ae(drags, ['alpha']) + self.ae(''.join(s.text_for_selection()), 'alpha') + + # Native drag-and-drop consumes mouse-up, even if the drop is canceled. + # The next gesture must work without delivering the previous release. + ev(LEFT, x=6, clear_click_queue=True) + ev(x=10) + ev(LEFT, x=10, is_release=True) + self.ae(''.join(s.text_for_selection()), 'beta') + self.ae(drags, ['alpha']) + ev(x=20) + self.ae(drags, ['alpha']) + + def test_selection_drag_clicks(self): + s, ev, drags = self.drag_screen() + s.draw('alpha beta gamma') + self.select(s) + ev(LEFT, x=1, clear_click_queue=True) + ev(x=1, pixel_x=13) + ev(LEFT, x=1, is_release=True) + self.assertFalse(s.has_selection()) + ev(x=20) + self.ae(drags, []) + + for count, expected in ((2, 'alpha'), (3, 'alpha beta gamma')): + with self.subTest(clicks=count): + self.select(s) + for i in range(count): + ev(LEFT, x=1, clear_click_queue=i == 0) + if i != count - 1: + ev(LEFT, x=1, is_release=True) + self.ae(''.join(s.text_for_selection()), expected) + ev(x=12) + ev(LEFT, x=12, is_release=True) + self.assertTrue(s.has_selection()) + self.ae(drags, []) + + def test_selection_drag_configuration(self): + for config, button in ( + (('drag_threshold 0',), LEFT), + (('mouse_map left press ungrabbed mouse_selection normal',), LEFT), + (('mouse_map right press ungrabbed mouse_selection drag_or_normal_select',), GLFW_MOUSE_BUTTON_RIGHT), + ): + with self.subTest(config=config): + s, ev, drags = self.drag_screen(*config) + s.draw('alpha beta gamma') + self.select(s) + ev(button, x=1, clear_click_queue=True) + ev(x=4) + ev(button, x=4, is_release=True) + self.ae(drags, []) + self.ae(''.join(s.text_for_selection()), 'lph') + + s, ev, drags = self.drag_screen('mouse_map shift+left press grabbed mouse_selection drag_or_normal_select') + s.draw('alpha beta gamma') + self.select(s) + s.set_mode(1002, True) # The terminal program owns unmodified mouse events. + ev(LEFT, x=1, clear_click_queue=True) + ev(x=4) + ev(LEFT, x=4, is_release=True) + self.ae(drags, []) + self.ae(''.join(s.text_for_selection()), 'alpha') + ev(LEFT, x=1, modifiers=GLFW_MOD_SHIFT, clear_click_queue=True) + ev(x=4, modifiers=GLFW_MOD_SHIFT) + self.ae(drags, ['alpha']) + + def test_selection_drag_links(self): + s, ev, drags = self.drag_screen() + s.draw('https://example.com') + urls = [] + s.callbacks.drag_url = lambda url, hyperlink_id: urls.append(url) + self.select(s, (8, 0), (15, 0)) + ev(x=10) + ev(LEFT, x=10, clear_click_queue=True) + ev(x=11) + self.ae(drags, ['example']) + self.ae(urls, []) + s.clear_selection() + ev(x=10) + ev(LEFT, x=10, clear_click_queue=True) + ev(x=11) + self.ae(urls, ['https://example.com']) + self.ae(drags, ['example']) + + def test_selection_drag_hit_testing(self): + for kind in ('forward', 'backward', 'rectangle', 'unicode', 'multicell', 'scrollback', 'alternate'): + with self.subTest(kind=kind): + s, ev, drags = self.drag_screen(cols=12, lines=4) + if kind == 'alternate': + s.toggle_alt_screen() + if kind == 'multicell': + s.draw('a') + draw_multicell(s, 'X', scale=2) + s.draw('bc') + elif kind == 'unicode': + s.draw('a中文😀e\u0301b') + else: + for i in range(8 if kind == 'scrollback' else 3): + s.draw(f'{i} abcdefghij\r\n') + if kind == 'scrollback': + s.scroll(2, True) + start, end = (2, 0), (5, 2) + if kind == 'backward': + start, end = end, start + for y in range(s.lines): + for x in range(s.columns): + self.select(s, start, end, kind == 'rectangle') + selected = bool(s.current_selections()[y * s.columns + x] & 1) + text = ''.join(s.text_for_selection()) + before = len(drags) + ev(LEFT, x=x, y=y, clear_click_queue=True) + ev(x=x, y=y, pixel_x=x * 10 + 6) + self.ae(len(drags) - before, int(selected), f'{kind}: cell {(x, y)}') + if selected: + self.ae(drags[-1], text) + ev(LEFT, x=x, y=y, is_release=True) + + def test_selection_drag_payload(self): + s, _, _ = self.drag_screen() + text = '中文 😀 e\u0301\t value\n' * 100 + w = SimpleNamespace( + text_for_selection=lambda: text, + screen=s, + geometry=SimpleNamespace(left=0, right=400), + os_window_id=1, + ) + with ( + patch('kitty.window.draw_single_line_of_text', return_value=(b'\0' * 4, 1)) as draw, + patch('kitty.window.start_drag_with_data') as start, + patch('kitty.window.set_clipboard_string') as clipboard, + ): + for macos in (True, False): + with self.subTest(macos=macos), patch('kitty.window.is_macos', macos): + Window.drag_selection(w) + data = start.call_args.args[1] + self.ae(data['text/plain'], text.encode('utf-8')) + if macos: + self.ae(len(data), 1) # Cocoa creates a separate drag item for each MIME type. + else: + self.ae(data['text/plain;charset=utf-8'], text.encode('utf-8')) + self.ae(start.call_args.args[2:], (((b'\0' * 4, 1, 1),), GLFW_DRAG_OPERATION_COPY)) + preview = draw.call_args.args[1] + self.assertLess(len(preview), 260) + self.assertNotIn('\n', preview) + self.assertNotIn('\t', preview) + start.side_effect = OSError('drag canceled') + with patch('kitty.window.log_error') as log: + Window.drag_selection(w) + log.assert_called_once() + text = '' + start.reset_mock() + Window.drag_selection(w) + start.assert_not_called() + clipboard.assert_not_called() + + def test_selection_drag_paste_policy(self): + s, _, _ = self.drag_screen() + for text, bracketed, confirm in ( + ('single line', False, False), + ('first\nsecond', False, True), + ('first\nsecond', True, False), + ('text\x1b[31m', True, True), + ): + with self.subTest(text=text, bracketed=bracketed): + (s.set_mode if bracketed else s.reset_mode)(2004, True) + pasted = [] + w = SimpleNamespace(destroyed=False, at_prompt=False, screen=s, paste_text=pasted.append) + w.handle_dangerous_paste_confirmation = partial(Window.handle_dangerous_paste_confirmation, w) + with patch('kitty.window.get_boss') as boss: + Window.paste_with_actions(w, text, from_drop=True) + self.ae(boss.return_value.choose.called, confirm) + self.ae(pasted, [] if confirm else [text.encode('utf-8')]) + + def test_selection_drag_clipped_multicell(self): + s, ev, drags = self.drag_screen(cols=12, lines=4) + draw_multicell(s, 'X', scale=3) + s.cursor_position(4, 1) + s.linefeed() + s.scroll(1, True) + self.select(s, (0, 0), (3, 0)) + s.scroll(1, False) + # The selected row has left the viewport. The remaining rows of the + # large character are not highlighted and must not start a text drag. + self.assertFalse(any(c & 1 for c in s.current_selections())) + ev(LEFT, x=1, clear_click_queue=True) + ev(x=1, pixel_x=16) + self.ae(drags, []) + ev(LEFT, x=1, is_release=True) + + def test_selection_drag_resumes_rendering(self): + s, ev, drags = self.drag_screen() + s.draw('alpha beta gamma') + self.select(s) + self.assertTrue(s.pause_rendering(True, 5000)) + ev(LEFT, x=1, clear_click_queue=True) + # Mouse selection ends synchronized output, including when preserving + # an existing selection while waiting for the drag threshold. + self.assertFalse(s.pause_rendering(False)) + ev(x=2) + self.ae(drags, ['alpha'])