mirror of
https://github.com/kovidgoyal/kitty.git
synced 2026-10-21 04:32:05 +00:00
Allow dragging selected text with drag_or_normal_select
This commit is contained in:
parent
d124d4f097
commit
2cd6733c75
11 changed files with 463 additions and 65 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
133
kitty/mouse.c
133
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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
""",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
264
kitty_tests/selection_drag.py
Normal file
264
kitty_tests/selection_drag.py
Normal 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'])
|
||||
Loading…
Add table
Add a link
Reference in a new issue