Allow optionally dragging URLs with the mouse

Fixes #9804
This commit is contained in:
Kovid Goyal 2026-04-06 10:40:11 +05:30
parent 69e4158259
commit c88adfba98
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
9 changed files with 82 additions and 15 deletions

View file

@ -183,6 +183,8 @@ Detailed list of changes
- :doc:`Remote control <remote-control>`: Expose :code:`session_name` and :code:`last_focused_at` in the output of ``kitten @ ls`` for each window (:iss:`9732`, :iss:`9799`)
- Allow optionally dragging URLs with the mouse, see :sc:`start_simple_selection` (:pull:`9804`)
- Fix thickness of diagonal lines in box drawing characters not the same as horizontal/vertical lines (:iss:`9719`)
- Graphics protocol: Fix crash when handling invalid PNG image with direct transmission

View file

@ -1260,6 +1260,7 @@ class Screen:
def hyperlink_for_id(self, hyperlink_id: int) -> str: ...
def erase_last_command(self, include_prompt: bool = True) -> 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

@ -413,9 +413,10 @@ handle_mouse_movement_in_kitty(Window *w, int button, bool mouse_cell_changed) {
static void
detect_url(Screen *screen, unsigned int x, unsigned int y) {
int hid = screen_detect_url(screen, x, y);
screen->current_hyperlink_under_mouse.id = 0;
zero_at_ptr(&screen->current_hyperlink_under_mouse);
if (hid != 0) {
mouse_cursor_shape = POINTER_POINTER;
screen->current_hyperlink_under_mouse.has_detected_url = hid < 0;
if (hid > 0) {
screen->current_hyperlink_under_mouse.id = (hyperlink_id_type)hid;
screen->current_hyperlink_under_mouse.x = x;
@ -672,16 +673,26 @@ HANDLER(handle_move_event) {
if (should_handle_in_kitty(w, screen, button)) {
handle_mouse_movement_in_kitty(w, button, mouse_cell_changed | cell_half_changed);
} else {
if (!mouse_cell_changed && screen->modes.mouse_tracking_protocol != SGR_PIXEL_PROTOCOL) return;
int sz = encode_mouse_button(w, button, button >=0 ? DRAG : MOVE, modifiers);
if (sz > 0) { mouse_event_buf[sz] = 0; write_escape_code_to_child(screen, ESC_CSI, mouse_event_buf); }
if (mouse_cell_changed || screen->modes.mouse_tracking_protocol == SGR_PIXEL_PROTOCOL) {
int sz = encode_mouse_button(w, button, button >=0 ? DRAG : MOVE, modifiers);
if (sz > 0) { mouse_event_buf[sz] = 0; write_escape_code_to_child(screen, ESC_CSI, mouse_event_buf); }
}
}
if (w->drag_source.can_offer && 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)) {
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);
snprintf(mouse_event_buf, sizeof(mouse_event_buf), "%d;t=o:x=%d:y=%d:X=%d:Y=%d",
DND_CODE, w->mouse_pos.cell_x, w->mouse_pos.cell_y, (int)w->mouse_pos.global_x, (int)w->mouse_pos.global_y);
write_escape_code_to_child(screen, ESC_OSC, mouse_event_buf);
debug("Sent drag start event to child\n");
if (w->drag_source.can_offer) {
snprintf(mouse_event_buf, sizeof(mouse_event_buf), "%d;t=o:x=%d:y=%d:X=%d:Y=%d",
DND_CODE, w->mouse_pos.cell_x, w->mouse_pos.cell_y, (int)w->mouse_pos.global_x, (int)w->mouse_pos.global_y);
write_escape_code_to_child(screen, ESC_OSC, mouse_event_buf);
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");
}
}
}
}
@ -749,7 +760,7 @@ bool
mouse_open_url(Window *w) {
Screen *screen = w->render_data.screen;
detect_url(screen, w->mouse_pos.cell_x, w->mouse_pos.cell_y);
return screen_open_url(screen);
return screen_open_url(screen, "open_url");
}
bool
@ -847,6 +858,7 @@ 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];
@ -879,7 +891,7 @@ HANDLER(handle_button_event) {
if (button == GLFW_MOUSE_BUTTON_LEFT) {
if (is_release) {
zero_at_ptr(&w->drag_source.initial_left_press);
} else if (w->drag_source.can_offer) {
} else {
w->drag_source.initial_left_press.x = w->mouse_pos.global_x;
w->drag_source.initial_left_press.y = w->mouse_pos.global_x;
w->drag_source.initial_left_press.at = monotonic();

View file

@ -988,6 +988,12 @@
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.
'''
)
mma('Start selecting text in a rectangle',

View file

@ -446,6 +446,7 @@ def mouse_selection(func: str, rest: str) -> FuncArgsType:
'normal': defines.MOUSE_SELECTION_NORMAL,
'extend': defines.MOUSE_SELECTION_EXTEND,
'move-end': defines.MOUSE_SELECTION_MOVE_END,
'move_end': defines.MOUSE_SELECTION_MOVE_END,
'rectangle': defines.MOUSE_SELECTION_RECTANGLE,
'word': defines.MOUSE_SELECTION_WORD,
'line': defines.MOUSE_SELECTION_LINE,
@ -453,6 +454,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,
}
setattr(mouse_selection, 'code_map', cmap)
return func, [cmap[rest]]

View file

@ -1512,6 +1512,16 @@ screen_dirty_line_graphics(Screen *self, const unsigned int top, const unsigned
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) {
if (!self->window_id) return;
@ -4084,13 +4094,13 @@ current_url_text(Screen *self, PyObject *args UNUSED) {
bool
screen_open_url(Screen *self) {
screen_open_url(Screen *self, const char *callback) {
if (!self->url_ranges.count) return false;
hyperlink_id_type hid = hyperlink_id_for_range(self, self->url_ranges.items);
if (hid) {
const char *url = get_hyperlink_for_id(self->hyperlink_pool, hid, true);
if (url) {
CALLBACK("open_url", "sH", url, hid);
CALLBACK(callback, "sH", url, hid);
return true;
}
}
@ -4101,7 +4111,7 @@ screen_open_url(Screen *self) {
}
bool found = false;
if (PyUnicode_Check(text)) {
CALLBACK("open_url", "OH", text, 0);
CALLBACK(callback, "OH", text, 0);
found = true;
}
Py_CLEAR(text);
@ -5948,6 +5958,12 @@ 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)
@ -6136,6 +6152,7 @@ static PyMethodDef methods[] = {
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)
{"select_graphic_rendition", (PyCFunction)_select_graphic_rendition, METH_VARARGS, ""},

View file

@ -186,6 +186,7 @@ typedef struct {
struct {
hyperlink_id_type id;
index_type x, y;
bool has_detected_url;
} current_hyperlink_under_mouse;
struct {
uint8_t stack[16], count;
@ -305,7 +306,7 @@ hyperlink_id_type screen_mark_hyperlink(Screen*, index_type, index_type);
void screen_handle_graphics_command(Screen *self, const GraphicsCommand *cmd, const uint8_t *payload);
void screen_handle_multicell_command(Screen *self, const MultiCellCommand *cmd, const uint8_t *payload);
void screen_handle_dnd_command(Screen *self, const DnDCommand *cmd, const uint8_t *payload);
bool screen_open_url(Screen*);
bool screen_open_url(Screen* self, const char* callback);
bool screen_set_last_visited_prompt(Screen*, index_type);
bool screen_select_cmd_output(Screen*, index_type);
void screen_dirty_sprite_positions(Screen *self);

View file

@ -290,6 +290,7 @@ typedef struct Window {
} drop;
struct {
bool can_offer;
struct { index_type x, y; bool active; } potential_url_drag;
struct { double x, y; monotonic_t at; } initial_left_press;
char *mimes_buf; size_t num_mimes, bufsz;
struct {

View file

@ -48,6 +48,7 @@
GLFW_PRESS,
GLFW_RELEASE,
GLFW_REPEAT,
MOUSE_SELECTION_NORMAL,
NO_CURSOR_SHAPE,
NULL_COLOR_VALUE,
SCROLL_FULL,
@ -65,6 +66,7 @@
click_mouse_cmd_output,
click_mouse_url,
current_focused_os_window_id,
draw_single_line_of_text,
encode_key_for_tty,
get_boss,
get_click_interval,
@ -88,6 +90,7 @@
set_window_padding,
set_window_render_data,
set_window_title_bar_render_data,
start_drag_with_data,
update_ime_position_for_window,
update_pointer_shape,
update_window_title,
@ -1309,6 +1312,24 @@ def on_mouse_event(self, event: dict[str, Any]) -> bool:
return False
return get_boss().combine(action, window_for_dispatch=self, dispatch_type='MouseEvent')
def drag_url(self, url: str, hyperlink_id: int) -> None:
if not url:
return
if url.startswith('/'):
from urllib.parse import quote
url = 'file://' + quote(os.path.abspath(url))
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 = draw_single_line_of_text(self.os_window_id, url, 0xff000000 | fg, 0xff000000 | bg, width)
height = len(pixels) // (width * 4)
thumbnails = ((pixels, width, height),)
drag_data = {'text/uri-list': (url + '\r\n').encode()}
try:
start_drag_with_data(self.os_window_id, drag_data, thumbnails)
except OSError as e:
log_error(f'Failed to start URL drag: {e}')
def open_url(self, url: str, hyperlink_id: int, cwd: str | None = None) -> None:
boss = get_boss()
try:
@ -1841,6 +1862,10 @@ def mouse_click_url_or_select(self) -> None:
For examples, see :ref:`conf-kitty-mouse.mousemap`
''')
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')