diff --git a/docs/changelog.rst b/docs/changelog.rst index e7d67ef32..dab7c7ce9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -47,6 +47,8 @@ Detailed list of changes - Speed up the ``kitty @`` executable by ~10x reducing the time for typical remote control commands from ~50ms to ~5ms +- Show the target of terminal hyperlinks when hovering over them with the mouse controlled by :opt:`show_hyperlink_targets` (:pull:`5830`) + - Keyboard protocol: Remove ``CSI R`` from the allowed encodings of the :kbd:`F3` key as it conflicts with the *Cursor Position Report* escape code (:disc:`5813`) - Allow using the cwd of the original process for :option:`launch --cwd` (:iss:`5672`) diff --git a/kitty/boss.py b/kitty/boss.py index 9c19f8a22..b87b694f2 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -8,7 +8,7 @@ import os import re import sys from contextlib import suppress -from functools import partial +from functools import lru_cache, partial from gettext import gettext as _ from time import monotonic, sleep from typing import ( @@ -33,21 +33,19 @@ from .constants import ( ) from .fast_data_types import ( CLOSE_BEING_CONFIRMED, GLFW_MOD_ALT, GLFW_MOD_CONTROL, GLFW_MOD_SHIFT, - GLFW_MOD_SUPER, GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, - IMPERATIVE_CLOSE_REQUESTED, NO_CLOSE_REQUESTED, ChildMonitor, Color, - EllipticCurveKey, KeyEvent, SingleKey, add_timer, apply_options_update, - background_opacity_of, change_background_opacity, change_os_window_state, - cocoa_hide_app, cocoa_hide_other_apps, cocoa_minimize_os_window, - cocoa_set_menubar_title, create_os_window, - current_application_quit_request, current_focused_os_window_id, - current_os_window, destroy_global_data, focus_os_window, get_boss, - get_options, get_os_window_size, global_font_size, - last_focused_os_window_id, mark_os_window_for_close, os_window_font_size, - patch_global_colors, redirect_mouse_handling, ring_bell, + GLFW_MOD_SUPER, GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, IMPERATIVE_CLOSE_REQUESTED, + NO_CLOSE_REQUESTED, ChildMonitor, Color, EllipticCurveKey, KeyEvent, SingleKey, + add_timer, apply_options_update, background_opacity_of, change_background_opacity, + change_os_window_state, cocoa_hide_app, cocoa_hide_other_apps, + cocoa_minimize_os_window, cocoa_set_menubar_title, create_os_window, + current_application_quit_request, current_focused_os_window_id, current_os_window, + destroy_global_data, focus_os_window, get_boss, get_options, get_os_window_size, + global_font_size, last_focused_os_window_id, mark_os_window_for_close, + os_window_font_size, patch_global_colors, redirect_mouse_handling, ring_bell, run_with_activation_token, safe_pipe, send_data_to_peer, - set_application_quit_request, set_background_image, set_boss, - set_in_sequence_mode, set_options, set_os_window_size, set_os_window_title, - thread_write, toggle_fullscreen, toggle_maximized, toggle_secure_input, + set_application_quit_request, set_background_image, set_boss, set_in_sequence_mode, + set_options, set_os_window_size, set_os_window_title, thread_write, + toggle_fullscreen, toggle_maximized, toggle_secure_input, ) from .key_encoding import get_name_to_functional_number_map from .keys import get_shortcut, shortcut_matches @@ -2604,3 +2602,8 @@ class Boss: def discard_event(self) -> None: pass mouse_discard_event = discard_event + + @lru_cache(maxsize=64) + def sanitize_url_for_dispay_to_user(self, url: str) -> str: + # TODO: Use punycode, remove percent encoding, etc. + return url diff --git a/kitty/mouse.c b/kitty/mouse.c index 97c84b961..c56657bf8 100644 --- a/kitty/mouse.c +++ b/kitty/mouse.c @@ -340,8 +340,16 @@ 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) { - if (screen_detect_url(screen, x, y)) mouse_cursor_shape = HAND; - else set_mouse_cursor_for_screen(screen); + int hid = screen_detect_url(screen, x, y); + screen->current_hyperlink_under_mouse.id = 0; + if (hid != 0) { + mouse_cursor_shape = HAND; + if (hid > 0) { + screen->current_hyperlink_under_mouse.id = (hyperlink_id_type)hid; + screen->current_hyperlink_under_mouse.x = x; + screen->current_hyperlink_under_mouse.y = y; + } + } else set_mouse_cursor_for_screen(screen); } static bool diff --git a/kitty/options/definition.py b/kitty/options/definition.py index fae5198d2..783e8dedc 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -432,6 +432,10 @@ mouse cursor. By default, all characters that are legal in URLs are allowed. ''' ) +opt('show_hyperlink_targets', 'yes', option_type='to_bool', ctype='bool', long_text=''' +When the mouse hovers over a terminal hyperlink, show the actual URL that will be +activated when the hyperlink is clicked.''') + opt('copy_on_select', 'no', option_type='copy_on_select', long_text=''' diff --git a/kitty/options/parse.py b/kitty/options/parse.py index 979fea003..a6ff1e1d7 100644 --- a/kitty/options/parse.py +++ b/kitty/options/parse.py @@ -1185,6 +1185,9 @@ class Parser: def shell_integration(self, val: str, ans: typing.Dict[str, typing.Any]) -> None: ans['shell_integration'] = shell_integration(val) + def show_hyperlink_targets(self, val: str, ans: typing.Dict[str, typing.Any]) -> None: + ans['show_hyperlink_targets'] = to_bool(val) + def single_window_margin_width(self, val: str, ans: typing.Dict[str, typing.Any]) -> None: ans['single_window_margin_width'] = optional_edge_width(val) diff --git a/kitty/options/to-c-generated.h b/kitty/options/to-c-generated.h index b2e61caae..665a31d61 100644 --- a/kitty/options/to-c-generated.h +++ b/kitty/options/to-c-generated.h @@ -265,6 +265,19 @@ convert_from_opts_url_excluded_characters(PyObject *py_opts, Options *opts) { Py_DECREF(ret); } +static void +convert_from_python_show_hyperlink_targets(PyObject *val, Options *opts) { + opts->show_hyperlink_targets = PyObject_IsTrue(val); +} + +static void +convert_from_opts_show_hyperlink_targets(PyObject *py_opts, Options *opts) { + PyObject *ret = PyObject_GetAttrString(py_opts, "show_hyperlink_targets"); + if (ret == NULL) return; + convert_from_python_show_hyperlink_targets(ret, opts); + Py_DECREF(ret); +} + static void convert_from_python_select_by_word_characters(PyObject *val, Options *opts) { select_by_word_characters(val, opts); @@ -1061,6 +1074,8 @@ convert_opts_from_python_opts(PyObject *py_opts, Options *opts) { if (PyErr_Occurred()) return false; convert_from_opts_url_excluded_characters(py_opts, opts); if (PyErr_Occurred()) return false; + convert_from_opts_show_hyperlink_targets(py_opts, opts); + if (PyErr_Occurred()) return false; convert_from_opts_select_by_word_characters(py_opts, opts); if (PyErr_Occurred()) return false; convert_from_opts_select_by_word_characters_forward(py_opts, opts); diff --git a/kitty/options/types.py b/kitty/options/types.py index 42a5aa34c..3629ddceb 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -418,6 +418,7 @@ option_names = ( # {{{ 'selection_foreground', 'shell', 'shell_integration', + 'show_hyperlink_targets', 'single_window_margin_width', 'startup_session', 'strip_trailing_spaces', @@ -568,6 +569,7 @@ class Options: selection_foreground: typing.Optional[kitty.fast_data_types.Color] = Color(0, 0, 0) shell: str = '.' shell_integration: typing.FrozenSet[str] = frozenset({'enabled'}) + show_hyperlink_targets: bool = True single_window_margin_width: FloatEdges = FloatEdges(left=-1.0, top=-1.0, right=-1.0, bottom=-1.0) startup_session: typing.Optional[str] = None strip_trailing_spaces: choices_for_strip_trailing_spaces = 'never' diff --git a/kitty/screen.c b/kitty/screen.c index aad13644f..5b7712ee5 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -2722,15 +2722,16 @@ get_url_sentinel(Line *line, index_type url_start) { return sentinel; } -bool +int screen_detect_url(Screen *screen, unsigned int x, unsigned int y) { bool has_url = false; index_type url_start, url_end = 0; Line *line = screen_visual_line(screen, y); - if (!line || x >= screen->columns) return false; - if (line->cpu_cells[x].hyperlink_id) { + if (!line || x >= screen->columns) return 0; + hyperlink_id_type hid; + if ((hid = line->cpu_cells[x].hyperlink_id)) { screen_mark_hyperlink(screen, x, y); - return true; + return hid; } char_type sentinel = 0; if (line) { @@ -2754,7 +2755,7 @@ screen_detect_url(Screen *screen, unsigned int x, unsigned int y) { } else { screen_mark_url(screen, 0, 0, 0, 0); } - return has_url; + return has_url ? -1 : 0; } diff --git a/kitty/screen.h b/kitty/screen.h index 9dea8186e..cf85a9d55 100644 --- a/kitty/screen.h +++ b/kitty/screen.h @@ -150,6 +150,10 @@ typedef struct { bool is_set; } last_visited_prompt; PyObject *last_reported_cwd; + struct { + hyperlink_id_type id; + index_type x, y; + } current_hyperlink_under_mouse; } Screen; @@ -261,7 +265,7 @@ void screen_push_key_encoding_flags(Screen *self, uint32_t val); void screen_pop_key_encoding_flags(Screen *self, uint32_t num); uint8_t screen_current_key_encoding_flags(Screen *self); void screen_report_key_encoding_flags(Screen *self); -bool screen_detect_url(Screen *screen, unsigned int x, unsigned int y); +int screen_detect_url(Screen *screen, unsigned int x, unsigned int y); int screen_cursor_at_a_shell_prompt(const Screen *); bool screen_fake_move_cursor_to_position(Screen *, index_type x, index_type y); bool screen_send_signal_for_key(Screen *, char key); diff --git a/kitty/shaders.c b/kitty/shaders.c index ce9495a0e..b5458e76c 100644 --- a/kitty/shaders.c +++ b/kitty/shaders.c @@ -571,32 +571,38 @@ set_cell_uniforms(float current_inactive_text_alpha, bool force) { } static GLfloat -render_window_title(OSWindow *os_window, Screen *screen UNUSED, GLfloat xstart, GLfloat ystart, GLfloat width, Window *window, GLfloat left, GLfloat right) { +render_a_bar(OSWindow *os_window, Screen *screen, const CellRenderData *crd, WindowBarData *bar, PyObject *title, bool along_bottom) { + GLfloat left = os_window->viewport_width * (crd->gl.xstart + 1.f) / 2.f; + GLfloat right = left + os_window->viewport_width * crd->gl.width / 2.f; unsigned bar_height = os_window->fonts_data->cell_height + 2; if (!bar_height || right <= left) return 0; unsigned bar_width = (unsigned)ceilf(right - left); - if (!window->title_bar_data.buf || window->title_bar_data.width != bar_width || window->title_bar_data.height != bar_height) { - free(window->title_bar_data.buf); - Py_CLEAR(window->title_bar_data.last_drawn_title_object_id); - window->title_bar_data.buf = malloc((size_t)4 * bar_width * bar_height); - if (!window->title_bar_data.buf) return 0; - window->title_bar_data.height = bar_height; - window->title_bar_data.width = bar_width; + if (!bar->buf || bar->width != bar_width || bar->height != bar_height) { + free(bar->buf); + bar->buf = malloc((size_t)4 * bar_width * bar_height); + if (!bar->buf) return 0; + bar->height = bar_height; + bar->width = bar_width; + bar->needs_render = true; } - static char title[2048] = {0}; - if (window->title_bar_data.last_drawn_title_object_id != window->title) { - snprintf(title, arraysz(title), " %s", PyUnicode_AsUTF8(window->title)); + + if (bar->last_drawn_title_object_id != title || bar->needs_render) { + static char titlebuf[2048] = {0}; + if (!title) return 0; + snprintf(titlebuf, arraysz(titlebuf), " %s", PyUnicode_AsUTF8(title)); #define RGBCOL(which, fallback) ( 0xff000000 | colorprofile_to_color_with_fallback(screen->color_profile, screen->color_profile->overridden.which, screen->color_profile->configured.which, screen->color_profile->overridden.fallback, screen->color_profile->configured.fallback)) - if (!draw_window_title(os_window, title, RGBCOL(highlight_fg, default_fg), RGBCOL(highlight_bg, default_bg), window->title_bar_data.buf, bar_width, bar_height)) return 0; + if (!draw_window_title(os_window, titlebuf, RGBCOL(highlight_fg, default_fg), RGBCOL(highlight_bg, default_bg), bar->buf, bar_width, bar_height)) return 0; #undef RGBCOL - window->title_bar_data.last_drawn_title_object_id = window->title; - Py_INCREF(window->title_bar_data.last_drawn_title_object_id); + bar->last_drawn_title_object_id = title; + Py_INCREF(bar->last_drawn_title_object_id); } static ImageRenderData data = {.group_count=1}; - xstart = clamp_position_to_nearest_pixel(xstart, os_window->viewport_width); - ystart = clamp_position_to_nearest_pixel(ystart, os_window->viewport_height); + GLfloat xstart, ystart; + xstart = clamp_position_to_nearest_pixel(crd->gl.xstart, os_window->viewport_width); GLfloat height_gl = gl_size(bar_height, os_window->viewport_height); - gpu_data_for_image(&data, xstart, ystart, xstart + width, ystart - height_gl); + if (along_bottom) ystart = crd->gl.ystart - crd->gl.height + height_gl; + else ystart = clamp_position_to_nearest_pixel(crd->gl.ystart, os_window->viewport_height); + gpu_data_for_image(&data, xstart, ystart, xstart + crd->gl.width, ystart - height_gl); if (!data.texture_id) { glGenTextures(1, &data.texture_id); } glBindTexture(GL_TEXTURE_2D, data.texture_id); glPixelStorei(GL_UNPACK_ALIGNMENT, 1); @@ -604,7 +610,7 @@ render_window_title(OSWindow *os_window, Screen *screen UNUSED, GLfloat xstart, glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bar_width, bar_height, 0, GL_RGBA, GL_UNSIGNED_BYTE, window->title_bar_data.buf); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bar_width, bar_height, 0, GL_RGBA, GL_UNSIGNED_BYTE, bar->buf); set_cell_uniforms(1.f, false); bind_program(GRAPHICS_PROGRAM); send_graphics_data_to_gpu(1, os_window->gvao_idx, &data); @@ -615,6 +621,25 @@ render_window_title(OSWindow *os_window, Screen *screen UNUSED, GLfloat xstart, return height_gl; } +static void +draw_hyperlink_target(OSWindow *os_window, Screen *screen, const CellRenderData *crd, Window *window) { + WindowBarData *bd = &window->url_target_bar_data; + if (bd->hyperlink_id_for_title_object != screen->current_hyperlink_under_mouse.id) { + bd->hyperlink_id_for_title_object = screen->current_hyperlink_under_mouse.id; + Py_CLEAR(bd->last_drawn_title_object_id); + const char *url = get_hyperlink_for_id(screen->hyperlink_pool, bd->hyperlink_id_for_title_object, true); + if (url == NULL) url = ""; + PyObject *h = PyObject_CallMethod(global_state.boss, "sanitize_url_for_dispay_to_user", "s", url); + if (h == NULL) { PyErr_Print(); return; } + bd->last_drawn_title_object_id = h; + Py_INCREF(bd->last_drawn_title_object_id); + bd->needs_render = true; + } + if (bd->last_drawn_title_object_id == NULL) return; + const bool along_bottom = screen->lines < 3 || screen->current_hyperlink_under_mouse.y < screen->lines - 2; + render_a_bar(os_window, screen, crd, &window->title_bar_data, bd->last_drawn_title_object_id, along_bottom); +} + static void draw_window_logo(ssize_t vao_idx, OSWindow *os_window, const WindowLogoRenderData *wl, const CellRenderData *crd) { if (os_window->live_resize.in_progress) return; @@ -642,7 +667,7 @@ draw_window_number(OSWindow *os_window, Screen *screen, const CellRenderData *cr GLfloat title_bar_height = 0; size_t requested_height = (size_t)(os_window->viewport_height * crd->gl.height / 2.f); if (window->title && PyUnicode_Check(window->title) && (requested_height > (os_window->fonts_data->cell_height + 1) * 2)) { - title_bar_height = render_window_title(os_window, screen, crd->gl.xstart, crd->gl.ystart, crd->gl.width, window, left, right); + title_bar_height = render_a_bar(os_window, screen, crd, &window->title_bar_data, window->title, false); } GLfloat ystart = crd->gl.ystart, height = crd->gl.height, xstart = crd->gl.xstart, width = crd->gl.width; if (title_bar_height > 0) { @@ -933,6 +958,7 @@ draw_cells(ssize_t vao_idx, ssize_t gvao_idx, const ScreenRenderData *srd, float } if (window && screen->display_window_char) draw_window_number(os_window, screen, &crd, window); + if (OPT(show_hyperlink_targets) && window && screen->current_hyperlink_under_mouse.id) draw_hyperlink_target(os_window, screen, &crd, window); } // }}} diff --git a/kitty/state.c b/kitty/state.c index ba3d48edc..8bcea5c8a 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -335,6 +335,8 @@ destroy_window(Window *w) { Py_CLEAR(w->render_data.screen); Py_CLEAR(w->title); Py_CLEAR(w->title_bar_data.last_drawn_title_object_id); free(w->title_bar_data.buf); w->title_bar_data.buf = NULL; + Py_CLEAR(w->url_target_bar_data.last_drawn_title_object_id); + free(w->url_target_bar_data.buf); w->url_target_bar_data.buf = NULL; release_gpu_resources_for_window(w); if (w->window_logo.id) { decref_window_logo(global_state.all_window_logos, w->window_logo.id); diff --git a/kitty/state.h b/kitty/state.h index 165888eba..49ee3b51e 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -87,6 +87,7 @@ typedef struct { struct { float val; AdjustmentUnit unit; } underline_position, underline_thickness, strikethrough_position, strikethrough_thickness, cell_width, cell_height, baseline; + bool show_hyperlink_targets; } Options; typedef struct WindowLogoRenderData { @@ -126,6 +127,14 @@ typedef struct MousePosition { bool in_left_half_of_cell; } MousePosition; +typedef struct WindowBarData { + unsigned width, height; + uint8_t *buf; + PyObject *last_drawn_title_object_id; + hyperlink_id_type hyperlink_id_for_title_object; + bool needs_render; +} WindowBarData; + typedef struct { id_type id; bool visible, cursor_visible_at_last_render; @@ -142,11 +151,7 @@ typedef struct { ClickQueue click_queues[8]; monotonic_t last_drag_scroll_at; uint32_t last_special_key_pressed; - struct { - unsigned width, height; - uint8_t *buf; - PyObject *last_drawn_title_object_id; - } title_bar_data; + WindowBarData title_bar_data, url_target_bar_data; } Window; typedef struct {