Allow dragging selected text with drag_or_normal_select

This commit is contained in:
白鸽 2026-09-06 02:40:43 +08:00
parent d124d4f097
commit 2cd6733c75
11 changed files with 463 additions and 65 deletions

View file

@ -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 <start_simple_selection>` 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

View file

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

View file

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

View file

@ -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.
""",
)

View file

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

View file

@ -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 */

View file

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

View file

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

View file

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

View file

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

View file

@ -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'])