diff --git a/docs/changelog.rst b/docs/changelog.rst index 44cdd0829..48001630d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -102,6 +102,8 @@ Detailed list of changes - Fix flickering of hyperlink underline when client program continuously redraws on mouse movement (:iss:`8414`) +- Wayland: Allow overriding the kitty OS Window icon on compositors that implement the xdg-toplevel-icon protocol + 0.40.0 [2025-03-08] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/faq.rst b/docs/faq.rst index ad3502af4..0d07fb15d 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -375,9 +375,11 @@ many alternate icons available, click on an icon to visit its homepage: :target: https://github.com/sfsam/some_icons :width: 256 -On macOS and X11 you can put :file:`kitty.app.icns` (macOS only) or :file:`kitty.app.png` in the +You can put :file:`kitty.app.icns` (macOS only) or :file:`kitty.app.png` in the :ref:`kitty configuration directory `, and this icon will be applied -automatically at startup. On X11, this will set the icon for kitty windows. +automatically at startup. On X11 and Wayland, this will set the icon for kitty windows. +Note that not all Wayland compositors support the `protocol needed `__ +for changing window icons. Unfortunately, on macOS, Apple's Dock does not change its cached icon so the custom icon will revert when kitty is quit. Run the following to force the Dock diff --git a/glfw/glfw3.h b/glfw/glfw3.h index c887460c4..4e4c85198 100644 --- a/glfw/glfw3.h +++ b/glfw/glfw3.h @@ -3000,10 +3000,6 @@ GLFWAPI void glfwSetWindowTitle(GLFWwindow* window, const char* title); * [Bundle Programming Guide](https://developer.apple.com/library/mac/documentation/CoreFoundation/Conceptual/CFBundles/) * in the Mac Developer Library. * - * @remark @wayland There is no existing protocol to change an icon, the - * window will thus inherit the one defined in the application's desktop file. - * This function will emit @ref GLFW_FEATURE_UNAVAILABLE. - * * @thread_safety This function must only be called from the main thread. * * @sa @ref window_icon diff --git a/glfw/source-info.json b/glfw/source-info.json index 1c61979f3..6ccc48970 100644 --- a/glfw/source-info.json +++ b/glfw/source-info.json @@ -84,6 +84,7 @@ "staging/fractional-scale/fractional-scale-v1.xml", "staging/single-pixel-buffer/single-pixel-buffer-v1.xml", "unstable/idle-inhibit/idle-inhibit-unstable-v1.xml", + "staging/xdg-toplevel-icon/xdg-toplevel-icon-v1.xml", "kwin-blur-v1.xml", "wlr-layer-shell-unstable-v1.xml" diff --git a/glfw/wl_init.c b/glfw/wl_init.c index 0e44df465..bc0f5f410 100644 --- a/glfw/wl_init.c +++ b/glfw/wl_init.c @@ -489,8 +489,7 @@ static void registryHandleGlobal(void* data UNUSED, } else if (is(wl_shm)) { - _glfw.wl.shm = - wl_registry_bind(registry, name, &wl_shm_interface, 1); + _glfw.wl.shm = wl_registry_bind(registry, name, &wl_shm_interface, 1); } else if (is(wl_output)) { @@ -601,6 +600,9 @@ static void registryHandleGlobal(void* data UNUSED, else if (is(zwp_idle_inhibit_manager_v1)) { _glfw.wl.idle_inhibit_manager = wl_registry_bind(registry, name, &zwp_idle_inhibit_manager_v1_interface, 1); } + else if (is(xdg_toplevel_icon_manager_v1)) { + _glfw.wl.xdg_toplevel_icon_manager_v1 = wl_registry_bind(registry, name, &xdg_toplevel_icon_manager_v1_interface, 1); + } #undef is } @@ -702,17 +704,17 @@ _glfwWaylandCompositorName(void) { static const char* get_compositor_missing_capabilities(void) { -#define C(title, x) if (!_glfw.wl.x) p += snprintf(buf, sizeof(buf) - (p - buf), "%s ", #title); - static char buf[256]; +#define C(title, x) if (!_glfw.wl.x) p += snprintf(p, sizeof(buf) - (p - buf), "%s ", #title); + static char buf[512]; char *p = buf; *p = 0; C(viewporter, wp_viewporter); C(fractional_scale, wp_fractional_scale_manager_v1); C(blur, org_kde_kwin_blur_manager); C(server_side_decorations, decorationManager); C(cursor_shape, wp_cursor_shape_manager_v1); C(layer_shell, zwlr_layer_shell_v1); C(single_pixel_buffer, wp_single_pixel_buffer_manager_v1); C(preferred_scale, has_preferred_buffer_scale); - C(idle_inhibit, idle_inhibit_manager); - if (_glfw.wl.xdg_wm_base_version < 6) p += snprintf(buf, sizeof(buf) - (p - buf), "%s ", "window-state-suspended"); - if (_glfw.wl.xdg_wm_base_version < 5) p += snprintf(buf, sizeof(buf) - (p - buf), "%s ", "window-capabilities"); + C(idle_inhibit, idle_inhibit_manager); C(icon, xdg_toplevel_icon_manager_v1); + if (_glfw.wl.xdg_wm_base_version < 6) p += snprintf(p, sizeof(buf) - (p - buf), "%s ", "window-state-suspended"); + if (_glfw.wl.xdg_wm_base_version < 5) p += snprintf(p, sizeof(buf) - (p - buf), "%s ", "window-capabilities"); #undef C while (p > buf && (p - 1)[0] == ' ') { p--; *p = 0; } return buf; @@ -885,6 +887,8 @@ void _glfwPlatformTerminate(void) zwp_primary_selection_device_manager_v1_destroy(_glfw.wl.primarySelectionDeviceManager); if (_glfw.wl.xdg_activation_v1) xdg_activation_v1_destroy(_glfw.wl.xdg_activation_v1); + if (_glfw.wl.xdg_toplevel_icon_manager_v1) + xdg_toplevel_icon_manager_v1_destroy(_glfw.wl.xdg_toplevel_icon_manager_v1); if (_glfw.wl.wp_single_pixel_buffer_manager_v1) wp_single_pixel_buffer_manager_v1_destroy(_glfw.wl.wp_single_pixel_buffer_manager_v1); if (_glfw.wl.wp_cursor_shape_manager_v1) diff --git a/glfw/wl_platform.h b/glfw/wl_platform.h index 007b8b532..30b2ce56e 100644 --- a/glfw/wl_platform.h +++ b/glfw/wl_platform.h @@ -66,6 +66,7 @@ typedef VkBool32 (APIENTRY *PFN_vkGetPhysicalDeviceWaylandPresentationSupportKHR #include "wayland-wlr-layer-shell-unstable-v1-client-protocol.h" #include "wayland-single-pixel-buffer-v1-client-protocol.h" #include "wayland-idle-inhibit-unstable-v1-client-protocol.h" +#include "wayland-xdg-toplevel-icon-v1-client-protocol.h" #define _glfw_dlopen(name) dlopen(name, RTLD_LAZY | RTLD_LOCAL) #define _glfw_dlclose(handle) dlclose(handle) @@ -332,6 +333,7 @@ typedef struct _GLFWlibraryWayland struct zwp_primary_selection_device_v1* primarySelectionDevice; struct zwp_primary_selection_source_v1* dataSourceForPrimarySelection; struct xdg_activation_v1* xdg_activation_v1; + struct xdg_toplevel_icon_manager_v1* xdg_toplevel_icon_manager_v1; struct wp_cursor_shape_manager_v1* wp_cursor_shape_manager_v1; struct wp_cursor_shape_device_v1* wp_cursor_shape_device_v1; struct wp_fractional_scale_manager_v1 *wp_fractional_scale_manager_v1; diff --git a/glfw/wl_window.c b/glfw/wl_window.c index 5a377d1d5..7fa344265 100644 --- a/glfw/wl_window.c +++ b/glfw/wl_window.c @@ -101,6 +101,19 @@ get_activation_token( #undef fail } +static void +convert_glfw_image_to_wayland_image(const GLFWimage* image, unsigned char *target) { + // convert RGBA non-premultiplied to ARGB pre-multiplied + unsigned char* source = (unsigned char*) image->pixels; + for (int i = 0; i < image->width * image->height; i++, source += 4) { + unsigned int alpha = source[3]; + *target++ = (unsigned char) ((source[2] * alpha) / 255); + *target++ = (unsigned char) ((source[1] * alpha) / 255); + *target++ = (unsigned char) ((source[0] * alpha) / 255); + *target++ = (unsigned char) alpha; + } +} + static struct wl_buffer* createShmBuffer(const GLFWimage* image, bool is_opaque, bool init_data) { struct wl_shm_pool* pool; @@ -108,7 +121,7 @@ static struct wl_buffer* createShmBuffer(const GLFWimage* image, bool is_opaque, int stride = image->width * 4; int length = image->width * image->height * 4; void* data; - int fd, i; + int fd; fd = createAnonymousFile(length); if (fd < 0) @@ -131,19 +144,7 @@ static struct wl_buffer* createShmBuffer(const GLFWimage* image, bool is_opaque, pool = wl_shm_create_pool(_glfw.wl.shm, fd, length); close(fd); - if (init_data) { - unsigned char* source = (unsigned char*) image->pixels; - unsigned char* target = data; - for (i = 0; i < image->width * image->height; i++, source += 4) - { - unsigned int alpha = source[3]; - - *target++ = (unsigned char) ((source[2] * alpha) / 255); - *target++ = (unsigned char) ((source[1] * alpha) / 255); - *target++ = (unsigned char) ((source[0] * alpha) / 255); - *target++ = (unsigned char) alpha; - } - } + if (init_data) convert_glfw_image_to_wayland_image(image, data); buffer = wl_shm_pool_create_buffer(pool, 0, @@ -1465,11 +1466,54 @@ void _glfwPlatformSetWindowTitle(_GLFWwindow* window, const char* title) } } -void _glfwPlatformSetWindowIcon(_GLFWwindow* window UNUSED, - int count UNUSED, const GLFWimage* images UNUSED) -{ - _glfwInputError(GLFW_FEATURE_UNAVAILABLE, - "Wayland: The platform does not support setting the window icon"); +void +_glfwPlatformSetWindowIcon(_GLFWwindow* window, int count, const GLFWimage* images) { + if (!_glfw.wl.xdg_toplevel_icon_manager_v1) { + static bool warned_once = false; + if (!warned_once) { + _glfwInputError(GLFW_FEATURE_UNAVAILABLE, "Wayland: The compositor does not support changing window icons"); + warned_once = true; + } + } + if (!count) { + xdg_toplevel_icon_manager_v1_set_icon(_glfw.wl.xdg_toplevel_icon_manager_v1, window->wl.xdg.toplevel, NULL); + return; + } + struct wl_buffer* *buffers = malloc(sizeof(struct wl_buffer*) * count); + if (!buffers) return; + size_t total_data_size = 0; + for (int i = 0; i < count; i++) total_data_size += images[i].width * images[i].height * 4; + int fd = createAnonymousFile(total_data_size); + if (fd < 0) { + _glfwInputError(GLFW_PLATFORM_ERROR, "Wayland: Creating a buffer file for %ld B failed: %s", (long)total_data_size, strerror(errno)); + free(buffers); + return; + } + unsigned char *data = mmap(NULL, total_data_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (data == MAP_FAILED) { + _glfwInputError(GLFW_PLATFORM_ERROR, "Wayland: mmap failed: %s", strerror(errno)); + free(buffers); + close(fd); + return; + } + struct wl_shm_pool* pool = wl_shm_create_pool(_glfw.wl.shm, fd, total_data_size); + struct xdg_toplevel_icon_v1 *icon = xdg_toplevel_icon_manager_v1_create_icon(_glfw.wl.xdg_toplevel_icon_manager_v1); + size_t pos = 0; + for (int i = 0; i < count; i++) { + const size_t sz = images[i].width * images[i].height * 4; + convert_glfw_image_to_wayland_image(images + i, data + pos); + buffers[i] = wl_shm_pool_create_buffer( + pool, pos, images[i].width, images[i].height, images[i].width * 4, WL_SHM_FORMAT_ARGB8888); + xdg_toplevel_icon_v1_add_buffer(icon, buffers[i], 1); + pos += sz; + } + xdg_toplevel_icon_manager_v1_set_icon(_glfw.wl.xdg_toplevel_icon_manager_v1, window->wl.xdg.toplevel, icon); + xdg_toplevel_icon_v1_destroy(icon); + for (int i = 0; i < count; i++) wl_buffer_destroy(buffers[i]); + free(buffers); + wl_shm_pool_destroy(pool); + munmap(data, total_data_size); + close(fd); } void _glfwPlatformGetWindowPos(_GLFWwindow* window UNUSED, int* xpos UNUSED, int* ypos UNUSED) diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index 8c9d9bbec..84487e5ad 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -654,6 +654,9 @@ def set_default_window_icon(path: str) -> None: pass +def set_os_window_icon(os_window_id: int, path: str | None | bytes = None) -> None: ... + + def set_custom_cursor( cursor_shape: str, images: Tuple[Tuple[bytes, int, int], ...], diff --git a/kitty/glfw.c b/kitty/glfw.c index c1f16a667..536c85261 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -767,6 +767,45 @@ set_default_window_icon(PyObject UNUSED *self, PyObject *args) { Py_RETURN_NONE; } +static PyObject* +set_os_window_icon(PyObject UNUSED *self, PyObject *args) { + size_t sz; + unsigned int width, height; + PyObject *what = NULL; + uint8_t *data; + unsigned long long id; + if(!PyArg_ParseTuple(args, "K|O", &id, &what)) return NULL; + OSWindow *os_window = os_window_for_id(id); + if (!os_window) { PyErr_Format(PyExc_KeyError, "No OS Window with id: %llu", id); return NULL; } + if (!what || what == Py_None) { + glfwSetWindowIcon(os_window->handle, 0, NULL); + Py_RETURN_NONE; + } + if (PyUnicode_Check(what)) { + const char *path = PyUnicode_AsUTF8(what); + if (png_path_to_bitmap(path, &data, &width, &height, &sz)) { + GLFWimage img = { .pixels = data, .width = width, .height = height }; + glfwSetWindowIcon(os_window->handle, 1, &img); + free(data); + } else { + PyErr_Format(PyExc_ValueError, "%s is not a valid PNG image", path); + return NULL; + } + Py_RETURN_NONE; + } + RAII_PY_BUFFER(buf); + if(!PyArg_ParseTuple(args, "Ky*", &id, &buf)) return NULL; + if (png_from_data(buf.buf, buf.len, "", &data, &width, &height, &sz)) { + GLFWimage img = { .pixels = data, .width = width, .height = height }; + glfwSetWindowIcon(os_window->handle, 1, &img); + } else { + PyErr_Format(PyExc_ValueError, "The supplied data of %lu bytes is not a valid PNG image", (unsigned long)buf.len); + return NULL; + } + Py_RETURN_NONE; +} + + void* make_os_window_context_current(OSWindow *w) { @@ -2323,6 +2362,7 @@ static PyMethodDef module_methods[] = { 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_os_window_icon, METH_VARARGS), METHODB(set_clipboard_data_types, METH_VARARGS), METHODB(get_clipboard_mime, METH_VARARGS), METHODB(toggle_secure_input, METH_NOARGS), diff --git a/kitty/main.py b/kitty/main.py index b52d21534..1dd31f695 100644 --- a/kitty/main.py +++ b/kitty/main.py @@ -156,17 +156,19 @@ def get_icon128_path(base_path: str) -> str: return f'{path}-128{ext}' -def set_x11_window_icon() -> None: +def set_window_icon() -> None: custom_icon_path = get_custom_window_icon()[1] + is_x11 = not is_macos and not is_wayland() try: if custom_icon_path is not None: custom_icon128_path = get_icon128_path(custom_icon_path) - if safe_mtime(custom_icon128_path) is None: - set_default_window_icon(custom_icon_path) - else: + if is_x11 and safe_mtime(custom_icon128_path) is not None: set_default_window_icon(custom_icon128_path) + else: + set_default_window_icon(custom_icon_path) else: - set_default_window_icon(get_icon128_path(logo_png_file)) + if is_x11: + set_default_window_icon(get_icon128_path(logo_png_file)) except ValueError as err: log_error(err) @@ -217,8 +219,7 @@ def _run_app(opts: Options, args: CLIOptions, bad_lines: Sequence[BadLine] = (), set_macos_app_custom_icon() else: global_shortcuts = {} - if not is_wayland(): # no window icons on wayland - set_x11_window_icon() + set_window_icon() with cached_values_for(run_app.cached_values_name) as cached_values: startup_sessions = tuple(create_sessions(opts, args, default_session=opts.startup_session))