From 169310760804815d11f5310e2dee349517d6388c Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 15 Oct 2023 19:56:41 +0530 Subject: [PATCH] A new escape code to change the shape of the mouse pointer Fixes #6711 --- docs/changelog.rst | 2 + docs/pointer-shapes.rst | 149 +++++++++++++++++++++++++++++++++ docs/protocol-extensions.rst | 1 + gen/cursors.py | 26 +++++- kitty/fast_data_types.pyi | 8 ++ kitty/glfw.c | 57 +++++++++++++ kitty/mouse.c | 25 +++++- kitty/parser.c | 1 + kitty/screen.c | 156 +++++++++++++++++++++++++++++++++++ kitty/screen.h | 4 + kitty/state.c | 13 +++ kitty/state.h | 1 + kitty/window.py | 40 +++++++++ kitty_tests/__init__.py | 6 +- kitty_tests/screen.py | 41 +++++++++ 15 files changed, 525 insertions(+), 5 deletions(-) create mode 100644 docs/pointer-shapes.rst diff --git a/docs/changelog.rst b/docs/changelog.rst index 7f3044232..5d1e739ee 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -48,6 +48,8 @@ Detailed list of changes - A new option :opt:`menu_map` that allows adding entries to the global menubar on macOS (:disc:`6680`) +- A new :doc:`escape code ` that can be used by programs running in the terminal to change the shape of the mouse pointer (:iss:`6711`) + - A new mouse action ``mouse_selection word_and_line_from_point`` to select the current word under the mouse cursor and extend to end of line (:pull:`6663`) - Allow using the full range of standard mouse cursor shapes when customizing the mouse cursor diff --git a/docs/pointer-shapes.rst b/docs/pointer-shapes.rst new file mode 100644 index 000000000..4750a21b8 --- /dev/null +++ b/docs/pointer-shapes.rst @@ -0,0 +1,149 @@ +Mouse pointer shapes +======================= + +This is a simple escape code that can be used by terminal programs to change +the shape of the mouse pointer. This is useful for buttons/links, dragging to +resize panes, etc. It is based on the original escape code proposal from xterm +however, it properly specifies names for the different cursors in a system +independent manner, adds a stack for easy push/pop of shapes, allows programs +to query support and specifies interaction with other terminal state. + +The escape code is of the form:: + + 22 ; \ + +Here, ```` is the bytes ``]`` and ```` is the byte ``0x1b``. +Spaces in the above are present for clarity only and should not be actually used. + +First some examples:: + + # Set the pointer to a pointing hand + 22 ; pointer \ + # Reset the pointer to default + 22 ; \ + # Push a shape onto the stack making it the current shape + 22 ; >wait \ + # Pop a shape off the stack restoring to the previous shape + 22 ; < \ + # Query the terminal for what the currently set shape is + 22 ; ?__current__ \ + +For more details see below. + +Setting the pointer shape +------------------------------- + +For set operations, the optional first char can be either ``=`` or omitted. +Follow the first char with the name of the shape. See the +:ref:`pointer_shape_names` table. + + +Pushing and popping shapes onto the stack +--------------------------------------------- + +The terminal emulator maintains a stack of shapes. To add shapes to the stack, +the optional first char must be ``>`` followed by a comma separated list of +shape names. See the :ref:`pointer_shape_names` table. All the specified names +are added to the stack, with the last name being the top of the stack and the +current shape. If the stack is full, the entry at the bottom of the stack is +evicted. Terminal implementations are free to choose an appropriate maximum +stack size, with a minimum stack size of 16. + +To pop shapes of the top of the stack the optional first char must be ``<``. +The comma separated list of names is ignored. Once the stack is empty further +pops have no effect. An empty stack means the terminal is free to use whatever +pointer shape it likes. + + +Querying support +------------------- + +Terminal programs can ask the terminal about this feature by setting the +optional first char to ``?``. The comma separated list of names is then +considered the query to which the terminal must respond with an OSC 22 code. +For example:: + + 22 ; ?__current__ \ + results in + 22 ; shape_name \ + +Here, ``shape_name`` will be a name from the table of shape names below or ``0`` +if the stack is empty, i.e., no shape is currently set. + +To check if the terminal supports some shapes, pass the shape names and the +terminal will reply with a comma separated list of zeros and ones where 1 means +the shape name is supported and zero means it is not. For example:: + + 22 ; ?pointer,crosshair,no-such-name,wait \ + results in + 22 ; 1,1,0,1 \ + +In addition to ``__current__`` there are a couple of other special names:: + + __default__ - The terminal responds with the shape name of the shape used by default + __grabbed__ - The terminal responds with the shape name of the shape used when the mouse is "grabbed" + + +Interaction with other terminal features +--------------------------------------------- + +The terminal must maintain separate shape stack for the *main* and *alternate* +screens. This allows full screen programs, which are likely to be the main +consumers of this feature, to easily temporarily, switch back from the alternate screen, +without needing to worry about pointer shape state. Think of suspending a +terminal editor to get back to the shell, for example. + +Resetting the terminal must empty both the shape stacks. + +When dragging to select text the terminal is free to ignore any mouse cursor +specified using this escape code in favor of one appropriate for dragging. +Similarly, when hovering over a URL or OSC 8 based hyperlink, the terminal may +choose to change the mouse cursor regardless of the value set by this escape +code. + + +.. _pointer_shape_names: + +Pointer shape names +---------------------------------- + +There is a well defined set of shape names that all conforming terminal +emulators must support. The list is based on the names used by the `cursor +property in the CSS standard +`__, click the link to +see representative images for the names. Valid names must consist of only the +characters from the set ``a-z0-9_-``. + +.. start list of shape css names (auto generated by gen-key-constants.py do not edit) + +#. alias +#. copy +#. crosshair +#. default +#. e-resize +#. ew-resize +#. grab +#. grabbing +#. help +#. move +#. n-resize +#. ne-resize +#. nesw-resize +#. no-drop +#. not-allowed +#. ns-resize +#. nw-resize +#. nwse-resize +#. pointer +#. progress +#. s-resize +#. se-resize +#. sw-resize +#. text +#. vertical-text +#. w-resize +#. wait +#. zoom-in +#. zoom-out + +.. end list of shape css names diff --git a/docs/protocol-extensions.rst b/docs/protocol-extensions.rst index 3398afe2d..610b691f6 100644 --- a/docs/protocol-extensions.rst +++ b/docs/protocol-extensions.rst @@ -29,6 +29,7 @@ please do so by opening issues in the `GitHub bug tracker keyboard-protocol file-transfer-protocol desktop-notifications + pointer-shapes unscroll color-stack deccara diff --git a/gen/cursors.py b/gen/cursors.py index 48b363dee..89004c857 100755 --- a/gen/cursors.py +++ b/gen/cursors.py @@ -14,7 +14,7 @@ if __name__ == '__main__' and not __package__: from .key_constants import patch_file # References for these names: -# CSS: https://developer.mozilla.org/en-US/docs/Web/CSS/cursor +# CSS:choices_for_{option.name} https://developer.mozilla.org/en-US/docs/Web/CSS/cursor # XCursor: https://tronche.com/gui/x/xlib/appendix/b/ + Absolute chaos # Wayland: https://wayland.app/protocols/cursor-shape-v1 # Cocoa: https://developer.apple.com/documentation/appkit/nscursor + secret apple selectors + SDL_cocoamouse.m @@ -59,11 +59,16 @@ grabbing grabbing grabbing,closedhand,dnd-none grabbing def main(args: List[str]=sys.argv) -> None: glfw_enum = [] + css_names = [] glfw_xc_map = {} glfw_xfont_map = [] kitty_to_enum_map = {} enum_to_glfw_map = {} + enum_to_css_map = {} glfw_cocoa_map = {} + glfw_css_map = {} + css_to_enum = {} + xc_to_enum = {} for line in cursors.splitlines(): line = line.strip() if line: @@ -73,6 +78,10 @@ def main(args: List[str]=sys.argv) -> None: glfw_name = 'GLFW_' + base + '_CURSOR' enum_name = base + '_POINTER' enum_to_glfw_map[enum_name] = glfw_name + enum_to_css_map[enum_name] = css + glfw_css_map[glfw_name] = css + css_to_enum[css] = enum_name + css_names.append(css) for n in names: kitty_to_enum_map[n] = enum_name glfw_enum.append(glfw_name) @@ -84,6 +93,9 @@ def main(args: List[str]=sys.argv) -> None: else: items = tuple('"' + x.replace('!', '') + '"' for x in xc) glfw_xfont_map.append(f'case {glfw_name}: return try_cursor_names(cursor, {len(items)}, {", ".join(items)});') + for x in xc: + x = x.lstrip('!') + xc_to_enum[x] = enum_name parts = cocoa.split(':', 1) if len(parts) == 1: if parts[0].startswith('_'): @@ -93,6 +105,9 @@ def main(args: List[str]=sys.argv) -> None: else: glfw_cocoa_map[glfw_name] = f'S({glfw_name}, {parts[0]}, {parts[1]});' + for x, v in xc_to_enum.items(): + if x not in css_to_enum: + css_to_enum[x] = v glfw_enum.append('GLFW_INVALID_CURSOR') patch_file('glfw/glfw3.h', 'mouse cursor shapes', '\n'.join(f' {x},' for x in glfw_enum)) @@ -109,7 +124,16 @@ def main(args: List[str]=sys.argv) -> None: f' case {k}: set_glfw_mouse_cursor(w, {v}); break;' for k, v in enum_to_glfw_map.items())) patch_file('kitty/glfw.c', 'name to glfw', '\n'.join( f' if (strcmp(name, "{k}") == 0) return {enum_to_glfw_map[v]};' for k, v in kitty_to_enum_map.items())) + patch_file('kitty/glfw.c', 'glfw to css', '\n'.join( + f' case {g}: return "{c}";' for g, c in glfw_css_map.items() + )) + patch_file('kitty/screen.c', 'enum to css', '\n'.join( + f' case {e}: ans = "{c}"; break;' for e, c in enum_to_css_map.items())) + patch_file('kitty/screen.c', 'css to enum', '\n'.join( + f' else if (strcmp("{c}", css_name) == 0) s = {e};' for c, e in css_to_enum.items())) patch_file('glfw/cocoa_window.m', 'glfw to cocoa', '\n'.join(f' {x}' for x in glfw_cocoa_map.values())) + patch_file('docs/pointer-shapes.rst', 'list of shape css names', '\n'.join( + f'#. {x}' if x else '' for x in [''] + sorted(css_names) + ['']), start_marker='.. ', end_marker='') subprocess.check_call(['glfw/glfw.py']) diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index ba820ed04..7b4b7df72 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -572,6 +572,10 @@ def set_custom_cursor( pass +def is_css_pointer_name_valid(name: str) -> bool: ... +def pointer_name_to_css_name(name: str) -> str: ... + + def load_png_data(data: bytes) -> Tuple[bytes, int, int]: pass @@ -1203,6 +1207,9 @@ class Screen: def line_edge_colors(self) -> Tuple[int, int]: pass + def current_pointer_shape(self) -> str: ... + def change_pointer_shape(self, op: str, name: str) -> None: ... + def set_tab_bar_render_data( os_window_id: int, screen: Screen, left: int, top: int, right: int, bottom: int @@ -1539,3 +1546,4 @@ def base64_encode(src: Union[bytes,str], add_padding: bool = False) -> bytes: .. def base64_decode(src: Union[bytes,str]) -> bytes: ... def cocoa_recreate_global_menu() -> None: ... def cocoa_clear_global_shortcuts() -> None: ... +def update_pointer_shape(os_window_id: int) -> None: ... diff --git a/kitty/glfw.c b/kitty/glfw.c index 0ab247773..7508a7964 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -1766,6 +1766,61 @@ pointer_name_to_glfw_name(const char *name) { return GLFW_INVALID_CURSOR; } +static PyObject* +is_css_pointer_name_valid(PyObject *self UNUSED, PyObject *name) { + if (!PyUnicode_Check(name)) { PyErr_SetString(PyExc_TypeError, "pointer name must be a string"); return NULL; } + const char *q = PyUnicode_AsUTF8(name); + if (strcmp(q, "default") == 0) { Py_RETURN_TRUE; } + if (pointer_name_to_glfw_name(q) == GLFW_INVALID_CURSOR) { Py_RETURN_FALSE; } + Py_RETURN_TRUE; +} + +static const char* +glfw_name_to_css_pointer_name(GLFWCursorShape q) { + switch(q) { + case GLFW_INVALID_CURSOR: return ""; + /* start glfw to css (auto generated by gen-key-constants.py do not edit) */ + case GLFW_DEFAULT_CURSOR: return "default"; + case GLFW_TEXT_CURSOR: return "text"; + case GLFW_POINTER_CURSOR: return "pointer"; + case GLFW_HELP_CURSOR: return "help"; + case GLFW_WAIT_CURSOR: return "wait"; + case GLFW_PROGRESS_CURSOR: return "progress"; + case GLFW_CROSSHAIR_CURSOR: return "crosshair"; + case GLFW_VERTICAL_TEXT_CURSOR: return "vertical-text"; + case GLFW_MOVE_CURSOR: return "move"; + case GLFW_E_RESIZE_CURSOR: return "e-resize"; + case GLFW_NE_RESIZE_CURSOR: return "ne-resize"; + case GLFW_NW_RESIZE_CURSOR: return "nw-resize"; + case GLFW_N_RESIZE_CURSOR: return "n-resize"; + case GLFW_SE_RESIZE_CURSOR: return "se-resize"; + case GLFW_SW_RESIZE_CURSOR: return "sw-resize"; + case GLFW_S_RESIZE_CURSOR: return "s-resize"; + case GLFW_W_RESIZE_CURSOR: return "w-resize"; + case GLFW_EW_RESIZE_CURSOR: return "ew-resize"; + case GLFW_NS_RESIZE_CURSOR: return "ns-resize"; + case GLFW_NESW_RESIZE_CURSOR: return "nesw-resize"; + case GLFW_NWSE_RESIZE_CURSOR: return "nwse-resize"; + case GLFW_ZOOM_IN_CURSOR: return "zoom-in"; + case GLFW_ZOOM_OUT_CURSOR: return "zoom-out"; + case GLFW_ALIAS_CURSOR: return "alias"; + case GLFW_COPY_CURSOR: return "copy"; + case GLFW_NOT_ALLOWED_CURSOR: return "not-allowed"; + case GLFW_NO_DROP_CURSOR: return "no-drop"; + case GLFW_GRAB_CURSOR: return "grab"; + case GLFW_GRABBING_CURSOR: return "grabbing"; +/* end glfw to css */ + } + return ""; +} + +static PyObject* +pointer_name_to_css_name(PyObject *self UNUSED, PyObject *name) { + if (!PyUnicode_Check(name)) { PyErr_SetString(PyExc_TypeError, "pointer name must be a string"); return NULL; } + GLFWCursorShape s = pointer_name_to_glfw_name(PyUnicode_AsUTF8(name)); + return PyUnicode_FromString(glfw_name_to_css_pointer_name(s)); +} + static PyObject* set_custom_cursor(PyObject *self UNUSED, PyObject *args) { int x=0, y=0; @@ -2016,6 +2071,8 @@ make_x11_window_a_dock_window(PyObject *self UNUSED, PyObject *args UNUSED) { static PyMethodDef module_methods[] = { METHODB(set_custom_cursor, METH_VARARGS), + METHODB(is_css_pointer_name_valid, METH_O), + METHODB(pointer_name_to_css_name, METH_O), {"create_os_window", (PyCFunction)(void (*) (void))(create_os_window), METH_VARARGS | METH_KEYWORDS, NULL}, METHODB(set_default_window_icon, METH_VARARGS), METHODB(set_clipboard_data_types, METH_VARARGS), diff --git a/kitty/mouse.c b/kitty/mouse.c index ca38eda54..69660fbe8 100644 --- a/kitty/mouse.c +++ b/kitty/mouse.c @@ -322,10 +322,15 @@ extend_selection(Window *w, bool ended, bool extend_nearest) { static void set_mouse_cursor_for_screen(Screen *screen) { - if (screen->modes.mouse_tracking_mode == NO_TRACKING) { - mouse_cursor_shape = OPT(default_pointer_shape); + MouseShape s = screen_pointer_shape(screen); + if (s != INVALID_POINTER) { + mouse_cursor_shape = s; } else { - mouse_cursor_shape = OPT(pointer_shape_when_grabbed); + if (screen->modes.mouse_tracking_mode == NO_TRACKING) { + mouse_cursor_shape = OPT(default_pointer_shape); + } else { + mouse_cursor_shape = OPT(pointer_shape_when_grabbed); + } } } @@ -664,6 +669,20 @@ focus_in_event(void) { set_mouse_cursor(mouse_cursor_shape); } +void +update_mouse_pointer_shape(void) { + mouse_cursor_shape = TEXT_POINTER; + bool in_tab_bar; + unsigned int window_idx = 0; + Window *w = window_for_event(&window_idx, &in_tab_bar); + if (in_tab_bar) { mouse_cursor_shape = POINTER_POINTER; } + else if (w && w->render_data.screen) { + screen_mark_url(w->render_data.screen, 0, 0, 0, 0); + set_mouse_cursor_for_screen(w->render_data.screen); + } + set_mouse_cursor(mouse_cursor_shape); +} + void enter_event(void) { #ifdef __APPLE__ diff --git a/kitty/parser.c b/kitty/parser.c index 6f69da014..56cdb13e4 100644 --- a/kitty/parser.c +++ b/kitty/parser.c @@ -433,6 +433,7 @@ dispatch_osc(Screen *screen, PyObject DUMP_UNUSED *dump_callback) { case 12: case 17: case 19: + case 22: case 110: case 111: case 112: diff --git a/kitty/screen.c b/kitty/screen.c index f5f340314..ecd6b1b56 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -169,6 +169,7 @@ static Line* range_line_(Screen *self, int y); void screen_reset(Screen *self) { + self->main_pointer_shape_stack.count = 0; self->alternate_pointer_shape_stack.count = 0; if (self->linebuf == self->alt_linebuf) screen_toggle_screen_buffer(self, true, true); if (screen_is_overlay_active(self)) { deactivate_overlay_line(self); @@ -1139,6 +1140,159 @@ screen_pop_key_encoding_flags(Screen *self, uint32_t num) { // Cursor {{{ +MouseShape +screen_pointer_shape(Screen *self) { + if (self->linebuf == self->main_linebuf) { + if (self->main_pointer_shape_stack.count) return self->main_pointer_shape_stack.stack[self->main_pointer_shape_stack.count-1]; + } else { + if (self->alternate_pointer_shape_stack.count) return self->alternate_pointer_shape_stack.stack[self->alternate_pointer_shape_stack.count-1]; + } + return INVALID_POINTER; +} + +static PyObject* +current_pointer_shape(Screen *self, PyObject *args UNUSED) { + MouseShape s = screen_pointer_shape(self); + const char *ans = "0"; + switch(s) { + case INVALID_POINTER: break; + /* start enum to css (auto generated by gen-key-constants.py do not edit) */ + case DEFAULT_POINTER: ans = "default"; break; + case TEXT_POINTER: ans = "text"; break; + case POINTER_POINTER: ans = "pointer"; break; + case HELP_POINTER: ans = "help"; break; + case WAIT_POINTER: ans = "wait"; break; + case PROGRESS_POINTER: ans = "progress"; break; + case CROSSHAIR_POINTER: ans = "crosshair"; break; + case VERTICAL_TEXT_POINTER: ans = "vertical-text"; break; + case MOVE_POINTER: ans = "move"; break; + case E_RESIZE_POINTER: ans = "e-resize"; break; + case NE_RESIZE_POINTER: ans = "ne-resize"; break; + case NW_RESIZE_POINTER: ans = "nw-resize"; break; + case N_RESIZE_POINTER: ans = "n-resize"; break; + case SE_RESIZE_POINTER: ans = "se-resize"; break; + case SW_RESIZE_POINTER: ans = "sw-resize"; break; + case S_RESIZE_POINTER: ans = "s-resize"; break; + case W_RESIZE_POINTER: ans = "w-resize"; break; + case EW_RESIZE_POINTER: ans = "ew-resize"; break; + case NS_RESIZE_POINTER: ans = "ns-resize"; break; + case NESW_RESIZE_POINTER: ans = "nesw-resize"; break; + case NWSE_RESIZE_POINTER: ans = "nwse-resize"; break; + case ZOOM_IN_POINTER: ans = "zoom-in"; break; + case ZOOM_OUT_POINTER: ans = "zoom-out"; break; + case ALIAS_POINTER: ans = "alias"; break; + case COPY_POINTER: ans = "copy"; break; + case NOT_ALLOWED_POINTER: ans = "not-allowed"; break; + case NO_DROP_POINTER: ans = "no-drop"; break; + case GRAB_POINTER: ans = "grab"; break; + case GRABBING_POINTER: ans = "grabbing"; break; +/* end enum to css */ + } + return PyUnicode_FromString(ans); +} + +static PyObject* +change_pointer_shape(Screen *self, PyObject *args) { + char op; const char *css_name, *b; + if (!PyArg_ParseTuple(args, "ss", &b, &css_name)) return NULL; + op = b[0]; + uint8_t *count, *stack; + if (self->main_linebuf == self->linebuf) { count = &self->main_pointer_shape_stack.count; stack = self->main_pointer_shape_stack.stack; } + else { count = &self->alternate_pointer_shape_stack.count; stack = self->alternate_pointer_shape_stack.stack; } + if (op == '<') { + if (*count) *count -= 1; + } else { + MouseShape s = INVALID_POINTER; + if (css_name[0] == 0) s = INVALID_POINTER; + /* start css to enum (auto generated by gen-key-constants.py do not edit) */ + else if (strcmp("default", css_name) == 0) s = DEFAULT_POINTER; + else if (strcmp("text", css_name) == 0) s = TEXT_POINTER; + else if (strcmp("pointer", css_name) == 0) s = POINTER_POINTER; + else if (strcmp("help", css_name) == 0) s = HELP_POINTER; + else if (strcmp("wait", css_name) == 0) s = WAIT_POINTER; + else if (strcmp("progress", css_name) == 0) s = PROGRESS_POINTER; + else if (strcmp("crosshair", css_name) == 0) s = CROSSHAIR_POINTER; + else if (strcmp("vertical-text", css_name) == 0) s = VERTICAL_TEXT_POINTER; + else if (strcmp("move", css_name) == 0) s = MOVE_POINTER; + else if (strcmp("e-resize", css_name) == 0) s = E_RESIZE_POINTER; + else if (strcmp("ne-resize", css_name) == 0) s = NE_RESIZE_POINTER; + else if (strcmp("nw-resize", css_name) == 0) s = NW_RESIZE_POINTER; + else if (strcmp("n-resize", css_name) == 0) s = N_RESIZE_POINTER; + else if (strcmp("se-resize", css_name) == 0) s = SE_RESIZE_POINTER; + else if (strcmp("sw-resize", css_name) == 0) s = SW_RESIZE_POINTER; + else if (strcmp("s-resize", css_name) == 0) s = S_RESIZE_POINTER; + else if (strcmp("w-resize", css_name) == 0) s = W_RESIZE_POINTER; + else if (strcmp("ew-resize", css_name) == 0) s = EW_RESIZE_POINTER; + else if (strcmp("ns-resize", css_name) == 0) s = NS_RESIZE_POINTER; + else if (strcmp("nesw-resize", css_name) == 0) s = NESW_RESIZE_POINTER; + else if (strcmp("nwse-resize", css_name) == 0) s = NWSE_RESIZE_POINTER; + else if (strcmp("zoom-in", css_name) == 0) s = ZOOM_IN_POINTER; + else if (strcmp("zoom-out", css_name) == 0) s = ZOOM_OUT_POINTER; + else if (strcmp("alias", css_name) == 0) s = ALIAS_POINTER; + else if (strcmp("copy", css_name) == 0) s = COPY_POINTER; + else if (strcmp("not-allowed", css_name) == 0) s = NOT_ALLOWED_POINTER; + else if (strcmp("no-drop", css_name) == 0) s = NO_DROP_POINTER; + else if (strcmp("grab", css_name) == 0) s = GRAB_POINTER; + else if (strcmp("grabbing", css_name) == 0) s = GRABBING_POINTER; + else if (strcmp("left_ptr", css_name) == 0) s = DEFAULT_POINTER; + else if (strcmp("xterm", css_name) == 0) s = TEXT_POINTER; + else if (strcmp("ibeam", css_name) == 0) s = TEXT_POINTER; + else if (strcmp("pointing_hand", css_name) == 0) s = POINTER_POINTER; + else if (strcmp("hand2", css_name) == 0) s = POINTER_POINTER; + else if (strcmp("hand", css_name) == 0) s = POINTER_POINTER; + else if (strcmp("question_arrow", css_name) == 0) s = HELP_POINTER; + else if (strcmp("whats_this", css_name) == 0) s = HELP_POINTER; + else if (strcmp("clock", css_name) == 0) s = WAIT_POINTER; + else if (strcmp("watch", css_name) == 0) s = WAIT_POINTER; + else if (strcmp("half-busy", css_name) == 0) s = PROGRESS_POINTER; + else if (strcmp("left_ptr_watch", css_name) == 0) s = PROGRESS_POINTER; + else if (strcmp("tcross", css_name) == 0) s = CROSSHAIR_POINTER; + else if (strcmp("fleur", css_name) == 0) s = MOVE_POINTER; + else if (strcmp("pointer-move", css_name) == 0) s = MOVE_POINTER; + else if (strcmp("right_side", css_name) == 0) s = E_RESIZE_POINTER; + else if (strcmp("top_right_corner", css_name) == 0) s = NE_RESIZE_POINTER; + else if (strcmp("top_left_corner", css_name) == 0) s = NW_RESIZE_POINTER; + else if (strcmp("top_side", css_name) == 0) s = N_RESIZE_POINTER; + else if (strcmp("bottom_right_corner", css_name) == 0) s = SE_RESIZE_POINTER; + else if (strcmp("bottom_left_corner", css_name) == 0) s = SW_RESIZE_POINTER; + else if (strcmp("bottom_side", css_name) == 0) s = S_RESIZE_POINTER; + else if (strcmp("left_side", css_name) == 0) s = W_RESIZE_POINTER; + else if (strcmp("sb_h_double_arrow", css_name) == 0) s = NWSE_RESIZE_POINTER; + else if (strcmp("split_h", css_name) == 0) s = EW_RESIZE_POINTER; + else if (strcmp("sb_v_double_arrow", css_name) == 0) s = NESW_RESIZE_POINTER; + else if (strcmp("split_v", css_name) == 0) s = NS_RESIZE_POINTER; + else if (strcmp("size_bdiag", css_name) == 0) s = NESW_RESIZE_POINTER; + else if (strcmp("size_fdiag", css_name) == 0) s = NWSE_RESIZE_POINTER; + else if (strcmp("zoom_in", css_name) == 0) s = ZOOM_IN_POINTER; + else if (strcmp("zoom_out", css_name) == 0) s = ZOOM_OUT_POINTER; + else if (strcmp("dnd-link", css_name) == 0) s = ALIAS_POINTER; + else if (strcmp("dnd-copy", css_name) == 0) s = COPY_POINTER; + else if (strcmp("forbidden", css_name) == 0) s = NOT_ALLOWED_POINTER; + else if (strcmp("crossed_circle", css_name) == 0) s = NOT_ALLOWED_POINTER; + else if (strcmp("dnd-no-drop", css_name) == 0) s = NO_DROP_POINTER; + else if (strcmp("openhand", css_name) == 0) s = GRAB_POINTER; + else if (strcmp("hand1", css_name) == 0) s = GRAB_POINTER; + else if (strcmp("closedhand", css_name) == 0) s = GRABBING_POINTER; + else if (strcmp("dnd-none", css_name) == 0) s = GRABBING_POINTER; +/* end css to enum */ + if (s == INVALID_POINTER && css_name[0] != 0) { PyErr_Format(PyExc_KeyError, "Not a known pointer shape: %s", css_name); return NULL; } + if (op == '=') { + if (!*count) *count += 1; + stack[*count - 1] = s; + } else if (op == '>') { + if ((*count + 1u) >= arraysz(self->main_pointer_shape_stack.stack)) { + remove_i_from_array(stack, 0, *count); + } + *count += 1; + stack[*count - 1] = s; + } else { + PyErr_SetString(PyExc_KeyError, "Not a known stack operation"); + return NULL; + } + } + Py_RETURN_NONE; +} + unsigned long screen_current_char_width(Screen *self) { unsigned long ans = 1; @@ -4392,6 +4546,8 @@ static PyMethodDef methods[] = { MND(insert_characters, METH_VARARGS) MND(delete_characters, METH_VARARGS) MND(erase_characters, METH_VARARGS) + MND(current_pointer_shape, METH_NOARGS) + MND(change_pointer_shape, METH_VARARGS) MND(cursor_up, METH_VARARGS) MND(cursor_up1, METH_VARARGS) MND(cursor_down, METH_VARARGS) diff --git a/kitty/screen.h b/kitty/screen.h index c6c74ee5b..5d8340eea 100644 --- a/kitty/screen.h +++ b/kitty/screen.h @@ -164,6 +164,9 @@ typedef struct { hyperlink_id_type id; index_type x, y; } current_hyperlink_under_mouse; + struct { + uint8_t stack[16], count; + } main_pointer_shape_stack, alternate_pointer_shape_stack; } Screen; @@ -211,6 +214,7 @@ void screen_cursor_forward(Screen *self, unsigned int count/*=1*/); void screen_cursor_down1(Screen *self, unsigned int count/*=1*/); void screen_cursor_up1(Screen *self, unsigned int count/*=1*/); void screen_cursor_to_line(Screen *screen, unsigned int line); +MouseShape screen_pointer_shape(Screen *self); void screen_insert_lines(Screen *self, unsigned int count/*=1*/); void screen_delete_lines(Screen *self, unsigned int count/*=1*/); void screen_repeat_character(Screen *self, unsigned int count); diff --git a/kitty/state.c b/kitty/state.c index 14bc30da6..2e2aa71b3 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -1257,6 +1257,18 @@ move_cursor_to_mouse_if_in_prompt(id_type os_window_id, id_type tab_id, id_type return moved; } +static PyObject* +pyupdate_pointer_shape(PyObject *self UNUSED, PyObject *args) { + id_type os_window_id; + PA("K", &os_window_id); + WITH_OS_WINDOW(os_window_id); + OSWindow *orig = global_state.callback_os_window; + global_state.callback_os_window = os_window; + update_mouse_pointer_shape(); + global_state.callback_os_window = orig; + END_WITH_OS_WINDOW; + Py_RETURN_NONE; +} static PyObject* pymouse_selection(PyObject *self UNUSED, PyObject *args) { @@ -1325,6 +1337,7 @@ KK5I(add_borders_rect) #define MW(name, arg_type) {#name, (PyCFunction)py##name, arg_type, NULL} static PyMethodDef module_methods[] = { + MW(update_pointer_shape, METH_VARARGS), MW(current_os_window, METH_NOARGS), MW(next_window_id, METH_NOARGS), MW(last_focused_os_window_id, METH_NOARGS), diff --git a/kitty/state.h b/kitty/state.h index 4c0a9482a..298007609 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -389,3 +389,4 @@ void set_ignore_os_keyboard_processing(bool enabled); void update_menu_bar_title(PyObject *title UNUSED); void change_live_resize_state(OSWindow*, bool); bool render_os_window(OSWindow *w, monotonic_t now, bool ignore_render_frames, bool scan_for_animated_images); +void update_mouse_pointer_shape(void); diff --git a/kitty/window.py b/kitty/window.py index 4c514f150..74ffa58cb 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -64,15 +64,18 @@ from .fast_data_types import ( get_boss, get_click_interval, get_options, + is_css_pointer_name_valid, last_focused_os_window_id, mark_os_window_dirty, mouse_selection, move_cursor_to_mouse_if_in_prompt, + pointer_name_to_css_name, pt_to_px, set_window_logo, set_window_padding, set_window_render_data, update_ime_position_for_window, + update_pointer_shape, update_window_title, update_window_visibility, wakeup_main_loop, @@ -1080,9 +1083,14 @@ class Window: identifier = sanitize_identifier_pat().sub('', identifier) self.screen.send_escape_code_to_child(OSC, f'99;i={identifier};') + def set_dynamic_color(self, code: int, value: Union[str, bytes]) -> None: if isinstance(value, bytes): value = value.decode('utf-8') + if code == 22: + ret = set_pointer_shape(self.screen, value, self.os_window_id) + if ret: + self.screen.send_escape_code_to_child(OSC, '22:' + ret) color_changes: Dict[DynamicColor, Optional[str]] = {} for val in value.split(';'): w = DYNAMIC_COLOR_CODES.get(code) @@ -1724,3 +1732,35 @@ class Window: url = docs_url(which) get_boss().open_url(url) # }}} + + +def set_pointer_shape(screen: Screen, value: str, os_window_id: int = 0) -> str: + op, ret = '=', '' + if value and value[0] in '><=?': + op = value[0] + value = value[1:] + if op in '=>': + for v in value.split(','): + screen.change_pointer_shape(op, v) + if os_window_id and current_focused_os_window_id() == os_window_id: + update_pointer_shape(os_window_id) + elif op == '<': + screen.change_pointer_shape('<', '') + if os_window_id and current_focused_os_window_id() == os_window_id: + update_pointer_shape(os_window_id) + elif op == '?': + ans = [] + for q in value.split(','): + if is_css_pointer_name_valid(q): + ans.append('1') + else: + if q == '__default__': + ans.append(pointer_name_to_css_name(get_options().default_pointer_shape)) + elif q == '__grabbed__': + ans.append(pointer_name_to_css_name(get_options().pointer_shape_when_grabbed)) + elif q == '__current__': + ans.append(screen.current_pointer_shape()) + else: + ans.append('0') + ret = ','.join(ans) + return ret diff --git a/kitty_tests/__init__.py b/kitty_tests/__init__.py index 92532fc20..c67f496e1 100644 --- a/kitty_tests/__init__.py +++ b/kitty_tests/__init__.py @@ -30,6 +30,7 @@ class Callbacks: self.clear() self.pty = pty self.ftc = None + self.set_pointer_shape = lambda data: None def write(self, data) -> None: self.wtcbuf += data @@ -41,7 +42,10 @@ class Callbacks: self.iconbuf += data def set_dynamic_color(self, code, data) -> None: - self.colorbuf += data or '' + if code == 22: + self.set_pointer_shape(data) + else: + self.colorbuf += data or '' def set_color_table_color(self, code, data) -> None: self.ctbuf += '' diff --git a/kitty_tests/screen.py b/kitty_tests/screen.py index 27846aad7..d9ac8d67a 100644 --- a/kitty_tests/screen.py +++ b/kitty_tests/screen.py @@ -1171,3 +1171,44 @@ class TestScreen(BaseTest): draw_prompt('p1') draw_prompt('p1') self.ae(lco(which=3), '0a\n1a') + + def test_pointer_shapes(self): + from kitty.window import set_pointer_shape + s = self.create_screen() + c = s.callbacks + response = '' + + def cb(data): + nonlocal response + response = set_pointer_shape(s, data) + c.set_pointer_shape = cb + + def send(a): + nonlocal response + response = '' + parse_bytes(s, f'\x1b]22;{a}\x1b\\'.encode()) + return response + + self.ae(send('?__current__'), '0') + self.ae(send('?__default__,__grabbed__,default,ne-resize,crosshair,XXX'), 'text,default,1,1,1,0') + + def t(q, e=None): + self.ae(send(q), '') + self.ae(send('?__current__'), e) + + t('default', 'default') + s.reset() + self.ae(send('?__current__'), '0') + t('=crosshair', 'crosshair') + t('<', '0') + t('=crosshair', 'crosshair') + t('', '0') + t('>help', 'help') + t('>wait', 'wait') + t('<', 'help') + t('<', '0') + t('default,help', 'help') + t('<', '0') + t('>default,help', 'help') + t('<', 'default') + t('<', '0')