From 5722acad4f601bd31b77c434a2fb29f71d7d0b4e Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 9 Mar 2026 08:35:28 +0530 Subject: [PATCH 01/70] remote_control_script: resolve relative paths with respect to kitty config directory Fixes #9625 --- docs/changelog.rst | 3 +++ docs/remote-control.rst | 3 ++- kitty/boss.py | 5 ++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 7d99d7988..95f0823e9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -269,6 +269,9 @@ Detailed list of changes - Allow double clicking on a tab to rename it (:pull:`9609`) +- :ac:`remote_control_script` resolve relative paths with respect to kitty + config directory (:iss:`9625`) + 0.45.0 [2025-12-24] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/remote-control.rst b/docs/remote-control.rst index 8da691a98..cc3c68f9f 100644 --- a/docs/remote-control.rst +++ b/docs/remote-control.rst @@ -298,7 +298,8 @@ control commands as you like and process their output. :ac:`launch` command with ``--type=background --allow-remote-control``. For more advanced usage, including fine grained permissions, setting env vars, command line interpolation, passing data to STDIN, etc. -the :doc:`launch ` command should be used. +the :doc:`launch ` command should be used. Relative paths to scripts +are interpreted with respect to the kitty config directory. .. note:: You do not need :opt:`allow_remote_control` to use these mappings, as they are not actual remote programs, but are simply a way to reuse the diff --git a/kitty/boss.py b/kitty/boss.py index 52f9658e6..b8f9603f9 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -165,6 +165,7 @@ from .utils import ( parse_address_spec, parse_os_window_state, platform_window_id, + resolve_custom_file, safe_print, sanitize_url_for_display_to_user, shlex_split, @@ -855,9 +856,11 @@ class Boss: map f1 remote_control_script /path/to/script arg1 arg2 ... - See :ref:`rc_mapping` for details. + See :ref:`rc_mapping` for details. Relative paths are resolved with respect + to the kitty config directory. ''') def remote_control_script(self, path: str, *args: str) -> None: + path = resolve_custom_file(path) path = which(path) or path if not os.access(path, os.X_OK): self.show_error('Remote control script not executable', f'The script {path} is not executable check its permissions') From 6d4ee1aa1dfc101662bb76cfd05e6d2ea1d542d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 03:43:19 +0000 Subject: [PATCH 02/70] Bump github.com/shirou/gopsutil/v4 in the all-go-deps group Bumps the all-go-deps group with 1 update: [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil). Updates `github.com/shirou/gopsutil/v4` from 4.26.1 to 4.26.2 - [Release notes](https://github.com/shirou/gopsutil/releases) - [Commits](https://github.com/shirou/gopsutil/compare/v4.26.1...v4.26.2) --- updated-dependencies: - dependency-name: github.com/shirou/gopsutil/v4 dependency-version: 4.26.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-go-deps ... Signed-off-by: dependabot[bot] --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 5f3a50421..88c59cbad 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/kovidgoyal/imaging v1.8.20 github.com/nwaples/rardecode/v2 v2.2.2 github.com/seancfoley/ipaddress-go v1.7.1 - github.com/shirou/gopsutil/v4 v4.26.1 + github.com/shirou/gopsutil/v4 v4.26.2 github.com/ulikunitz/xz v0.5.15 github.com/zeebo/xxh3 v1.1.0 golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b @@ -36,7 +36,7 @@ require ( // replace github.com/kovidgoyal/imaging => ../imaging require ( - github.com/ebitengine/purego v0.9.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a // indirect diff --git a/go.sum b/go.sum index a97bcc882..3f9cc2516 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= -github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -52,8 +52,8 @@ github.com/seancfoley/bintree v1.3.1 h1:cqmmQK7Jm4aw8gna0bP+huu5leVOgHGSJBEpUx3E github.com/seancfoley/bintree v1.3.1/go.mod h1:hIUabL8OFYyFVTQ6azeajbopogQc2l5C/hiXMcemWNU= github.com/seancfoley/ipaddress-go v1.7.1 h1:fDWryS+L8iaaH5RxIKbY0xB5Z+Zxk8xoXLN4S4eAPdQ= github.com/seancfoley/ipaddress-go v1.7.1/go.mod h1:TQRZgv+9jdvzHmKoPGBMxyiaVmoI0rYpfEk8Q/sL/Iw= -github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo= -github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc= +github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= +github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= From 551acca0e470cba9b43ab631d1a62fbfa3e6b56b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:26:14 +0000 Subject: [PATCH 03/70] Implement maximize layout action for splits layout Fixes #9629 Fixes #9630 --- docs/layouts.rst | 16 +++++- kitty/layout/splits.py | 114 ++++++++++++++++++++++++++++++++++++++++- kitty_tests/layout.py | 53 +++++++++++++++++++ 3 files changed, 181 insertions(+), 2 deletions(-) diff --git a/docs/layouts.rst b/docs/layouts.rst index aa41d4da6..b33feae0d 100644 --- a/docs/layouts.rst +++ b/docs/layouts.rst @@ -184,10 +184,24 @@ define a few extra key bindings in :file:`kitty.conf`:: # window's size. map ctrl+. layout_action bias 80 + # Maximize the active window along the horizontal axis (fill full width), + # keeping other windows visible in their vertical positions. Press again to + # restore the original layout. + map ctrl+shift+right layout_action maximize horizontal + + # Maximize the active window along the vertical axis (fill full height), + # keeping other windows visible in their horizontal positions. Press again + # to restore the original layout. + map ctrl+shift+up layout_action maximize vertical + Windows can be resized using :ref:`window_resizing`. You can swap the windows in a split using the ``rotate`` action with an argument of ``180`` and rotate -and swap with an argument of ``270``. +and swap with an argument of ``270``. The ``maximize`` action expands the active +window to fill the maximum available space along a single axis while keeping +the rest of the layout intact. Use ``maximize horizontal`` to fill the full +width and ``maximize vertical`` to fill the full height. Calling it again +restores the original split sizes. This layout takes one option, ``split_axis`` that controls whether new windows are placed into vertical or horizontal splits when a :option:`--location diff --git a/kitty/layout/splits.py b/kitty/layout/splits.py index e331c0a73..f99ba70ab 100644 --- a/kitty/layout/splits.py +++ b/kitty/layout/splits.py @@ -502,6 +502,44 @@ class Pair: return False return self.two.is_group_on_second(gid) + def find_window_in_tree(self, window_id: int) -> 'list[tuple[Pair, bool]] | None': + # Returns list of (pair, is_in_one) from self down to the pair containing window_id. + if self.one == window_id: + return [(self, True)] + if self.two == window_id: + return [(self, False)] + if isinstance(self.one, Pair): + path = self.one.find_window_in_tree(window_id) + if path is not None: + return [(self, True)] + path + if isinstance(self.two, Pair): + path = self.two.find_window_in_tree(window_id) + if path is not None: + return [(self, False)] + path + return None + + def path_from_root(self, target: 'Pair') -> 'list[str] | None': + if self is target: + return [] + if isinstance(self.one, Pair): + sub = self.one.path_from_root(target) + if sub is not None: + return ['one'] + sub + if isinstance(self.two, Pair): + sub = self.two.path_from_root(target) + if sub is not None: + return ['two'] + sub + return None + + def pair_at_path(self, path: 'list[str]') -> 'Pair | None': + current: Pair = self + for step in path: + child = current.one if step == 'one' else current.two + if not isinstance(child, Pair): + return None + current = child + return current + class SplitsLayoutOpts(LayoutOpts): @@ -749,6 +787,46 @@ class Splits(Layout): if pair is not None: pair.set_bias(wg.id, bias) return True + elif action_name == 'maximize': + args = args or ('horizontal',) + axis = args[0] + is_horizontal = axis == 'horizontal' + wg = all_windows.active_group + if wg is not None: + key = (wg.id, is_horizontal) + maximized_biases: dict[tuple[int, bool], list[tuple[Pair, float]]] = getattr(self, '_maximized_biases', {}) + if key in maximized_biases: + # Already maximized along this axis for this window — toggle back + current_pair_ids = {id(p) for p in self.pairs_root.self_and_descendants()} + for pair_ref, saved_bias in maximized_biases.pop(key): + if id(pair_ref) in current_pair_ids: + pair_ref.bias = saved_bias + self._maximized_biases = maximized_biases + return True + else: + # Undo any existing maximize along the same axis (different window) + stale_keys = [k for k in maximized_biases if k[1] == is_horizontal] + if stale_keys: + current_pair_ids = {id(p) for p in self.pairs_root.self_and_descendants()} + for k in stale_keys: + for pair_ref, saved_bias in maximized_biases.pop(k): + if id(pair_ref) in current_pair_ids: + pair_ref.bias = saved_bias + # Maximize: set biases along the path to give maximum space to active window + # Only adjust pairs whose split axis matches the requested direction: + # horizontal maximize expands width (affects horizontal/side-by-side splits), + # vertical maximize expands height (affects vertical/top-bottom splits). + tree_path = self.pairs_root.find_window_in_tree(wg.id) + if tree_path is not None: + saved_biases: list[tuple[Pair, float]] = [] + for pair, is_in_one in tree_path: + if pair.horizontal == is_horizontal and not pair.is_redundant: + saved_biases.append((pair, pair.bias)) + pair.bias = 1.0 if is_in_one else 0.0 + if saved_biases: + maximized_biases[key] = saved_biases + self._maximized_biases = maximized_biases + return True return None @@ -818,7 +896,25 @@ class Splits(Layout): return ans def layout_state(self) -> dict[str, Any]: - return {'pairs': self.pairs_root.serialize()} + ans: dict[str, Any] = {'pairs': self.pairs_root.serialize()} + maximized_biases: dict[tuple[int, bool], list[tuple[Pair, float]]] = getattr(self, '_maximized_biases', {}) + if maximized_biases: + serialized_maximized = [] + for (window_id, is_horizontal), saved_biases_list in maximized_biases.items(): + entries = [] + for pair_ref, saved_bias in saved_biases_list: + path = self.pairs_root.path_from_root(pair_ref) + if path is not None: + entries.append({'path': path, 'bias': saved_bias}) + if entries: + serialized_maximized.append({ + 'window_id': window_id, + 'is_horizontal': is_horizontal, + 'saved_biases': entries, + }) + if serialized_maximized: + ans['maximized'] = serialized_maximized + return ans def set_layout_state(self, layout_state: dict[str, Any], map_group_id: WindowMapper) -> bool: new_root = Pair() @@ -827,5 +923,21 @@ class Splits(Layout): if before == frozenset(new_root.all_window_ids()): self.pairs_root = new_root self.layout_opts = SplitsLayoutOpts(layout_state['opts']) + if 'maximized' in layout_state: + maximized_biases: dict[tuple[int, bool], list[tuple[Pair, float]]] = {} + for entry in layout_state['maximized']: + new_window_id = map_group_id(entry['window_id']) + if new_window_id is None: + continue + is_horizontal: bool = entry['is_horizontal'] + saved_biases_list: list[tuple[Pair, float]] = [] + for saved in entry['saved_biases']: + pair = new_root.pair_at_path(saved['path']) + if pair is not None: + saved_biases_list.append((pair, saved['bias'])) + if saved_biases_list: + maximized_biases[(new_window_id, is_horizontal)] = saved_biases_list + if maximized_biases: + self._maximized_biases = maximized_biases return True return False diff --git a/kitty_tests/layout.py b/kitty_tests/layout.py index 3f045c5c3..ad2c45f12 100644 --- a/kitty_tests/layout.py +++ b/kitty_tests/layout.py @@ -5,6 +5,7 @@ from kitty.config import defaults from kitty.fast_data_types import Region from kitty.layout.base import lgd from kitty.layout.interface import Grid, Horizontal, Splits, Stack, Tall +from kitty.layout.splits import Pair from kitty.types import WindowGeometry from kitty.window import EdgeWidths from kitty.window_list import WindowList, reset_group_id_counter @@ -271,3 +272,55 @@ class TestLayout(BaseTest): self.ae(q.neighbors_for_window(windows[1], all_windows), {'left': [1], 'bottom': [3, 4]}) self.ae(q.neighbors_for_window(windows[2], all_windows), {'left': [1], 'right': [4], 'top': [2]}) self.ae(q.neighbors_for_window(windows[3], all_windows), {'left': [3], 'top': [2]}) + + def test_splits_maximize(self): + q = create_layout(Splits) + all_windows = create_windows(q, num=0) + w1 = Window(1) + q.add_window(all_windows, w1) + w2 = Window(2) + q.add_window(all_windows, w2, location='vsplit') + w3 = Window(3) + q.add_window(all_windows, w3, location='hsplit') + # Layout: w1 | (w2 above w3) — horizontal split at root, vertical split on right + root = q.pairs_root + # root is horizontal, containing w1 and [w2/w3 vertical pair] + self.ae(root.horizontal, True) + right_pair = root.two if isinstance(root.two, Pair) else root.one + self.assertIsInstance(right_pair, Pair) + + # Focus window 3 (bottom-right) + all_windows.set_active_group_idx(all_windows.groups.index(all_windows.group_for_window(w3))) + + # Save original biases + root_bias_before = root.bias + right_pair_bias_before = right_pair.bias + + # maximize vertical (fill full height) — affects vertical (horizontal==False) pairs + result = q.layout_action('maximize', ('vertical',), all_windows) + self.assertTrue(result) + # right_pair is vertical (horizontal==False) so its bias should be 0.0 (w3 is in 'two') + self.ae(right_pair.bias, 0.0) + # root is horizontal so its bias should be unchanged + self.ae(root.bias, root_bias_before) + # _maximized_biases should track w3's vertical maximize + self.assertIn((all_windows.active_group.id, False), q._maximized_biases) + + # Toggle back + result = q.layout_action('maximize', ('vertical',), all_windows) + self.assertTrue(result) + self.ae(right_pair.bias, right_pair_bias_before) + self.ae(getattr(q, '_maximized_biases', {}), {}) + + # maximize horizontal (fill full width) — affects horizontal pairs + result = q.layout_action('maximize', ('horizontal',), all_windows) + self.assertTrue(result) + # root is horizontal, w3 is under root.two (right side), so bias should be 0.0 + self.ae(root.bias, 0.0) + # right_pair is vertical, so unchanged + self.ae(right_pair.bias, right_pair_bias_before) + + # Toggle back + result = q.layout_action('maximize', ('horizontal',), all_windows) + self.assertTrue(result) + self.ae(root.bias, root_bias_before) From 06b074d68dadb1c463d282feeac37b8be3368ecb Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 9 Mar 2026 15:29:39 +0530 Subject: [PATCH 04/70] Update changelog --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 95f0823e9..bce640f65 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -272,6 +272,9 @@ Detailed list of changes - :ac:`remote_control_script` resolve relative paths with respect to kitty config directory (:iss:`9625`) +- Splits layout: Add new mappable actions to maximize a window in the splits + layout (:iss:`9629`) + 0.45.0 [2025-12-24] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 4706243380125b74dc61474a339d29bd3d93b20c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:09:47 +0000 Subject: [PATCH 05/70] Fix GLFW drop API to always present full original MIME type list to callbacks Fixes #9633 --- glfw/cocoa_platform.h | 5 +++- glfw/cocoa_window.m | 62 ++++++++++++++++++++++++++------------- glfw/input.c | 8 ++++- glfw/wl_platform.h | 2 ++ glfw/wl_window.c | 58 +++++++++++++++++++++++------------- glfw/x11_platform.h | 7 +++-- glfw/x11_window.c | 68 ++++++++++++++++++++++++++++--------------- 7 files changed, 142 insertions(+), 68 deletions(-) diff --git a/glfw/cocoa_platform.h b/glfw/cocoa_platform.h index 4303d80e7..fc6e98ba6 100644 --- a/glfw/cocoa_platform.h +++ b/glfw/cocoa_platform.h @@ -117,8 +117,11 @@ typedef UInt8 (*PFN_LMGetKbdType)(void); #define LMGetKbdType _glfw.ns.tis.GetKbdType typedef struct _GLFWDropData { - const char **mimes; + const char **mimes; // Original MIME list; strings are owned here, never reordered size_t mimes_count; + const char **copy_mimes; // Working copy passed to callbacks; pointers into mimes[] + size_t copy_mimes_count; // Accepted count after last callback + bool drag_accepted; id pasteboard; id data_mapping; id file_promise_mapping; diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index fd6c53572..768f3b545 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -1458,8 +1458,10 @@ is_modifier_pressed(NSUInteger flags, NSUInteger target_mask, NSUInteger other_m static void free_drop_data(_GLFWwindow *window) { if (window->ns.drop_data.mimes) { - for (size_t i = 0; i < window->ns.drop_data.mimes_count; i++) free(window->ns.drop_data.mimes + i); + for (size_t i = 0; i < window->ns.drop_data.mimes_count; i++) free((void*)window->ns.drop_data.mimes[i]); + free(window->ns.drop_data.mimes); } + free(window->ns.drop_data.copy_mimes); // pointer array only; strings owned by mimes[] if (window->ns.drop_data.pasteboard) [window->ns.drop_data.pasteboard release]; if (window->ns.drop_data.data_mapping) [window->ns.drop_data.data_mapping release]; if (window->ns.drop_data.file_promise_mapping) { @@ -1476,12 +1478,24 @@ free_drop_data(_GLFWwindow *window) { } static void -update_drop_state(_GLFWwindow *window, size_t mime_count) { +update_drop_state(_GLFWwindow *window, size_t accepted_count) { _GLFWDropData *d = &window->ns.drop_data; - for (size_t i = mime_count; i < d->mimes_count; i++) { - if (d->mimes[i]) { free((void*)d->mimes[i]); d->mimes[i] = NULL; } + d->copy_mimes_count = accepted_count; + d->drag_accepted = accepted_count > 0; +} + +// Reset the working copy of mimes so the next callback sees the full original +// list. Returns false on allocation failure. +static bool +reset_drop_copy_mimes(_GLFWDropData *d) { + if (d->mimes_count == 0) { d->copy_mimes_count = 0; return true; } + if (!d->copy_mimes) { + d->copy_mimes = malloc(d->mimes_count * sizeof(const char*)); + if (!d->copy_mimes) return false; } - d->mimes_count = mime_count; + memcpy(d->copy_mimes, d->mimes, d->mimes_count * sizeof(const char*)); + d->copy_mimes_count = d->mimes_count; + return true; } - (NSDragOperation)draggingEntered:(id )sender @@ -1546,14 +1560,17 @@ update_drop_state(_GLFWwindow *window, size_t mime_count) { window->ns.drop_data.mimes = mime_array; window->ns.drop_data.mimes_count = mime_count; bool from_self = ([sender draggingSource] != nil); - mime_count = _glfwInputDropEvent(window, GLFW_DROP_ENTER, xpos, ypos, mime_array, mime_count, from_self); - update_drop_state(window, mime_count); - return mime_count ? NSDragOperationGeneric :NSDragOperationNone; + _GLFWDropData *d = &window->ns.drop_data; + if (reset_drop_copy_mimes(d)) { + size_t accepted_count = _glfwInputDropEvent(window, GLFW_DROP_ENTER, xpos, ypos, d->copy_mimes, d->copy_mimes_count, from_self); + update_drop_state(window, accepted_count); + } + return window->ns.drop_data.drag_accepted ? NSDragOperationGeneric : NSDragOperationNone; } - (NSDragOperation)draggingUpdated:(id )sender { - if (!window->ns.drop_data.mimes_count) return NSDragOperationNone; + if (!window->ns.drop_data.drag_accepted) return NSDragOperationNone; const NSRect contentRect = [window->ns.view frame]; const NSPoint pos = [sender draggingLocation]; double xpos = pos.x; @@ -1561,35 +1578,40 @@ update_drop_state(_GLFWwindow *window, size_t mime_count) { bool from_self = ([sender draggingSource] != nil); _GLFWDropData *d = &window->ns.drop_data; - size_t mime_count = _glfwInputDropEvent(window, GLFW_DROP_MOVE, xpos, ypos, d->mimes, d->mimes_count, from_self); - update_drop_state(window, mime_count); - return mime_count ? NSDragOperationGeneric :NSDragOperationNone; + if (reset_drop_copy_mimes(d)) { + size_t accepted_count = _glfwInputDropEvent(window, GLFW_DROP_MOVE, xpos, ypos, d->copy_mimes, d->copy_mimes_count, from_self); + update_drop_state(window, accepted_count); + } + return window->ns.drop_data.drag_accepted ? NSDragOperationGeneric : NSDragOperationNone; } - (void)draggingExited:(id )sender { bool from_self = ([sender draggingSource] != nil); _GLFWDropData *d = &window->ns.drop_data; - size_t mime_count = _glfwInputDropEvent(window, GLFW_DROP_LEAVE, 0, 0, d->mimes, d->mimes_count, from_self); - update_drop_state(window, mime_count); + if (reset_drop_copy_mimes(d)) { + size_t accepted_count = _glfwInputDropEvent(window, GLFW_DROP_LEAVE, 0, 0, d->copy_mimes, d->copy_mimes_count, from_self); + update_drop_state(window, accepted_count); + } free_drop_data(window); } - (BOOL)performDragOperation:(id )sender { - if (!window->ns.drop_data.mimes_count) return NO; + if (!window->ns.drop_data.drag_accepted) return NO; const NSRect contentRect = [window->ns.view frame]; const NSPoint pos = [sender draggingLocation]; double xpos = pos.x; double ypos = contentRect.size.height - pos.y; bool from_self = ([sender draggingSource] != nil); _GLFWDropData *d = &window->ns.drop_data; - size_t mime_count = _glfwInputDropEvent(window, GLFW_DROP_DROP, xpos, ypos, d->mimes, d->mimes_count, from_self); - if (d->mimes) { - update_drop_state(window, mime_count); + if (!reset_drop_copy_mimes(d)) return NO; + size_t num_accepted = _glfwInputDropEvent(window, GLFW_DROP_DROP, xpos, ypos, d->copy_mimes, d->copy_mimes_count, from_self); + if (d->copy_mimes) { + update_drop_state(window, num_accepted); window->ns.drop_data.pasteboard = [[sender draggingPasteboard] retain]; - for (size_t i = 0; i < d->mimes_count; i++) - _glfwPlatformRequestDropData(window, d->mimes[i]); + for (size_t i = 0; i < num_accepted; i++) + _glfwPlatformRequestDropData(window, d->copy_mimes[i]); } return YES; } diff --git a/glfw/input.c b/glfw/input.c index ba84a54b6..d35f11ba6 100644 --- a/glfw/input.c +++ b/glfw/input.c @@ -402,7 +402,13 @@ void _glfwInputCursorEnter(_GLFWwindow* window, bool entered) window->callbacks.cursorEnter((GLFWwindow*) window, entered); } -// Notifies shared code of a drop event +// Notifies shared code of a drop event. +// The caller is responsible for passing a mutable working-copy of the mimes +// array (reset to the full original list before each call) so that the +// callback can sort/filter in-place without touching the backend's canonical +// storage. The return value is ev.num_mimes after the callback returns, +// i.e. the number of accepted (possibly reordered) mimes starting at +// mimes[0]. size_t _glfwInputDropEvent(_GLFWwindow *window, GLFWDropEventType type, double xpos, double ypos, const char** mimes, size_t num_mimes, bool from_self) { if (!window->callbacks.drop_event) return 0; GLFWDropEvent ev = { diff --git a/glfw/wl_platform.h b/glfw/wl_platform.h index 4781c802b..e9ae1533e 100644 --- a/glfw/wl_platform.h +++ b/glfw/wl_platform.h @@ -310,6 +310,8 @@ typedef struct _GLFWWaylandDataOffer struct wl_surface *surface; const char **mimes; size_t mimes_capacity, mimes_count; + const char **copy_mimes; // Working copy passed to callbacks; pointers into mimes[] + size_t copy_mimes_count; // Count of entries in copy_mimes (accepted count after callback) bool drag_accepted, dropped; uint32_t serial; struct { diff --git a/glfw/wl_window.c b/glfw/wl_window.c index 3a3feeac9..1ebeeea83 100644 --- a/glfw/wl_window.c +++ b/glfw/wl_window.c @@ -2364,10 +2364,25 @@ destroy_data_offer(_GLFWWaylandDataOffer *offer) { for (size_t i = 0; i < offer->mimes_count; i++) free((char*)offer->mimes[i]); free(offer->mimes); } + free(offer->copy_mimes); // pointer array only; strings are owned by mimes[] if (offer->requested_drop_data) destroy_drop_data(offer); memset(offer, 0, sizeof(offer[0])); } +// Reset the working copy of mimes so the next callback sees the full original +// list. Returns false on allocation failure. +static bool +reset_copy_mimes(_GLFWWaylandDataOffer *offer) { + if (offer->mimes_count == 0) { offer->copy_mimes_count = 0; return true; } + if (!offer->copy_mimes) { + offer->copy_mimes = malloc(offer->mimes_count * sizeof(const char*)); + if (!offer->copy_mimes) return false; + } + memcpy(offer->copy_mimes, offer->mimes, offer->mimes_count * sizeof(const char*)); + offer->copy_mimes_count = offer->mimes_count; + return true; +} + static void mark_data_offer(_GLFWWaylandDataOffer *ans, void *id) { if (ans->id) destroy_data_offer(ans); @@ -2480,15 +2495,12 @@ static void handle_primary_selection_offer(void *data UNUSED, struct zwp_primary // Helper function to update drop state from callback results static void -update_drop_state(_GLFWWaylandDataOffer *d, _GLFWwindow* window UNUSED, size_t mime_count) { - for (size_t i = mime_count; i < d->mimes_count; i++) { - if (d->mimes[i]) { free((void*)d->mimes[i]); d->mimes[i] = NULL; } - } - d->mimes_count = mime_count; - bool accepted = mime_count > 0; +update_drop_state(_GLFWWaylandDataOffer *d, _GLFWwindow* window UNUSED, size_t accepted_count) { + d->copy_mimes_count = accepted_count; + bool accepted = accepted_count > 0; bool acceptance_changed = (accepted != d->drag_accepted); - // The first MIME in the sorted list is the preferred one for drop - const char* new_preferred_mime = (accepted && mime_count > 0) ? d->mimes[0] : NULL; + // The first entry in the accepted (sorted) copy is the preferred MIME. + const char* new_preferred_mime = (accepted && d->copy_mimes) ? d->copy_mimes[0] : NULL; bool mime_changed = false; // Check if the preferred MIME changed @@ -2522,10 +2534,12 @@ drag_enter(void *data UNUSED, struct wl_data_device *wl_data_device UNUSED, uint if (window->wl.surface == surface) { double xpos = wl_fixed_to_double(x); double ypos = wl_fixed_to_double(y); - size_t mime_count = _glfwInputDropEvent( - window, GLFW_DROP_ENTER, xpos, ypos, - offer->mimes, offer->mimes_count, offer->is_self_offer); - update_drop_state(offer, window, mime_count); + if (reset_copy_mimes(offer)) { + size_t mime_count = _glfwInputDropEvent( + window, GLFW_DROP_ENTER, xpos, ypos, + offer->copy_mimes, offer->copy_mimes_count, offer->is_self_offer); + update_drop_state(offer, window, mime_count); + } break; } window = window->next; @@ -2643,9 +2657,11 @@ drop(void *data UNUSED, struct wl_data_device *wl_data_device UNUSED) { _GLFWwindow* window = _glfw.windowListHead; while (window) { if (window->wl.surface == offer->surface) { - size_t num_mimes = _glfwInputDropEvent(window, GLFW_DROP_DROP, 0, 0, offer->mimes, offer->mimes_count, offer->is_self_offer); - if (!offer->mimes) { destroy_data_offer(offer); return; } - for (size_t i = 0; i < num_mimes; i++) request_drop_data(offer, offer->mimes[i]); + if (reset_copy_mimes(offer)) { + size_t num_accepted = _glfwInputDropEvent(window, GLFW_DROP_DROP, 0, 0, offer->copy_mimes, offer->copy_mimes_count, offer->is_self_offer); + if (!offer->copy_mimes) { destroy_data_offer(offer); return; } + for (size_t i = 0; i < num_accepted; i++) request_drop_data(offer, offer->copy_mimes[i]); + } break; } window = window->next; @@ -2661,9 +2677,11 @@ motion(void *data UNUSED, struct wl_data_device *wl_data_device UNUSED, uint32_t if (window->wl.surface == offer->surface) { double xpos = wl_fixed_to_double(x); double ypos = wl_fixed_to_double(y); - size_t mime_count = _glfwInputDropEvent( - window, GLFW_DROP_MOVE, xpos, ypos, offer->mimes, offer->mimes_count, offer->is_self_offer); - update_drop_state(offer, window, mime_count); + if (reset_copy_mimes(offer)) { + size_t mime_count = _glfwInputDropEvent( + window, GLFW_DROP_MOVE, xpos, ypos, offer->copy_mimes, offer->copy_mimes_count, offer->is_self_offer); + update_drop_state(offer, window, mime_count); + } break; } window = window->next; @@ -2673,8 +2691,8 @@ motion(void *data UNUSED, struct wl_data_device *wl_data_device UNUSED, uint32_t void _glfwPlatformRequestDropUpdate(_GLFWwindow* window) { _GLFWWaylandDataOffer *d = &_glfw.wl.drop_data_offer; - if (d->id) { - size_t mime_count = _glfwInputDropEvent(window, GLFW_DROP_STATUS_UPDATE, 0, 0, d->mimes, d->mimes_count, d->is_self_offer); + if (d->id && reset_copy_mimes(d)) { + size_t mime_count = _glfwInputDropEvent(window, GLFW_DROP_STATUS_UPDATE, 0, 0, d->copy_mimes, d->copy_mimes_count, d->is_self_offer); update_drop_state(d, window, mime_count); } } diff --git a/glfw/x11_platform.h b/glfw/x11_platform.h index a74dae830..a8bcdaf2f 100644 --- a/glfw/x11_platform.h +++ b/glfw/x11_platform.h @@ -393,8 +393,11 @@ typedef struct _GLFWlibraryX11 char format[256]; int format_priority; Window target_window; // For drag events: the window being dragged over - const char** mimes; // Cached MIME types from drag enter - size_t mimes_count; // Current count of MIME types (may be reduced by callback) + const char** mimes; // Cached MIME types from drag enter (original, never reordered) + size_t mimes_count; // Count of MIME types (full original list, never reduced) + const char** copy_mimes; // Working copy passed to callbacks; pointers into mimes[] + size_t copy_mimes_count; // Accepted count after last callback + bool drag_accepted; // Whether the callback accepted at least one MIME type bool from_self, dropped; Time drop_time; XdndSelectionRequest *selection_requests; diff --git a/glfw/x11_window.c b/glfw/x11_window.c index ec05354be..31d44f44e 100644 --- a/glfw/x11_window.c +++ b/glfw/x11_window.c @@ -1498,7 +1498,7 @@ handle_xi_motion_event(_GLFWwindow *window, XIDeviceEvent *de) { static void end_drop(_GLFWwindow *window, GLFWDragOperationType op) { - bool accepted = dnd.mimes_count > 0 || dnd.dropped; + bool accepted = dnd.drag_accepted || dnd.dropped; XEvent reply = { ClientMessage }; reply.xclient.window = dnd.source; reply.xclient.message_type = _glfw.x11.XdndFinished; @@ -1520,14 +1520,12 @@ end_drop(_GLFWwindow *window, GLFWDragOperationType op) { static void -update_drop_state(_GLFWwindow* window, size_t mime_count) { - for (size_t i = mime_count; i < dnd.mimes_count; i++) { - if (dnd.mimes[i]) { XFree((void*)dnd.mimes[i]); dnd.mimes[i] = NULL; } - } - dnd.mimes_count = mime_count; - bool accepted = mime_count > 0; - // The first MIME in the sorted list is the preferred one for drop - const char* new_preferred_mime = (accepted && mime_count > 0) ? dnd.mimes[0] : NULL; +update_drop_state(_GLFWwindow* window, size_t accepted_count) { + dnd.copy_mimes_count = accepted_count; + bool accepted = accepted_count > 0; + dnd.drag_accepted = accepted; + // The first entry in the accepted (sorted) copy is the preferred MIME. + const char* new_preferred_mime = (accepted && dnd.copy_mimes) ? dnd.copy_mimes[0] : NULL; // Check if the preferred MIME changed bool mime_changed = strncmp(new_preferred_mime ? new_preferred_mime : "", dnd.format, arraysz(dnd.format)) != 0; if (mime_changed) { @@ -1566,12 +1564,30 @@ free_dnd_mimes(void) { dnd.mimes = NULL; dnd.mimes_count = 0; } + free(dnd.copy_mimes); // pointer array only; strings are owned by mimes[] + dnd.copy_mimes = NULL; + dnd.copy_mimes_count = 0; +} + +// Reset the working copy of mimes so the next callback sees the full original +// list. Returns false on allocation failure. +static bool +reset_dnd_copy_mimes(void) { + if (dnd.mimes_count == 0) { dnd.copy_mimes_count = 0; return true; } + if (!dnd.copy_mimes) { + dnd.copy_mimes = malloc(dnd.mimes_count * sizeof(const char*)); + if (!dnd.copy_mimes) return false; + } + memcpy(dnd.copy_mimes, dnd.mimes, dnd.mimes_count * sizeof(const char*)); + dnd.copy_mimes_count = dnd.mimes_count; + return true; } void free_dnd_data(void) { dnd.source = None; dnd.target_window = None; + dnd.drag_accepted = false; free_dnd_mimes(); if (dnd.selection_requests) { for (size_t i = 0; i < dnd.selection_requests_count; i++) { @@ -1640,14 +1656,13 @@ drop_start(_GLFWwindow *window, XEvent *event) { update_dnd_mimes(event); dnd.from_self = _glfw.x11.drag.source_window != None && dnd.source == _glfw.x11.drag.source_window; // Position is not known yet at enter time, will be updated with XdndPosition - size_t mimes_count = _glfwInputDropEvent( - window, GLFW_DROP_ENTER, 0, 0, dnd.mimes, dnd.mimes_count, dnd.from_self); - update_drop_state(window, mimes_count); - // Update cached mime count with callback result - if (dnd.mimes_count > 0) { - // The first MIME type in the reordered list is the preferred one - strncpy(dnd.format, dnd.mimes[0], arraysz(dnd.format) - 1); - dnd.format_priority = 1; + if (reset_dnd_copy_mimes()) { + size_t accepted_count = _glfwInputDropEvent( + window, GLFW_DROP_ENTER, 0, 0, dnd.copy_mimes, dnd.copy_mimes_count, dnd.from_self); + update_drop_state(window, accepted_count); + // Set format_priority when the callback accepted at least one MIME type. + // dnd.format has already been updated by update_drop_state. + if (accepted_count > 0) dnd.format_priority = 1; } } @@ -1674,8 +1689,10 @@ drop_move(_GLFWwindow *window, XEvent *event) { _glfwReleaseErrorHandlerX11(); if (_glfw.x11.errorCode != Success) _glfwInputError(GLFW_PLATFORM_ERROR, "X11: Failed to get DND event position"); _glfwInputCursorPos(window, xpos, ypos); - size_t mimes_count = _glfwInputDropEvent(window, GLFW_DROP_MOVE, xpos, ypos, dnd.mimes, dnd.mimes_count, dnd.from_self); - update_drop_state(window, mimes_count); + if (reset_dnd_copy_mimes()) { + size_t mimes_count = _glfwInputDropEvent(window, GLFW_DROP_MOVE, xpos, ypos, dnd.copy_mimes, dnd.copy_mimes_count, dnd.from_self); + update_drop_state(window, mimes_count); + } } void @@ -1684,8 +1701,10 @@ _glfwPlatformRequestDropUpdate(_GLFWwindow* window) { if (dnd.source == None || dnd.target_window != window->x11.handle) return; // Call the drag callback with STATUS_UPDATE event to get updated state // Position values are not valid for this event type - size_t mimes_count = _glfwInputDropEvent(window, GLFW_DROP_STATUS_UPDATE, 0, 0, dnd.mimes, dnd.mimes_count, dnd.from_self); - update_drop_state(window, mimes_count); + if (reset_dnd_copy_mimes()) { + size_t mimes_count = _glfwInputDropEvent(window, GLFW_DROP_STATUS_UPDATE, 0, 0, dnd.copy_mimes, dnd.copy_mimes_count, dnd.from_self); + update_drop_state(window, mimes_count); + } } @@ -1695,9 +1714,10 @@ drop(_GLFWwindow *window, XEvent *event) { if (dnd.version > _GLFW_XDND_VERSION || dnd.version < 2) return; dnd.dropped = true; dnd.drop_time = (unsigned long)event->xclient.data.l[2]; - size_t mimes_count = _glfwInputDropEvent(window, GLFW_DROP_DROP, 0, 0, dnd.mimes, dnd.mimes_count, dnd.from_self); - if (!dnd.mimes) return; - for (size_t i = 0; i < mimes_count; i++) _glfwPlatformRequestDropData(window, dnd.mimes[i]); + if (!reset_dnd_copy_mimes()) return; + size_t num_accepted = _glfwInputDropEvent(window, GLFW_DROP_DROP, 0, 0, dnd.copy_mimes, dnd.copy_mimes_count, dnd.from_self); + if (!dnd.copy_mimes) return; + for (size_t i = 0; i < num_accepted; i++) _glfwPlatformRequestDropData(window, dnd.copy_mimes[i]); } static void From 03376dc7040b062acd73600a74cc2fecd817c1c9 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 10 Mar 2026 20:30:45 +0530 Subject: [PATCH 06/70] Fix drops being sent to invisbile windows in stack layout --- kitty/boss.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/kitty/boss.py b/kitty/boss.py index b8f9603f9..3f53e34e3 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -1958,10 +1958,11 @@ class Boss: y -= central.top if tab := tm.active_tab: for window in tab: - g = window.geometry - if g.left <= x < g.right and g.top <= y < g.bottom: - window.on_drop(drop) - break + if window.is_visible_in_layout: + g = window.geometry + if g.left <= x < g.right and g.top <= y < g.bottom: + window.on_drop(drop) + break elif tab_bar.left <= x < tab_bar.right and tab_bar.top <= y < tab_bar.bottom: if (tab_id := tm.tab_bar.tab_id_at(x)) and (tab := self.tab_for_id(tab_id)) and (w := tab.active_window): w.on_drop(drop) From fa2d7a789e97705cfa4e97f0b55d4a6a6bd8f326 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 11 Mar 2026 08:24:42 +0530 Subject: [PATCH 07/70] Mention the command palette in the overview docs --- docs/overview.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/overview.rst b/docs/overview.rst index ccbd85ff2..2eaaaa56d 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -33,6 +33,9 @@ frames-per-second. Press :sc:`edit_config_file` in kitty to open its fully commented sample config file in your text editor. For details see the :doc:`configuration docs `. +You can quickly browse all available mappable actions by pressing +:sc:`command_palette`. + .. toctree:: :hidden: From 0b2f081d40ab9d179327a00c2ae6fc707809b8f0 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 11 Mar 2026 08:26:12 +0530 Subject: [PATCH 08/70] version 0.46.0 --- docs/changelog.rst | 2 +- kitty/constants.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index bce640f65..d1dab1e29 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -150,7 +150,7 @@ consumption to do the same tasks. Detailed list of changes ------------------------------------- -0.46.0 [future] +0.46.0 [2026-03-11] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Pixel scrolling for the kitty scrollback buffer controlled via :opt:`pixel_scroll` (:pull:`9330`) diff --git a/kitty/constants.py b/kitty/constants.py index 1e3e6d71d..9b6a07380 100644 --- a/kitty/constants.py +++ b/kitty/constants.py @@ -22,7 +22,7 @@ class Version(NamedTuple): appname: str = 'kitty' kitty_face = '🐱' -version: Version = Version(0, 45, 0) +version: Version = Version(0, 46, 0) str_version: str = '.'.join(map(str, version)) _plat = sys.platform.lower() is_macos: bool = 'darwin' in _plat From 74f532bd07277128c3ae9c9e095948acba81e4f7 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 11 Mar 2026 08:53:58 +0530 Subject: [PATCH 09/70] Update major changes --- docs/changelog.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index d1dab1e29..bc9889924 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,20 @@ To update |kitty|, :doc:`follow the instructions `. Recent major new features --------------------------- +Mousing [0.46] +~~~~~~~~~~~~~~~ + +kitty already had excellent mouse support, but now it is taking it to the next +level. The kitty scrollback buffer grew support for :pull:`smooth scrolling <9330>` +and momentum based scrolling for a natural, smooth and kinetic scrolling experience. + +Additionally, you can now :pull:`drag kitty tabs around <9296>` with the mouse +to re-order them, move them to another kitty OS Window or even detach them into +their own OS Window. + +Finally, a long requested feature, the ability to resize kitty windows (aka +splits) with the mouse was finally implemented. + Choose files, fast [0.45] ~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 4ad6d30ab883f44ba969af668ca99406534c9390 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 11 Mar 2026 21:03:57 +0530 Subject: [PATCH 10/70] Add command palette shortcut to changelog --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index bc9889924..4a9657639 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -184,7 +184,7 @@ Detailed list of changes - Allow showing :opt:`configurable window titles ` for individual kitty windows via a window title bar (:pull:`9450`) -- A command palette to browse and trigger all mapped and unmapped actions +- A command palette (:sc:`command_palette`) to browse and trigger all mapped and unmapped actions (:pull:`9545`) - choose-files kitten: Fix JXL image preview not working (:iss:`9323`) From 9aa938c6cd5640358f3ab4be7c537984a7a45ce0 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 11 Mar 2026 21:13:24 +0530 Subject: [PATCH 11/70] Make the error message a bit more clear --- kittens/command_palette/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kittens/command_palette/main.go b/kittens/command_palette/main.go index 9a93d9b7a..d9e983163 100644 --- a/kittens/command_palette/main.go +++ b/kittens/command_palette/main.go @@ -790,7 +790,7 @@ func (h *Handler) triggerSelected() { func main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) { if tty.IsTerminal(os.Stdin.Fd()) { - return 1, fmt.Errorf("This kitten must only be run via a mapping in kitty.conf") + return 1, fmt.Errorf("This kitten must only be run via the command_palette action mapped to a shortcut in kitty.conf") } output := tui.KittenOutputSerializer() lp, err := loop.New() From 121eec48b5b0b38be3382e58cd856a56e03c8606 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 11 Mar 2026 21:21:33 +0530 Subject: [PATCH 12/70] Make the changelog link to options rather than PRs --- docs/changelog.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 4a9657639..7b5f5e0d8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,8 +13,9 @@ Mousing [0.46] ~~~~~~~~~~~~~~~ kitty already had excellent mouse support, but now it is taking it to the next -level. The kitty scrollback buffer grew support for :pull:`smooth scrolling <9330>` -and momentum based scrolling for a natural, smooth and kinetic scrolling experience. +level. The kitty scrollback buffer grew support for :opt:`smooth scrolling +` and :opt:`momentum based scrolling ` +for a natural, smooth and kinetic scrolling experience. Additionally, you can now :pull:`drag kitty tabs around <9296>` with the mouse to re-order them, move them to another kitty OS Window or even detach them into From e64fbe145edcfbf708fd7d55c9dc5347c8b73b15 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 11 Mar 2026 21:24:24 +0530 Subject: [PATCH 13/70] ... --- docs/changelog.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 7b5f5e0d8..5d715ffee 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -17,7 +17,7 @@ level. The kitty scrollback buffer grew support for :opt:`smooth scrolling ` and :opt:`momentum based scrolling ` for a natural, smooth and kinetic scrolling experience. -Additionally, you can now :pull:`drag kitty tabs around <9296>` with the mouse +Additionally, you can now :opt:`drag kitty tabs around ` with the mouse to re-order them, move them to another kitty OS Window or even detach them into their own OS Window. @@ -176,7 +176,7 @@ Detailed list of changes - macOS: Implement support for Apple dictation to input text in kitty (:iss:`3732`) -- Allow dragging tabs in the tab bar to re-order, move to another OS Window or +- Allow dragging tabs (opt:`tab_bar_drag_threshold`) in the tab bar to re-order, move to another OS Window or detach (:pull:`9296`) - Allow dragging window borders to resize kitty windows in all the different From a1e02c8858098d01e1f2b9eba5fd96165bb5a2f9 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 11 Mar 2026 21:24:49 +0530 Subject: [PATCH 14/70] ... --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5d715ffee..12671f60e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,7 +22,7 @@ to re-order them, move them to another kitty OS Window or even detach them into their own OS Window. Finally, a long requested feature, the ability to resize kitty windows (aka -splits) with the mouse was finally implemented. +splits) with the mouse was implemented. Choose files, fast [0.45] ~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 50a69cb093831d44672efbc6ad62327f05f88a11 Mon Sep 17 00:00:00 2001 From: newwares <45801973+waresnew@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:37:48 -0400 Subject: [PATCH 15/70] Fix outdated check in paste confirmation --- kitty/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kitty/window.py b/kitty/window.py index b53f7ce66..76acc8547 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -2018,7 +2018,7 @@ class Window: def handle_dangerous_paste_confirmation(self, unsanitized: bytes, sanitized: bytes, choice: str) -> None: if choice == 's': self.paste_text(sanitized) - elif choice == 'p': + elif choice == 'a': self.paste_text(unsanitized) def handle_large_paste_confirmation(self, btext: bytes, confirmed: bool) -> None: From e9de88221f10810b3d1b9bef0a908a2810002dd8 Mon Sep 17 00:00:00 2001 From: Pieter van der Heijden Date: Wed, 11 Mar 2026 21:41:06 +0100 Subject: [PATCH 16/70] Clear macOS dock badge on keypress and mouse click The dock badge set by macos_dock_badge_on_bell is currently only cleared when the app transitions from inactive to active via NSApplicationDidBecomeActiveNotification. This means if a bell occurs while kitty is already the active app (e.g. in a background tmux pane or non-focused kitty tab), the badge persists until the user switches away and back. Clear the badge on any user interaction (keypress or mouse click) so it is dismissed as soon as the user engages with kitty, regardless of whether kitty was already active. --- kitty/cocoa_window.h | 1 + kitty/cocoa_window.m | 8 ++++++++ kitty/glfw.c | 6 ++++++ 3 files changed, 15 insertions(+) diff --git a/kitty/cocoa_window.h b/kitty/cocoa_window.h index 1779de9e2..773d576c7 100644 --- a/kitty/cocoa_window.h +++ b/kitty/cocoa_window.h @@ -67,3 +67,4 @@ void get_cocoa_key_equivalent(uint32_t, int, char *key, size_t key_sz, int*); void set_cocoa_pending_action(CocoaPendingAction action, const char*); void cocoa_report_live_notifications(const char* ident); void cocoa_set_dock_badge(const char *label); +void cocoa_clear_dock_badge_if_set(void); diff --git a/kitty/cocoa_window.m b/kitty/cocoa_window.m index ce7a7c58c..50249f785 100644 --- a/kitty/cocoa_window.m +++ b/kitty/cocoa_window.m @@ -1341,15 +1341,23 @@ cocoa_show_progress_bar_on_dock_icon(PyObject *self UNUSED, PyObject *args) { // Dock badge {{{ +static bool dock_badge_is_set = false; + void cocoa_set_dock_badge(const char *label) { @autoreleasepool { NSDockTile *dockTile = [NSApp dockTile]; [dockTile setBadgeLabel:label ? @(label) : nil]; [dockTile display]; + dock_badge_is_set = (label != NULL); } } +void +cocoa_clear_dock_badge_if_set(void) { + if (dock_badge_is_set) cocoa_set_dock_badge(NULL); +} + // }}} static PyMethodDef module_methods[] = { diff --git a/kitty/glfw.c b/kitty/glfw.c index 7daa39f3c..6e9924b5b 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -518,6 +518,9 @@ update_modifier_state_on_modifier_key_event(GLFWkeyevent *ev, int key_modifier, static void key_callback(GLFWwindow *w, GLFWkeyevent *ev) { if (!set_callback_window(w)) return; +#ifdef __APPLE__ + cocoa_clear_dock_badge_if_set(); +#endif #ifndef __APPLE__ bool is_left; int key_modifier = key_to_modifier(ev->key, &is_left); @@ -554,6 +557,9 @@ cursor_enter_callback(GLFWwindow *w, int entered) { static void mouse_button_callback(GLFWwindow *w, int button, int action, int mods) { if (!set_callback_window(w)) return; +#ifdef __APPLE__ + cocoa_clear_dock_badge_if_set(); +#endif monotonic_t now = monotonic(); cursor_active_callback(now); mods_at_last_key_or_button_event = mods; From 01104bac65505fc8a7c5f19920f52ab7de830469 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 12 Mar 2026 08:33:20 +0530 Subject: [PATCH 17/70] Fix a regression that broke ``kitten update-self`` Fixes #9642 --- docs/changelog.rst | 5 +++++ setup.py | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 12671f60e..c73a09d55 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -165,6 +165,11 @@ consumption to do the same tasks. Detailed list of changes ------------------------------------- +0.46.1 [future] +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Fix a regression that broke ``kitten update-self`` (:iss:`9642`) + 0.46.0 [2026-03-11] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/setup.py b/setup.py index 9ba503dd7..5ea2eac0f 100755 --- a/setup.py +++ b/setup.py @@ -1286,11 +1286,12 @@ def build_static_kittens( cmd = go + ['build', '-v'] vcs_rev = args.vcs_rev or get_vcs_rev() ld_flags: List[str] = [] - binary_data_flags = [f"-X kitty.VCSRevision={vcs_rev}"] + modpath = 'github.com/kovidgoyal/kitty' + binary_data_flags = [f"-X {modpath}.VCSRevision={vcs_rev}"] if for_freeze: - binary_data_flags.append("-X kitty.IsFrozenBuild=true") + binary_data_flags.append(f"-X {modpath}.IsFrozenBuild=true") if for_platform: - binary_data_flags.append("-X kitty.IsStandaloneBuild=true") + binary_data_flags.append(f"-X {modpath}.IsStandaloneBuild=true") if not args.debug: ld_flags.append('-s') ld_flags.append('-w') From 36810862eddfbe8f597ece8c50169f02e9056083 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 12 Mar 2026 08:57:57 +0530 Subject: [PATCH 18/70] Update changelog --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index c73a09d55..5f34cf33d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -170,6 +170,8 @@ Detailed list of changes - Fix a regression that broke ``kitten update-self`` (:iss:`9642`) +- macOS: Clear bell alert badge on dock icon on mouse/keyboard activity (:iss:`9640`) + 0.46.0 [2026-03-11] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From c2447abd3008d004335b6dd7575fe2efc3808f9e Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 12 Mar 2026 09:22:02 +0530 Subject: [PATCH 19/70] Update changelog --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5f34cf33d..d4d804d5a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -172,6 +172,8 @@ Detailed list of changes - macOS: Clear bell alert badge on dock icon on mouse/keyboard activity (:iss:`9640`) +- Fix a regression that broke accept anyway shortcut in the paste confirmation dialog (:pull:`9640`) + 0.46.0 [2026-03-11] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From b9d7a661cec7ed453aef5c8516f5677f8948b23e Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 12 Mar 2026 09:46:22 +0530 Subject: [PATCH 20/70] Dont import ctypes unless type checking ctypes in Python 3.14 is broken on Intel macs. It uses libffi which is broken on Intel macs without extra security entitlements. At least com.apple.security.cs.allow-unsigned-executable-memory and possibly com.apple.security.cs.disable-executable-page-protection Rather than add these entitlements, we simply do not import ctypes as it is not actually used on macs anyway. Fixes #9643 --- docs/changelog.rst | 2 ++ kitty/fonts/render.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index d4d804d5a..4e5a84f2b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -174,6 +174,8 @@ Detailed list of changes - Fix a regression that broke accept anyway shortcut in the paste confirmation dialog (:pull:`9640`) +- Fix kitty hanging on startup on Intel macs (:iss:`9643`) + 0.46.0 [2026-03-11] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/kitty/fonts/render.py b/kitty/fonts/render.py index 49220d94f..5aa88bd99 100644 --- a/kitty/fonts/render.py +++ b/kitty/fonts/render.py @@ -1,7 +1,6 @@ #!/usr/bin/env python # License: GPL v3 Copyright: 2016, Kovid Goyal -import ctypes import os import sys from collections.abc import Callable, Generator @@ -218,6 +217,7 @@ def set_font_family(opts: Options | None = None, override_font_size: float | Non if TYPE_CHECKING: + import ctypes CBufType = ctypes.Array[ctypes.c_ubyte] else: CBufType = None From a0709acdde03ac761a0dc3d44ef8bbafac90ea4a Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 12 Mar 2026 10:06:20 +0530 Subject: [PATCH 21/70] DRYer --- setup.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5ea2eac0f..826da394d 100755 --- a/setup.py +++ b/setup.py @@ -1286,7 +1286,10 @@ def build_static_kittens( cmd = go + ['build', '-v'] vcs_rev = args.vcs_rev or get_vcs_rev() ld_flags: List[str] = [] - modpath = 'github.com/kovidgoyal/kitty' + with open('go.mod') as f: + m = re.search(r'^module\s+(\S+)', f.read(), flags=re.M) + assert m is not None + modpath = m.group(1).strip() binary_data_flags = [f"-X {modpath}.VCSRevision={vcs_rev}"] if for_freeze: binary_data_flags.append(f"-X {modpath}.IsFrozenBuild=true") From 9f45daf300214de65038cde3b8a0939a3c58256c Mon Sep 17 00:00:00 2001 From: Chang Yu Date: Wed, 11 Mar 2026 22:26:17 -0700 Subject: [PATCH 22/70] Fix bookorat link in integrations.rst --- docs/integrations.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/integrations.rst b/docs/integrations.rst index d417dfa86..97acf7959 100644 --- a/docs/integrations.rst +++ b/docs/integrations.rst @@ -16,7 +16,7 @@ images and other types of documents directly in your terminal, even over SSH. .. _tool_bookorat: -`bookorat ` +`bookorat `_ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ A terminal PDF/EPUB viewer From c78592174df9b3bef40dad05de03d39e6e631488 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 12 Mar 2026 12:23:58 +0530 Subject: [PATCH 23/70] ... --- docs/integrations.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/integrations.rst b/docs/integrations.rst index 97acf7959..34b87e023 100644 --- a/docs/integrations.rst +++ b/docs/integrations.rst @@ -14,9 +14,9 @@ Image and document viewers Powered by kitty's :doc:`graphics-protocol` there exist many tools for viewing images and other types of documents directly in your terminal, even over SSH. -.. _tool_bookorat: +.. _tool_bookokrat: -`bookorat `_ +`bookokrat `_ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ A terminal PDF/EPUB viewer From f45345c7a7870db2342c874f5dd5d550a3c21436 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 04:44:13 +0000 Subject: [PATCH 24/70] Add colorMoved support to kitty diff kitten Fixes #3241 Fixes #9644 --- kittens/diff/main.py | 6 +++++ kittens/diff/patch.go | 61 ++++++++++++++++++++++++++++++++++++++++++ kittens/diff/render.go | 38 ++++++++++++++++++++------ 3 files changed, 97 insertions(+), 8 deletions(-) diff --git a/kittens/diff/main.py b/kittens/diff/main.py index cc1a1c4ab..808e59644 100644 --- a/kittens/diff/main.py +++ b/kittens/diff/main.py @@ -152,6 +152,12 @@ opt('dark_highlight_added_bg', '#31503d', option_type='to_color') opt('added_margin_bg', '#cdffd8', option_type='to_color') opt('dark_added_margin_bg', '#31503d', option_type='to_color') +opt('moved_bg', '#fffde7', option_type='to_color', long_text='Moved text backgrounds (same text that was removed in one place and added in another)') +opt('dark_moved_bg', '#2c2200', option_type='to_color') + +opt('moved_margin_bg', '#fff3b0', option_type='to_color') +opt('dark_moved_margin_bg', '#4a3800', option_type='to_color') + opt('filler_bg', '#fafbfc', option_type='to_color', long_text='Filler (empty) line background') opt('dark_filler_bg', '#262c36', option_type='to_color') diff --git a/kittens/diff/patch.go b/kittens/diff/patch.go index 81ff508bf..b1779a52e 100644 --- a/kittens/diff/patch.go +++ b/kittens/diff/patch.go @@ -14,6 +14,7 @@ import ( "sync" parallel "github.com/kovidgoyal/go-parallel" + "github.com/kovidgoyal/kitty/tools/simdstring" "github.com/kovidgoyal/kitty/tools/utils" "github.com/kovidgoyal/kitty/tools/utils/images" "github.com/kovidgoyal/kitty/tools/utils/shlex" @@ -336,6 +337,7 @@ func (self *Hunk) finalize(left_lines, right_lines []string) error { type Patch struct { all_hunks []*Hunk largest_line_number, added_count, removed_count int + left_moved_lines, right_moved_lines *utils.Set[int] } func (self *Patch) Len() int { return len(self.all_hunks) } @@ -451,6 +453,62 @@ func (self *Patch) compute_centers(left_lines, right_lines []string) error { return nil } +func (self *Patch) detect_moved_lines(left_lines, right_lines []string) { + // Build maps from line text to lists of line numbers for removed and added lines. + removed := make(map[string][]int) // text -> left line numbers + added := make(map[string][]int) // text -> right line numbers + // Use SIMD to efficiently find non-blank lines: a line is non-blank if it + // contains at least one character that is not a space or tab. + is_non_blank := func(text string) bool { + for len(text) > 0 { + idx := simdstring.IndexByte2String(text, ' ', '\t') + if idx != 0 { + // idx < 0: no space/tab found, remaining chars are non-blank; + // idx > 0: non-blank chars exist before the first space/tab. + return true + } + text = text[1:] + } + return false + } + for _, hunk := range self.all_hunks { + for _, chunk := range hunk.chunks { + if !chunk.is_context { + for i := 0; i < chunk.left_count; i++ { + lnum := chunk.left_start + i + text := left_lines[lnum] + if is_non_blank(text) { + removed[text] = append(removed[text], lnum) + } + } + for i := 0; i < chunk.right_count; i++ { + rnum := chunk.right_start + i + text := right_lines[rnum] + if is_non_blank(text) { + added[text] = append(added[text], rnum) + } + } + } + } + } + // Lines that appear in both removed and added sets are moved lines. When a + // line appears multiple times on each side, only min(left_count, + // right_count) occurrences are marked as moved. + self.left_moved_lines = utils.NewSet[int]() + self.right_moved_lines = utils.NewSet[int]() + for text, lnums := range removed { + if rnums, ok := added[text]; ok { + count := min(len(lnums), len(rnums)) + for _, lnum := range lnums[:count] { + self.left_moved_lines.Add(lnum) + } + for _, rnum := range rnums[:count] { + self.right_moved_lines.Add(rnum) + } + } + } +} + func parse_patch(raw string, left_lines, right_lines []string) (ans *Patch, err error) { ans = &Patch{all_hunks: make([]*Hunk, 0, 32)} var current_hunk *Hunk @@ -486,6 +544,9 @@ func parse_patch(raw string, left_lines, right_lines []string) (ans *Patch, err ans.largest_line_number = ans.all_hunks[len(ans.all_hunks)-1].largest_line_number } err = ans.compute_centers(left_lines, right_lines) + if err == nil { + ans.detect_moved_lines(left_lines, right_lines) + } return } diff --git a/kittens/diff/render.go b/kittens/diff/render.go index 512445b58..e8db88c0a 100644 --- a/kittens/diff/render.go +++ b/kittens/diff/render.go @@ -40,6 +40,7 @@ type HalfScreenLine struct { marked_up_margin_text string marked_up_text string is_filler bool + is_moved bool cached_wcswidth int } @@ -81,6 +82,9 @@ func (self *LogicalLine) render_screen_line(n int, lp *loop.Loop, margin_size, c if sl.left.is_filler { left_margin = format_as_sgr.margin_filler + left_margin left_text = format_as_sgr.filler + left_text + } else if sl.left.is_moved { + left_margin = format_as_sgr.moved_margin + left_margin + left_text = format_as_sgr.moved + left_text } else { switch self.line_type { case CHANGE_LINE, IMAGE_LINE: @@ -104,6 +108,9 @@ func (self *LogicalLine) render_screen_line(n int, lp *loop.Loop, margin_size, c if sl.right.is_filler { right_margin = format_as_sgr.margin_filler + right_margin right_text = format_as_sgr.filler + right_text + } else if sl.right.is_moved { + right_margin = format_as_sgr.moved_margin + right_margin + right_text = format_as_sgr.moved + right_text } else { switch self.line_type { case CHANGE_LINE, IMAGE_LINE: @@ -156,7 +163,7 @@ func place_in(text string, sz int) string { } var format_as_sgr struct { - title, margin, added, removed, added_margin, removed_margin, filler, margin_filler, hunk_margin, hunk, selection, search string + title, margin, added, removed, added_margin, removed_margin, filler, margin_filler, hunk_margin, hunk, selection, search, moved, moved_margin string } var statusline_format, added_count_format, removed_count_format, message_format func(...any) string @@ -175,6 +182,8 @@ type ResolvedColors struct { Margin_bg style.RGBA Margin_fg style.RGBA Margin_filler_bg style.NullableColor + Moved_bg style.RGBA + Moved_margin_bg style.RGBA Removed_bg style.RGBA Removed_margin_bg style.RGBA Search_bg style.RGBA @@ -202,6 +211,8 @@ func create_formatters() { rc.Margin_bg = conf.Dark_margin_bg rc.Margin_fg = conf.Dark_margin_fg rc.Margin_filler_bg = conf.Dark_margin_filler_bg + rc.Moved_bg = conf.Dark_moved_bg + rc.Moved_margin_bg = conf.Dark_moved_margin_bg rc.Removed_bg = conf.Dark_removed_bg rc.Removed_margin_bg = conf.Dark_removed_margin_bg rc.Search_bg = conf.Dark_search_bg @@ -223,6 +234,8 @@ func create_formatters() { rc.Margin_bg = conf.Margin_bg rc.Margin_fg = conf.Margin_fg rc.Margin_filler_bg = conf.Margin_filler_bg + rc.Moved_bg = conf.Moved_bg + rc.Moved_margin_bg = conf.Moved_margin_bg rc.Removed_bg = conf.Removed_bg rc.Removed_margin_bg = conf.Removed_margin_bg rc.Search_bg = conf.Search_bg @@ -248,6 +261,8 @@ func create_formatters() { format_as_sgr.added_margin = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Added_margin_bg.AsRGBSharp())) format_as_sgr.removed = only_open("bg=" + rc.Removed_bg.AsRGBSharp()) format_as_sgr.removed_margin = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Removed_margin_bg.AsRGBSharp())) + format_as_sgr.moved = only_open("bg=" + rc.Moved_bg.AsRGBSharp()) + format_as_sgr.moved_margin = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Moved_margin_bg.AsRGBSharp())) format_as_sgr.title = only_open(fmt.Sprintf("fg=%s bg=%s bold", rc.Title_fg.AsRGBSharp(), rc.Title_bg.AsRGBSharp())) format_as_sgr.margin = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Margin_bg.AsRGBSharp())) format_as_sgr.hunk = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Hunk_bg.AsRGBSharp())) @@ -532,7 +547,9 @@ type DiffData struct { left_path, right_path string available_cols, margin_size int - left_lines, right_lines []string + left_lines, right_lines []string + left_moved_lines *utils.Set[int] + right_moved_lines *utils.Set[int] } func hunk_title(hunk *Hunk) string { @@ -567,7 +584,7 @@ func splitlines(text string, width int) []string { return style.WrapTextAsLines(text, width, style.WrapOptions{}) } -func render_half_line(line_number int, line, ltype string, available_cols int, center Center, ans []HalfScreenLine) []HalfScreenLine { +func render_half_line(line_number int, line, ltype string, available_cols int, center Center, is_moved bool, ans []HalfScreenLine) []HalfScreenLine { var regions []Region if ltype == "remove" { regions = center.left_regions @@ -583,7 +600,7 @@ func render_half_line(line_number int, line, ltype string, available_cols int, c } lnum := strconv.Itoa(line_number + 1) for _, sc := range splitlines(line, available_cols) { - ans = append(ans, HalfScreenLine{marked_up_margin_text: lnum, marked_up_text: sc}) + ans = append(ans, HalfScreenLine{marked_up_margin_text: lnum, marked_up_text: sc, is_moved: is_moved}) lnum = "" } return ans @@ -601,13 +618,15 @@ func lines_for_diff_chunk(data *DiffData, _ int, chunk *Chunk, _ int, ans []*Log } if i < chunk.left_count { left_lnum = chunk.left_start + i - ll = render_half_line(left_lnum, data.left_lines[left_lnum], "remove", data.available_cols, center, ll) + left_is_moved := data.left_moved_lines != nil && data.left_moved_lines.Has(left_lnum) + ll = render_half_line(left_lnum, data.left_lines[left_lnum], "remove", data.available_cols, center, left_is_moved, ll) left_lnum++ } if i < chunk.right_count { right_lnum = chunk.right_start + i - rl = render_half_line(right_lnum, data.right_lines[right_lnum], "add", data.available_cols, center, rl) + right_is_moved := data.right_moved_lines != nil && data.right_moved_lines.Has(right_lnum) + rl = render_half_line(right_lnum, data.right_lines[right_lnum], "add", data.available_cols, center, right_is_moved, rl) right_lnum++ } @@ -663,7 +682,10 @@ func lines_for_diff(left_path string, right_path string, patch *Patch, columns, return append(ans, &ht), nil } available_cols := columns/2 - margin_size - data := DiffData{left_path: left_path, right_path: right_path, available_cols: available_cols, margin_size: margin_size} + data := DiffData{ + left_path: left_path, right_path: right_path, available_cols: available_cols, margin_size: margin_size, + left_moved_lines: patch.left_moved_lines, right_moved_lines: patch.right_moved_lines, + } if left_path != "" { data.left_lines, err = highlighted_lines_for_path(left_path) if err != nil { @@ -720,7 +742,7 @@ func all_lines(path string, columns, margin_size int, is_add bool, ans []*Logica } for line_number, line := range lines { hlines := make([]HalfScreenLine, 0, 8) - hlines = render_half_line(line_number, line, ltype, available_cols, Center{}, hlines) + hlines = render_half_line(line_number, line, ltype, available_cols, Center{}, false, hlines) l := ll if is_add { l.right_reference.linenum = line_number + 1 From d8af7e2c887936ef6fe6df399a02af2374d5a614 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 12 Mar 2026 12:59:46 +0530 Subject: [PATCH 25/70] Add an option to control highlighting of moved lines --- docs/changelog.rst | 2 ++ kittens/diff/main.py | 8 ++++++-- kittens/diff/patch.go | 2 +- kittens/diff/render.go | 6 +++--- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 4e5a84f2b..bf0dfad8b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -168,6 +168,8 @@ Detailed list of changes 0.46.1 [future] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- diff kitten: Highlight moved lines using a different background color (:opt:`kitten-diff.mark_moved_lines`) (:iss:`3241`) + - Fix a regression that broke ``kitten update-self`` (:iss:`9642`) - macOS: Clear bell alert badge on dock icon on mouse/keyboard activity (:iss:`9640`) diff --git a/kittens/diff/main.py b/kittens/diff/main.py index 808e59644..9d6ed6ca4 100644 --- a/kittens/diff/main.py +++ b/kittens/diff/main.py @@ -65,6 +65,10 @@ Can be specified multiple times to use multiple patterns. For example:: ''', ) +opt('mark_moved_lines', 'yes', option_type='to_bool', long_text=''' +Highlight lines that are moved, that is removed from the left and added to the right +differently, using the :opt:`moved_bg` color.''') + opt('word_diff_mode', 'words', choices=('words', 'central'), long_text=''' The algorithm to use for highlighting which parts of changed lines differ. @@ -153,10 +157,10 @@ opt('added_margin_bg', '#cdffd8', option_type='to_color') opt('dark_added_margin_bg', '#31503d', option_type='to_color') opt('moved_bg', '#fffde7', option_type='to_color', long_text='Moved text backgrounds (same text that was removed in one place and added in another)') -opt('dark_moved_bg', '#2c2200', option_type='to_color') +opt('dark_moved_bg', '#003333', option_type='to_color') opt('moved_margin_bg', '#fff3b0', option_type='to_color') -opt('dark_moved_margin_bg', '#4a3800', option_type='to_color') +opt('dark_moved_margin_bg', '#00495b', option_type='to_color') opt('filler_bg', '#fafbfc', option_type='to_color', long_text='Filler (empty) line background') opt('dark_filler_bg', '#262c36', option_type='to_color') diff --git a/kittens/diff/patch.go b/kittens/diff/patch.go index b1779a52e..9df622ad7 100644 --- a/kittens/diff/patch.go +++ b/kittens/diff/patch.go @@ -544,7 +544,7 @@ func parse_patch(raw string, left_lines, right_lines []string) (ans *Patch, err ans.largest_line_number = ans.all_hunks[len(ans.all_hunks)-1].largest_line_number } err = ans.compute_centers(left_lines, right_lines) - if err == nil { + if err == nil && conf.Mark_moved_lines { ans.detect_moved_lines(left_lines, right_lines) } return diff --git a/kittens/diff/render.go b/kittens/diff/render.go index e8db88c0a..9dfd7046e 100644 --- a/kittens/diff/render.go +++ b/kittens/diff/render.go @@ -547,9 +547,9 @@ type DiffData struct { left_path, right_path string available_cols, margin_size int - left_lines, right_lines []string - left_moved_lines *utils.Set[int] - right_moved_lines *utils.Set[int] + left_lines, right_lines []string + left_moved_lines *utils.Set[int] + right_moved_lines *utils.Set[int] } func hunk_title(hunk *Hunk) string { From f4bf9cf1c97eec8a5de461640b32d2d1500017be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 07:39:02 +0000 Subject: [PATCH 26/70] Add not_index_byte and not_index_byte2 functions to simdstring package Fixes #9646 --- tools/simdstring/benchmarks_test.go | 42 ++++++++++++++++ tools/simdstring/generate.go | 74 +++++++++++++++++++++++++++++ tools/simdstring/intrinsics.go | 20 ++++++++ tools/simdstring/intrinsics_test.go | 59 +++++++++++++++++++++++ tools/simdstring/scalar.go | 36 ++++++++++++++ 5 files changed, 231 insertions(+) diff --git a/tools/simdstring/benchmarks_test.go b/tools/simdstring/benchmarks_test.go index 37587962b..590074102 100644 --- a/tools/simdstring/benchmarks_test.go +++ b/tools/simdstring/benchmarks_test.go @@ -64,3 +64,45 @@ func BenchmarkIndexByte2(b *testing.B) { t(pos, "scalar") } } + +func BenchmarkNotIndexByte(b *testing.B) { + t := func(pos int, which string) { + // Fill with 'a' and place 'q' (a non-matching byte) at the target position + data := haystack('a', 'q', pos) + f := NotIndexByte + switch which { + case "scalar": + f = not_index_byte_scalar + } + b.Run(fmt.Sprintf("%s_sz=%d", which, pos), func(b *testing.B) { + for b.Loop() { + f(data, 'a') + } + }) + } + for _, pos := range sizes { + t(pos, "simdstring") + t(pos, "scalar") + } +} + +func BenchmarkNotIndexByte2(b *testing.B) { + t := func(pos int, which string) { + // Fill with 'a' and place 'q' (neither 'a' nor 'x') at the target position + data := haystack('a', 'q', pos) + f := NotIndexByte2 + switch which { + case "scalar": + f = not_index_byte2_scalar + } + b.Run(fmt.Sprintf("%s_sz=%d", which, pos), func(b *testing.B) { + for b.Loop() { + f(data, 'a', 'x') + } + }) + } + for _, pos := range sizes { + t(pos, "simdstring") + t(pos, "scalar") + } +} diff --git a/tools/simdstring/generate.go b/tools/simdstring/generate.go index c0830d01c..5e7f5225f 100644 --- a/tools/simdstring/generate.go +++ b/tools/simdstring/generate.go @@ -404,6 +404,12 @@ func encode_cmgt16b(a, b, dest Register) (ans uint32) { return 0x271<<21 | b.ARMId()<<16 | 0xd<<10 | a.ARMId()<<5 | dest.ARMId() } +func encode_not16b(src, dest Register) uint32 { + // NOT Vd.16B, Vn.16B (alias of MVN) + // Encoding: 0 Q 1 01110 size 10000 00101 10 Rn Rd (Q=1, size=00 for .16B) + return 0x6E205800 | (src.ARMId() << 5) | dest.ARMId() +} + func (f *Function) MaskForCountDestructive(vec, ans Register) { // vec is clobbered by this function f.Comment("Count the number of bytes to the first 0xff byte and put the result in", ans) @@ -688,6 +694,24 @@ func (f *Function) Or(a, b, dest Register) { f.AddTrailingComment(dest, "=", a, "|", b, "(bitwise)") } +func (f *Function) NotSelf(r Register) { + if f.ISA.Goarch == ARM64 { + f.Comment("Go assembler doesn't support the VMVN instruction, below we have: NOT", r.ARMFullWidth()+",", r.ARMFullWidth()) + f.instr("WORD", fmt.Sprintf("$0x%x", encode_not16b(r, r))) + f.AddTrailingComment(r, "= ~", r, "(bitwise NOT)") + return + } + all_ones := f.Vec(r.Size) + defer f.ReleaseReg(all_ones) + f.AllOnesRegister(all_ones) + if r.Size == 128 { + f.instr("PXOR", all_ones, r) + } else { + f.instr("VPXOR", all_ones, r, r) + } + f.AddTrailingComment(r, "= ~", r, "(bitwise NOT)") +} + func (f *Function) And(a, b, dest Register) { if f.ISA.Goarch == ARM64 { f.instr("VAND", a.ARMFullWidth(), b.ARMFullWidth(), dest.ARMFullWidth()) @@ -1504,6 +1528,54 @@ func (s *State) indexc0() { } +func (s *State) not_index_byte_body(f *Function) { + b := f.Vec() + f.Set1Epi8("b", b) + test_bytes := func(bytes_to_test, test_ans Register) { + f.CmpEqEpi8(bytes_to_test, b, test_ans) + f.NotSelf(test_ans) + } + s.index_func(f, test_bytes) +} + +func (s *State) not_index_byte() { + f := s.NewFunction("not_index_byte_asm", "Find the index of the first byte that is not b", []FunctionParam{{"data", ByteSlice}, {"b", types.Byte}}, []FunctionParam{{"ans", types.Int}}) + if s.ISA.HasSIMD { + s.not_index_byte_body(f) + } + f = s.NewFunction("not_index_byte_string_asm", "Find the index of the first byte that is not b", []FunctionParam{{"data", types.String}, {"b", types.Byte}}, []FunctionParam{{"ans", types.Int}}) + if s.ISA.HasSIMD { + s.not_index_byte_body(f) + } + +} + +func (s *State) not_index_byte2_body(f *Function) { + b1 := f.Vec() + b2 := f.Vec() + f.Set1Epi8("b1", b1) + f.Set1Epi8("b2", b2) + test_bytes := func(bytes_to_test, test_ans Register) { + f.CmpEqEpi8(bytes_to_test, b1, test_ans) + f.CmpEqEpi8(bytes_to_test, b2, bytes_to_test) + f.Or(test_ans, bytes_to_test, test_ans) + f.NotSelf(test_ans) + } + s.index_func(f, test_bytes) +} + +func (s *State) not_index_byte2() { + f := s.NewFunction("not_index_byte2_asm", "Find the index of the first byte that is neither b1 nor b2", []FunctionParam{{"data", ByteSlice}, {"b1", types.Byte}, {"b2", types.Byte}}, []FunctionParam{{"ans", types.Int}}) + if s.ISA.HasSIMD { + s.not_index_byte2_body(f) + } + f = s.NewFunction("not_index_byte2_string_asm", "Find the index of the first byte that is neither b1 nor b2", []FunctionParam{{"data", types.String}, {"b1", types.Byte}, {"b2", types.Byte}}, []FunctionParam{{"ans", types.Int}}) + if s.ISA.HasSIMD { + s.not_index_byte2_body(f) + } + +} + func (s *State) Generate() { s.test_load() s.test_set1_epi8() @@ -1516,6 +1588,8 @@ func (s *State) Generate() { s.indexbyte2() s.indexc0() s.indexbyte() + s.not_index_byte() + s.not_index_byte2() s.OutputFunction() } diff --git a/tools/simdstring/intrinsics.go b/tools/simdstring/intrinsics.go index 413a9e3a3..71de1df8b 100644 --- a/tools/simdstring/intrinsics.go +++ b/tools/simdstring/intrinsics.go @@ -33,6 +33,18 @@ var IndexC0 func(data []byte) int = index_c0_scalar // Return the index at which the first C0 byte is found or -1 when no such bytes are present. var IndexC0String func(data string) int = index_c0_string_scalar +// Return the index of the first byte in data that is not equal to b. If all bytes equal b, -1 is returned. +var NotIndexByte func(data []byte, b byte) int = not_index_byte_scalar + +// Return the index of the first byte in text that is not equal to b. If all bytes equal b, -1 is returned. +var NotIndexByteString func(text string, b byte) int = not_index_byte_string_scalar + +// Return the index of the first byte in data that is neither a nor b. If all bytes are a or b, -1 is returned. +var NotIndexByte2 func(data []byte, a, b byte) int = not_index_byte2_scalar + +// Return the index of the first byte in text that is neither a nor b. If all bytes are a or b, -1 is returned. +var NotIndexByte2String func(text string, a, b byte) int = not_index_byte2_string_scalar + func init() { switch runtime.GOARCH { case "amd64": @@ -51,6 +63,10 @@ func init() { IndexByte2String = index_byte2_string_asm_256 IndexC0 = index_c0_asm_256 IndexC0String = index_c0_string_asm_256 + NotIndexByte = not_index_byte_asm_256 + NotIndexByteString = not_index_byte_string_asm_256 + NotIndexByte2 = not_index_byte2_asm_256 + NotIndexByte2String = not_index_byte2_string_asm_256 VectorSize = 32 } else if Have128bit { IndexByte = index_byte_asm_128 @@ -59,6 +75,10 @@ func init() { IndexByte2String = index_byte2_string_asm_128 IndexC0 = index_c0_asm_128 IndexC0String = index_c0_string_asm_128 + NotIndexByte = not_index_byte_asm_128 + NotIndexByteString = not_index_byte_string_asm_128 + NotIndexByte2 = not_index_byte2_asm_128 + NotIndexByte2String = not_index_byte2_string_asm_128 VectorSize = 16 } } diff --git a/tools/simdstring/intrinsics_test.go b/tools/simdstring/intrinsics_test.go index 23f29aec3..84e19f4e4 100644 --- a/tools/simdstring/intrinsics_test.go +++ b/tools/simdstring/intrinsics_test.go @@ -244,6 +244,65 @@ func TestSIMDStringOps(t *testing.T) { index_test([]byte("abc"), 'x') index_test([]byte("abc"), 'b') + not_index_test := func(haystack []byte, needle byte) { + var actual int + expected := not_index_byte_scalar(haystack, needle) + + for _, sz := range sizes { + switch sz { + case 16: + actual = not_index_byte_asm_128(haystack, needle) + case 32: + actual = not_index_byte_asm_256(haystack, needle) + } + if actual != expected { + t.Fatalf("not_index failed in: %#v (%d != %d) at size: %d with needle: %#v", string(haystack), expected, actual, sz, needle) + } + } + } + not_index_test(nil, 'a') + not_index_test([]byte{}, 'a') + not_index_test([]byte("aaa"), 'a') + not_index_test([]byte("aaab"), 'a') + not_index_test([]byte("baaa"), 'a') + not_index_test([]byte("abc"), 'a') + for _, sz := range []int{0, 16, 32, 64, 79} { + q := strings.Repeat("a", sz) + "b" + not_index_test([]byte(q), 'a') + not_index_test([]byte(q), 'b') + not_index_test([]byte(strings.Repeat("a", sz)), 'a') + } + + not_index2_test := func(haystack []byte, a, b byte) { + var actual int + expected := not_index_byte2_scalar(haystack, a, b) + + for _, sz := range sizes { + switch sz { + case 16: + actual = not_index_byte2_asm_128(haystack, a, b) + case 32: + actual = not_index_byte2_asm_256(haystack, a, b) + } + if actual != expected { + t.Fatalf("not_index2 failed in: %#v (%d != %d) at size: %d with needles: %#v %#v", string(haystack), expected, actual, sz, a, b) + } + } + } + not_index2_test(nil, 'a', 'b') + not_index2_test([]byte{}, 'a', 'b') + not_index2_test([]byte("aabb"), 'a', 'b') + not_index2_test([]byte("aabbc"), 'a', 'b') + not_index2_test([]byte("caabb"), 'a', 'b') + for _, sz := range []int{0, 16, 32, 64, 79} { + q := strings.Repeat("ab", sz) + "c" + not_index2_test([]byte(q), 'a', 'b') + not_index2_test([]byte(strings.Repeat("ab", sz)), 'a', 'b') + for align := range 32 { + not_index2_test([]byte(strings.Repeat(" ", align)+q), 'a', 'b') + } + } + } func TestIntrinsics(t *testing.T) { diff --git a/tools/simdstring/scalar.go b/tools/simdstring/scalar.go index 3044740ce..811bf7260 100644 --- a/tools/simdstring/scalar.go +++ b/tools/simdstring/scalar.go @@ -57,3 +57,39 @@ func index_c0_string_scalar(data string) int { } return -1 } + +func not_index_byte_scalar(data []byte, b byte) int { + for i, ch := range data { + if ch != b { + return i + } + } + return -1 +} + +func not_index_byte_string_scalar(data string, b byte) int { + for i := 0; i < len(data); i++ { + if data[i] != b { + return i + } + } + return -1 +} + +func not_index_byte2_scalar(data []byte, a, b byte) int { + for i, ch := range data { + if ch != a && ch != b { + return i + } + } + return -1 +} + +func not_index_byte2_string_scalar(data string, a, b byte) int { + for i := 0; i < len(data); i++ { + if data[i] != a && data[i] != b { + return i + } + } + return -1 +} From 7b9b8834a8b8b5eb8426f57dbb80edb9e81d0550 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 12 Mar 2026 13:48:20 +0530 Subject: [PATCH 27/70] ... --- tools/simdstring/intrinsics.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/simdstring/intrinsics.go b/tools/simdstring/intrinsics.go index 71de1df8b..db8e27397 100644 --- a/tools/simdstring/intrinsics.go +++ b/tools/simdstring/intrinsics.go @@ -15,8 +15,7 @@ var VectorSize = 1 // Return the index at which b first occurs in data. If not found -1 is returned. var IndexByte func(data []byte, b byte) int = index_byte_scalar -// Return the index at which either a or b first occurs in text. If neither is -// found -1 is returned. +// Return the index at which b first occurs in text. If not found -1 is returned. var IndexByteString func(text string, b byte) int = index_byte_string_scalar // Return the index at which either a or b first occurs in data. If neither is From 0614f053354eba6fc18932cb3d0c648b8e54e0b4 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 12 Mar 2026 13:50:35 +0530 Subject: [PATCH 28/70] Use new SIMD function for non blank checking --- kittens/diff/patch.go | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/kittens/diff/patch.go b/kittens/diff/patch.go index 9df622ad7..3d238c106 100644 --- a/kittens/diff/patch.go +++ b/kittens/diff/patch.go @@ -453,24 +453,16 @@ func (self *Patch) compute_centers(left_lines, right_lines []string) error { return nil } +// Use SIMD to efficiently find non-blank lines: a line is non-blank if it +// contains at least one character that is not a space or tab. +func is_non_blank(text string) bool { + return simdstring.NotIndexByte2String(text, ' ', '\t') >= 0 +} + func (self *Patch) detect_moved_lines(left_lines, right_lines []string) { // Build maps from line text to lists of line numbers for removed and added lines. removed := make(map[string][]int) // text -> left line numbers added := make(map[string][]int) // text -> right line numbers - // Use SIMD to efficiently find non-blank lines: a line is non-blank if it - // contains at least one character that is not a space or tab. - is_non_blank := func(text string) bool { - for len(text) > 0 { - idx := simdstring.IndexByte2String(text, ' ', '\t') - if idx != 0 { - // idx < 0: no space/tab found, remaining chars are non-blank; - // idx > 0: non-blank chars exist before the first space/tab. - return true - } - text = text[1:] - } - return false - } for _, hunk := range self.all_hunks { for _, chunk := range hunk.chunks { if !chunk.is_context { From 01a70e06c4bd9aeeb438552ac8a8d1337e73df0b Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 12 Mar 2026 13:53:41 +0530 Subject: [PATCH 29/70] ... --- kittens/diff/patch.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kittens/diff/patch.go b/kittens/diff/patch.go index 3d238c106..18cd49d95 100644 --- a/kittens/diff/patch.go +++ b/kittens/diff/patch.go @@ -461,8 +461,8 @@ func is_non_blank(text string) bool { func (self *Patch) detect_moved_lines(left_lines, right_lines []string) { // Build maps from line text to lists of line numbers for removed and added lines. - removed := make(map[string][]int) // text -> left line numbers - added := make(map[string][]int) // text -> right line numbers + removed := make(map[string][]int, len(left_lines)) // text -> left line numbers + added := make(map[string][]int, len(right_lines)) // text -> right line numbers for _, hunk := range self.all_hunks { for _, chunk := range hunk.chunks { if !chunk.is_context { From 2c395a1ef97280dc6cf23b64934af13cc3024e49 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 12 Mar 2026 18:03:53 +0530 Subject: [PATCH 30/70] Fix #9650 --- kitty/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kitty/window.py b/kitty/window.py index 76acc8547..92a4f80cc 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -1251,7 +1251,7 @@ class Window: try: parts = tuple(map(int, raw_data.split(';')))[1:] except Exception: - log_error(f'Ignoring malmormed OSC 9;4 progress report: {raw_data!r}') + log_error(f'Ignoring malformed OSC 9;4 progress report: {raw_data!r}') return self.progress.update(*parts[:2]) if (tab := self.tabref()) is not None: From 39683e0c34fa24ff6d52a749c2b136373f463f7c Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 12 Mar 2026 18:33:52 +0530 Subject: [PATCH 31/70] ... --- kitty/mouse.c | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/kitty/mouse.c b/kitty/mouse.c index c61aaa3cf..65d331e75 100644 --- a/kitty/mouse.c +++ b/kitty/mouse.c @@ -1388,7 +1388,7 @@ pixel_scroll_enabled_for_screen(const Screen *screen) { void scroll_event(const GLFWScrollEvent *ev) { - debug("\x1b[36mScroll\x1b[m %s x: %f y: %f momentum: %s modifiers: %s\n", scroll_offset_type(ev->offset_type), ev->x_offset, ev->y_offset, scroll_phase(ev->momentum_type), format_mods(ev->keyboard_modifiers)); + debug("\x1b[36mScroll\x1b[m type=%s x: %f y: %f momentum: %s modifiers: %s\n", scroll_offset_type(ev->offset_type), ev->x_offset, ev->y_offset, scroll_phase(ev->momentum_type), format_mods(ev->keyboard_modifiers)); static id_type window_for_momentum_scroll = 0; static bool main_screen_for_momentum_scroll = false; // allow scroll events even if window is not currently focused (in @@ -1461,14 +1461,14 @@ scroll_event(const GLFWScrollEvent *ev) { write_escape_code_to_child(screen, ESC_CSI, mouse_event_buf); } } - } else { - if (screen->linebuf == screen->main_linebuf) { - screen_history_scroll(screen, abs(s), upwards); - if (screen->selections.in_progress) update_drag(w); + } else { + if (screen->linebuf == screen->main_linebuf) { + screen_history_scroll(screen, abs(s), upwards); + if (screen->selections.in_progress) update_drag(w); + } + else fake_scroll(w, abs(s), upwards); } - else fake_scroll(w, abs(s), upwards); } - } } } if (ev->x_offset != 0.0) { From 5ba3d10471398c00c9f51a3ee6d2935ce5b625fe Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 12 Mar 2026 18:41:20 +0530 Subject: [PATCH 32/70] X11: Fix a regression that caused some high res scroll devices to be treated as line based scroll devices Apparently when running under XWayland, we cant rely on libinput to detect highres scroll devices. Sigh. Linux input is such a disaster. Dunno if this will break something else, hopefully not. Fixes #9649 --- docs/changelog.rst | 2 ++ glfw/x11_window.c | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index bf0dfad8b..191aa50e3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -178,6 +178,8 @@ Detailed list of changes - Fix kitty hanging on startup on Intel macs (:iss:`9643`) +- X11: Fix a regression that caused some high res scroll devices to be treated as line based scroll devices (:iss:`9649`) + 0.46.0 [2026-03-11] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/glfw/x11_window.c b/glfw/x11_window.c index 31d44f44e..539ced065 100644 --- a/glfw/x11_window.c +++ b/glfw/x11_window.c @@ -29,6 +29,7 @@ #define _GNU_SOURCE #include "internal.h" +#include "math.h" #include "backend_utils.h" #include "linux_notify.h" #include "../kitty/monotonic.h" @@ -1446,7 +1447,7 @@ handle_xi_motion_event(_GLFWwindow *window, XIDeviceEvent *de) { *off = delta; if (!d->is_highres) { if (v->increment == 120.) type = GLFW_SCROLL_OFFEST_V120; - else { + else if (fabs(delta) >= fabs(v->increment)) { type = GLFW_SCROLL_OFFSET_LINES; if (v->increment != 0) *off /= v->increment; } From 99639f1373ad4d687869896b139ef17b9b3a8bc5 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 12 Mar 2026 20:20:41 +0530 Subject: [PATCH 33/70] XInput2: Assume any fractional scroll value means the device is high res Because as far as I can tell there is no reliable way to detect high res scroll devices under XWayland. Sigh. --- glfw/x11_window.c | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/glfw/x11_window.c b/glfw/x11_window.c index 539ced065..f46365a75 100644 --- a/glfw/x11_window.c +++ b/glfw/x11_window.c @@ -1447,9 +1447,16 @@ handle_xi_motion_event(_GLFWwindow *window, XIDeviceEvent *de) { *off = delta; if (!d->is_highres) { if (v->increment == 120.) type = GLFW_SCROLL_OFFEST_V120; - else if (fabs(delta) >= fabs(v->increment)) { - type = GLFW_SCROLL_OFFSET_LINES; - if (v->increment != 0) *off /= v->increment; + else { + // XInput2 has no reliable way to distinguish high res scroll devices so we + // assume that if fractional values are seen it must be high res. Sigh, Linux, the + // land of truly wondrous wonders. See https://github.com/kovidgoyal/kitty/issues/9649 + double int_part; bool delta_is_fractional = modf(delta, &int_part) != 0.; + if (delta_is_fractional) d->is_highres = true; + else { + type = GLFW_SCROLL_OFFSET_LINES; + if (v->increment != 0) *off /= v->increment; + } } } } From c0b549fee817c175731f1049bf86eeb9af292e9c Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 12 Mar 2026 21:44:20 +0530 Subject: [PATCH 34/70] Make XI2 scroll offset type detection more robust Since we have to use heuristics, lets at least collect stats for a few events before deciding. --- glfw/x11_init.c | 6 +++++- glfw/x11_platform.h | 4 +++- glfw/x11_window.c | 42 +++++++++++++++++++++++++++++------------- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/glfw/x11_init.c b/glfw/x11_init.c index 0b518af51..5bea3c3fc 100644 --- a/glfw/x11_init.c +++ b/glfw/x11_init.c @@ -216,8 +216,12 @@ read_xi_scroll_devices(void) { if (_glfw.x11.xi.num_scroll_devices >= arraysz(_glfw.x11.xi.scroll_devices)) continue; d = &_glfw.x11.xi.scroll_devices[_glfw.x11.xi.num_scroll_devices++]; *d = (XIScrollDevice){ - .is_highres=is_highres, .is_finger_based=is_finger_based, .deviceid=device->deviceid, .sourceid=scroll->sourceid, + .is_finger_based=is_finger_based, .deviceid=device->deviceid, .sourceid=scroll->sourceid, }; + if (is_highres) { + d->type_detected = true; + d->offset_type = GLFW_SCROLL_OFFEST_HIGHRES; + } memcpy(d->name, device->name, MIN(sizeof(d->name)-1, strlen(device->name))); } if (d->num_valuators >= arraysz(d->valuators)) continue; diff --git a/glfw/x11_platform.h b/glfw/x11_platform.h index a8bcdaf2f..33f32dfe8 100644 --- a/glfw/x11_platform.h +++ b/glfw/x11_platform.h @@ -239,12 +239,14 @@ typedef struct XIScrollValuator { } XIScrollValuator; typedef struct XIScrollDevice { - bool is_highres; bool is_finger_based; + bool type_detected; int deviceid, sourceid; XIScrollValuator valuators[8]; unsigned num_valuators; char name[32]; + unsigned num_events; + GLFWOffsetType offset_type; } XIScrollDevice; typedef struct XdndSelectionRequest { diff --git a/glfw/x11_window.c b/glfw/x11_window.c index f46365a75..7650fb90b 100644 --- a/glfw/x11_window.c +++ b/glfw/x11_window.c @@ -1415,6 +1415,11 @@ handle_mouse_move_event(_GLFWwindow *window, const int x, const int y) { window->x11.lastCursorPosY = y; } +static bool +number_has_fractional_part(double x) { + return fabs(x - round(x)) >= 1e-6; +} + static void handle_xi_motion_event(_GLFWwindow *window, XIDeviceEvent *de) { XIScrollDevice *d = NULL; @@ -1442,24 +1447,35 @@ handle_xi_motion_event(_GLFWwindow *window, XIDeviceEvent *de) { scroll_valuator_found = true; double delta = value - v->value; v->value = value; - if (v->is_vertical) delta *= -1; + delta *= -1; double *off = v->is_vertical ? &yOffset : &xOffset; *off = delta; - if (!d->is_highres) { - if (v->increment == 120.) type = GLFW_SCROLL_OFFEST_V120; - else { - // XInput2 has no reliable way to distinguish high res scroll devices so we - // assume that if fractional values are seen it must be high res. Sigh, Linux, the - // land of truly wondrous wonders. See https://github.com/kovidgoyal/kitty/issues/9649 - double int_part; bool delta_is_fractional = modf(delta, &int_part) != 0.; - if (delta_is_fractional) d->is_highres = true; - else { - type = GLFW_SCROLL_OFFSET_LINES; - if (v->increment != 0) *off /= v->increment; + d->num_events++; + if (!d->type_detected) { + if (v->increment == 120.) { + d->type_detected = true; + d->offset_type = GLFW_SCROLL_OFFEST_V120; + } else { + bool delta_is_fractional = number_has_fractional_part(delta); + if (delta_is_fractional) { + if (fabs(delta * 120 - round(delta * 120)) < 0.01) { + d->type_detected = d->num_events > 2; + d->offset_type = GLFW_SCROLL_OFFEST_V120; + } else { + d->type_detected = true; + d->offset_type = GLFW_SCROLL_OFFEST_HIGHRES; + } + } else { + d->type_detected = d->num_events > 2; + d->offset_type = GLFW_SCROLL_OFFSET_LINES; } } } + if (d->offset_type == GLFW_SCROLL_OFFSET_LINES) { + if (v->increment != 0) *off /= v->increment; + } } + type = d->offset_type; if (xOffset != 0 || yOffset != 0) { // Get keyboard modifiers int mods = translateState(de->mods.effective); @@ -1473,7 +1489,7 @@ handle_xi_motion_event(_GLFWwindow *window, XIDeviceEvent *de) { }; // For high-resolution, finger-based scrolling, use timer-based momentum scrolling - if (d->is_highres && d->is_finger_based && type == GLFW_SCROLL_OFFEST_HIGHRES) { + if (d->is_finger_based && type == GLFW_SCROLL_OFFEST_HIGHRES) { // Reset the timer on each scroll event x11_cancel_momentum_scroll_timer(); From ffe8cef1a8f75d5b327311c266ffb7086621af09 Mon Sep 17 00:00:00 2001 From: 1024th Date: Fri, 13 Mar 2026 01:03:20 +0800 Subject: [PATCH 35/70] Fix font detection command on linux in setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 826da394d..a26ff354e 100755 --- a/setup.py +++ b/setup.py @@ -949,7 +949,7 @@ def add_builtin_fonts(args: Options) -> None: break else: lines = subprocess.check_output([ - 'fc-match', '--format', '%{file}\n%{postscriptname}', f'term:postscriptname={psname}', 'file', 'postscriptname']).decode().splitlines() + 'fc-list', '--format', '%{file}\n%{postscriptname}', f':postscriptname={psname}']).decode().splitlines() if len(lines) != 2: raise SystemExit(f'fc-match returned unexpected output: {lines}') if lines[1] != psname: From 98931d99b0f4e0014022782bfd180744495766d4 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 13 Mar 2026 08:47:45 +0530 Subject: [PATCH 36/70] Wayland: Fix momentum scrolling not working on compositors that send a stop frame with no axis information Fixes #9653 --- docs/changelog.rst | 2 ++ glfw/wl_init.c | 10 +++++++++- glfw/wl_platform.h | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 191aa50e3..f90bfb2de 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -180,6 +180,8 @@ Detailed list of changes - X11: Fix a regression that caused some high res scroll devices to be treated as line based scroll devices (:iss:`9649`) +- Wayland: Fix momentum scrolling not working on compositors that send a stop frame with no axis information (:iss:`9653`) + 0.46.0 [2026-03-11] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/glfw/wl_init.c b/glfw/wl_init.c index e38f651ae..be840b40f 100644 --- a/glfw/wl_init.c +++ b/glfw/wl_init.c @@ -206,27 +206,35 @@ pointer_handle_frame(void *data UNUSED, struct wl_pointer *pointer UNUSED) { _GLFWwindow* window = _glfw.wl.pointerFocus; if (!window) return; GLFWScrollEvent ev = {.keyboard_modifiers=_glfw.wl.xkb.states.modifiers}; + bool found = false; if (info.discrete.y_axis_type != AXIS_EVENT_UNKNOWN) { ev.unscaled.y = info.discrete.y; if (info.discrete.y_axis_type == AXIS_EVENT_VALUE120) ev.offset_type = GLFW_SCROLL_OFFEST_V120; + found = true; } else if (info.continuous.y_axis_type != AXIS_EVENT_UNKNOWN) { ev.offset_type = GLFW_SCROLL_OFFEST_HIGHRES; ev.unscaled.y = info.continuous.y; + found = true; } if (info.discrete.x_axis_type != AXIS_EVENT_UNKNOWN) { ev.unscaled.x = info.discrete.x; if (info.discrete.x_axis_type == AXIS_EVENT_VALUE120) ev.offset_type = GLFW_SCROLL_OFFEST_V120; + found = true; } else if (info.continuous.x_axis_type != AXIS_EVENT_UNKNOWN) { ev.offset_type = GLFW_SCROLL_OFFEST_HIGHRES; ev.unscaled.x = info.continuous.x; + found = true; } + bool stopped = info.y_stop_received || info.x_stop_received; + if (!found && stopped) ev.offset_type = window->wl.prev_frame_offset_type; ev.unscaled.x *= -1; const double scale = ev.offset_type == GLFW_SCROLL_OFFEST_HIGHRES ? _glfwWaylandWindowScale(window) : 1; ev.x_offset = scale * ev.unscaled.x; ev.y_offset = scale * ev.unscaled.y; glfw_handle_scroll_event_for_momentum( - window, &ev, info.y_stop_received || info.x_stop_received, info.source_type == WL_POINTER_AXIS_SOURCE_FINGER); + window, &ev, stopped, info.source_type == WL_POINTER_AXIS_SOURCE_FINGER); + window->wl.prev_frame_offset_type = ev.offset_type; /* clear pointer_curr_axis_info for next frame */ memset(&info, 0, sizeof(info)); } diff --git a/glfw/wl_platform.h b/glfw/wl_platform.h index e9ae1533e..8e8f72ba7 100644 --- a/glfw/wl_platform.h +++ b/glfw/wl_platform.h @@ -212,6 +212,7 @@ typedef struct _GLFWwindowWayland uint32_t source_type; monotonic_t x_start_time, x_stop_time, y_stop_time, y_start_time; } pointer_curr_axis_info; + GLFWOffsetType prev_frame_offset_type; _GLFWcursor* currentCursor; double cursorPosX, cursorPosY, allCursorPosX, allCursorPosY; From c332211997852f4781d3a7d9bf9db963f2a3831c Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 13 Mar 2026 19:16:44 +0530 Subject: [PATCH 37/70] Possible fix for #9656 --- kitty/glfw.c | 1 + 1 file changed, 1 insertion(+) diff --git a/kitty/glfw.c b/kitty/glfw.c index 6e9924b5b..206a68625 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -734,6 +734,7 @@ read_drop_data(GLFWwindow *window, GLFWDropEvent *ev) { PyObject *data = chunk; RAII_PyObject(existing, PyDict_GetItemString(global_state.drop_dest.data, ev->mimes[0])); if (existing) { + existing = Py_NewRef(existing); // because PyBytes_Concat steals a reference PyBytes_Concat(&existing, chunk); data = existing; } From a97f468a0226851385a0578b98b1f2ef1dfccbbe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:20:18 +0000 Subject: [PATCH 38/70] Add remove_duplicate_mimes() and call it from update_allowed_mimes_for_drop() Fixes #9657 --- kitty/glfw.c | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/kitty/glfw.c b/kitty/glfw.c index 206a68625..a85044cfa 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -682,6 +682,23 @@ is_droppable_mime(const char *mime) { return 0; } +static size_t +remove_duplicate_mimes(const char **mimes, size_t count) { + // Use simple O(n²) scan since lists are typically small + size_t new_count = 0; + for (size_t i = 0; i < count; i++) { + bool is_duplicate = false; + for (size_t j = 0; j < new_count; j++) { + if (strcmp(mimes[i], mimes[j]) == 0) { is_duplicate = true; break; } + } + if (!is_duplicate) { + if (new_count != i) SWAP(mimes[i], mimes[new_count]); + new_count++; + } + } + return new_count; +} + static void update_allowed_mimes_for_drop(GLFWDropEvent *ev) { if (ev->mimes && ev->num_mimes) { @@ -712,7 +729,7 @@ update_allowed_mimes_for_drop(GLFWDropEvent *ev) { } } if (prio_arr != priorities) free(prio_arr); - ev->num_mimes = new_count; + ev->num_mimes = remove_duplicate_mimes(ev->mimes, new_count); } } From e72f49952d7f35c153f46763a159a289b9b91a6e Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 13 Mar 2026 20:19:33 +0530 Subject: [PATCH 39/70] Linux: Fix regression that broke drag and drop from GTK applications Fixes #9656 --- docs/changelog.rst | 2 ++ glfw/wl_window.c | 2 +- kitty/glfw.c | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f90bfb2de..ad743dab4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -182,6 +182,8 @@ Detailed list of changes - Wayland: Fix momentum scrolling not working on compositors that send a stop frame with no axis information (:iss:`9653`) +- Linux: Fix regression that broke drag and drop from GTK applications (:iss:`9656`) + 0.46.0 [2026-03-11] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/glfw/wl_window.c b/glfw/wl_window.c index 1ebeeea83..1d00917dd 100644 --- a/glfw/wl_window.c +++ b/glfw/wl_window.c @@ -2577,7 +2577,7 @@ _glfwPlatformReadAvailableDropData(GLFWwindow *w, GLFWDropEvent *ev, char *buffe for (size_t o = 0; o < offer->dd_count; o++) { if (offer->requested_drop_data[o].fd == fd) { ssize_t ret; - do { ret = read(fd, buffer, sz); } while (ret < 0 && errno == EINTR); + do { ret = read(fd, buffer, sz); } while (ret < 0 && (errno == EINTR || errno == EAGAIN)); if (ret <= 0) removeWatch(&_glfw.wl.eventLoopData, offer->requested_drop_data[o].watch_id); return ret < 0 ? -errno : ret; } diff --git a/kitty/glfw.c b/kitty/glfw.c index a85044cfa..9b98d74d1 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -729,7 +729,7 @@ update_allowed_mimes_for_drop(GLFWDropEvent *ev) { } } if (prio_arr != priorities) free(prio_arr); - ev->num_mimes = remove_duplicate_mimes(ev->mimes, new_count); + ev->num_mimes = new_count; } } @@ -799,6 +799,7 @@ on_drop(GLFWwindow *window, GLFWDropEvent *ev) { break; } update_allowed_mimes_for_drop(ev); + ev->num_mimes = remove_duplicate_mimes(ev->mimes, ev->num_mimes); global_state.drop_dest.num_left = ev->num_mimes; if (!global_state.drop_dest.num_left || !(global_state.drop_dest.data = PyDict_New())) { ev->finish_drop(window, GLFW_DRAG_OPERATION_GENERIC); From b66c6c4932810fa493c1c9e21d7fcfbff7c7fd8e Mon Sep 17 00:00:00 2001 From: Alexey Shurygin Date: Sun, 15 Mar 2026 01:15:05 +0300 Subject: [PATCH 40/70] Fix: Dictation does not start on double Fn key press on Mac OS --- glfw/cocoa_init.m | 20 ++++++++ glfw/cocoa_window.m | 28 +++++++++++ kitty_tests/check_build.py | 96 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+) diff --git a/glfw/cocoa_init.m b/glfw/cocoa_init.m index e258c3d65..c5a1205cc 100644 --- a/glfw/cocoa_init.m +++ b/glfw/cocoa_init.m @@ -839,6 +839,22 @@ is_apple_jis_layout_function_key(NSEvent *event) { return [event keyCode] == 0x66 /* kVK_JIS_Eisu */ || [event keyCode] == 0x68 /* kVK_JIS_Kana */; } +static bool +has_apple_fn_global_shortcut(void) { + NSDictionary *hitoolbox_settings = [[NSUserDefaults standardUserDefaults] persistentDomainForName:@"com.apple.HIToolbox"]; + id obj = [hitoolbox_settings objectForKey:@"AppleFnUsageType"]; + if (![obj isKindOfClass:[NSNumber class]]) return false; + return [obj integerValue] != 0; +} + +static bool +is_apple_fn_global_shortcut(NSEvent *event) { + if ([event keyCode] != 0x3f /* kVK_Function */) return false; + NSEventModifierFlags mods = USEFUL_MODS([event modifierFlags]); + if (mods != 0 && mods != NSEventModifierFlagFunction) return false; + return has_apple_fn_global_shortcut(); +} + GLFWAPI GLFWapplicationshouldhandlereopenfun glfwSetApplicationShouldHandleReopen(GLFWapplicationshouldhandlereopenfun callback) { GLFWapplicationshouldhandlereopenfun previous = handle_reopen_callback; handle_reopen_callback = callback; @@ -949,6 +965,10 @@ int _glfwPlatformInit(bool *supports_window_occlusion) debug_key("-------------- flags changed -----------------\n"); debug_key("%s\n", [[event description] UTF8String]); last_keydown_shortcut_event.virtual_key_code = 0xffff; + if (!_glfw.ignoreOSKeyboardProcessing && !_glfw.keyboard_grabbed && is_apple_fn_global_shortcut(event)) { + debug_key("flagsChanged triggered global fn shortcut ignoring\n"); + return event; + } // switching to the next input source is only confirmed when all modifier keys are released if (last_keydown_shortcut_event.input_source_switch_modifiers) { if (!([event modifierFlags] & last_keydown_shortcut_event.input_source_switch_modifiers)) diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index 768f3b545..d7587b27d 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -99,6 +99,22 @@ polymorphic_string_as_utf8(id string) { return [characters UTF8String]; } +static bool +forward_dictation_selector_to_app(SEL selector, id sender) { + static SEL start_dictation_selector = NULL, stop_dictation_selector = NULL; + if (start_dictation_selector == NULL) { + start_dictation_selector = NSSelectorFromString(@"startDictation:"); + stop_dictation_selector = NSSelectorFromString(@"stopDictation:"); + } + if (selector != start_dictation_selector && selector != stop_dictation_selector) return false; + if ([NSApp respondsToSelector:selector]) { + debug_key("Forwarding %s to NSApp\n", [NSStringFromSelector(selector) UTF8String]); + [NSApp performSelector:selector withObject:sender]; + return true; + } + return false; +} + static uint32_t vk_code_to_functional_key_code(uint8_t key_code) { // {{{ switch(key_code) { @@ -842,6 +858,7 @@ static void update_titlebar_button_visibility_after_fullscreen_transition(_GLFWw // With the default macOS keybindings, pressing certain key combinations // (e.g. Ctrl+/, Ctrl+Cmd+Down/Left/Right) will produce a beep sound. debug_key("\n\tTextInputCtx: doCommandBySelector: (%s)\n", [NSStringFromSelector(selector) UTF8String]); + if (forward_dictation_selector_to_app(selector, nil)) return; } @end // }}} @@ -1930,6 +1947,17 @@ void _glfwPlatformUpdateIMEState(_GLFWwindow *w, const GLFWIMEUpdateEvent *ev) { - (void)doCommandBySelector:(SEL)selector { debug_key("\n\tdoCommandBySelector: (%s)\n", [NSStringFromSelector(selector) UTF8String]); + if (forward_dictation_selector_to_app(selector, self)) return; +} + +- (void)startDictation:(id)sender +{ + forward_dictation_selector_to_app(_cmd, sender); +} + +- (void)stopDictation:(id)sender +{ + forward_dictation_selector_to_app(_cmd, sender); } - (BOOL)isAccessibilityElement diff --git a/kitty_tests/check_build.py b/kitty_tests/check_build.py index 086175558..fbbdcf9dc 100644 --- a/kitty_tests/check_build.py +++ b/kitty_tests/check_build.py @@ -2,10 +2,13 @@ # License: GPLv3 Copyright: 2021, Kovid Goyal +import json import os import stat import subprocess import sys +import tempfile +import textwrap import unittest from functools import partial @@ -35,6 +38,99 @@ class TestBuild(BaseTest): for name in 'cell border bgimage tint graphics'.split(): Program(name) + def test_macos_dictation_forwarding(self) -> None: + from kitty.constants import glfw_path, is_macos + if not is_macos: + self.skipTest('Dictation smoke test is macOS only') + cocoa_module = glfw_path('cocoa') + probe = textwrap.dedent('''\ + #import + #import + #import + + static int start_calls = 0; + static int stop_calls = 0; + static id last_sender = nil; + + static void fake_start_dictation(id self, SEL _cmd, id sender) { + (void)self; (void)_cmd; + start_calls++; + last_sender = sender; + } + + static void fake_stop_dictation(id self, SEL _cmd, id sender) { + (void)self; (void)_cmd; + stop_calls++; + last_sender = sender; + } + + static void require_true(BOOL condition, const char *message) { + if (!condition) { + fprintf(stderr, "FAIL: %s\\n", message); + exit(1); + } + } + + int main(void) { + @autoreleasepool { + [NSApplication sharedApplication]; + void *handle = dlopen(@@COCOA_MODULE@@, RTLD_NOW | RTLD_GLOBAL); + require_true(handle != NULL, dlerror()); + + SEL start = NSSelectorFromString(@"startDictation:"); + SEL stop = NSSelectorFromString(@"stopDictation:"); + Method start_method = class_getInstanceMethod([NSApplication class], start); + Method stop_method = class_getInstanceMethod([NSApplication class], stop); + require_true(start_method != NULL, "NSApplication startDictation: missing"); + require_true(stop_method != NULL, "NSApplication stopDictation: missing"); + method_setImplementation(start_method, (IMP)fake_start_dictation); + method_setImplementation(stop_method, (IMP)fake_stop_dictation); + + Class view_cls = NSClassFromString(@"GLFWContentView"); + Class context_cls = NSClassFromString(@"GLFWTextInputContext"); + require_true(view_cls != Nil, "GLFWContentView class not loaded"); + require_true(context_cls != Nil, "GLFWTextInputContext class not loaded"); + + id view = [[view_cls alloc] initWithFrame:NSMakeRect(0, 0, 10, 10)]; + require_true([view respondsToSelector:start], "GLFWContentView does not expose startDictation:"); + require_true([view respondsToSelector:stop], "GLFWContentView does not expose stopDictation:"); + + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [view performSelector:start withObject:@"menu sender"]; + #pragma clang diagnostic pop + require_true(start_calls == 1, "startDictation: action was not forwarded to NSApplication"); + require_true([(id)last_sender isEqual:@"menu sender"], "startDictation: forwarded wrong sender"); + + [view doCommandBySelector:start]; + require_true(start_calls == 2, "doCommandBySelector:startDictation: was swallowed"); + require_true(last_sender == view, "doCommandBySelector:startDictation: should forward self as sender"); + + id context = [[context_cls alloc] initWithClient:view]; + require_true(context != nil, "GLFWTextInputContext initWithClient: failed"); + [context doCommandBySelector:stop]; + require_true(stop_calls == 1, "GLFWTextInputContext did not forward stopDictation:"); + require_true(last_sender == nil, "GLFWTextInputContext should forward nil sender"); + + printf("dictation forwarding probe passed\\n"); + } + return 0; + } + ''').replace('@@COCOA_MODULE@@', json.dumps(cocoa_module)) + with tempfile.TemporaryDirectory() as tdir: + src = os.path.join(tdir, 'dictation_probe.m') + exe = os.path.join(tdir, 'dictation_probe') + with open(src, 'w') as f: + f.write(probe) + cp = subprocess.run( + ['clang', '-framework', 'AppKit', src, '-o', exe], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True + ) + self.assertEqual(cp.returncode, 0, cp.stdout) + cp = subprocess.run([exe], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + self.assertEqual(cp.returncode, 0, cp.stdout) + self.assertIn('dictation forwarding probe passed', cp.stdout) + def test_glfw_modules(self) -> None: from kitty.constants import glfw_path, is_macos linux_backends = ['x11'] From 827c8dd61468237dead148dbbf1b78e0ff1b37af Mon Sep 17 00:00:00 2001 From: Alexey Shurygin Date: Sun, 15 Mar 2026 01:33:38 +0300 Subject: [PATCH 41/70] Address PR review feedback --- glfw/cocoa_init.m | 2 ++ kitty_tests/check_build.py | 10 +++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/glfw/cocoa_init.m b/glfw/cocoa_init.m index c5a1205cc..8bac2837d 100644 --- a/glfw/cocoa_init.m +++ b/glfw/cocoa_init.m @@ -844,6 +844,8 @@ has_apple_fn_global_shortcut(void) { NSDictionary *hitoolbox_settings = [[NSUserDefaults standardUserDefaults] persistentDomainForName:@"com.apple.HIToolbox"]; id obj = [hitoolbox_settings objectForKey:@"AppleFnUsageType"]; if (![obj isKindOfClass:[NSNumber class]]) return false; + // Non-zero AppleFnUsageType means macOS has reserved Fn/Globe for a + // system action such as input source switching, emoji picker, or dictation. return [obj integerValue] != 0; } diff --git a/kitty_tests/check_build.py b/kitty_tests/check_build.py index fbbdcf9dc..dd11e31c4 100644 --- a/kitty_tests/check_build.py +++ b/kitty_tests/check_build.py @@ -47,6 +47,7 @@ class TestBuild(BaseTest): #import #import #import + #import static int start_calls = 0; static int stop_calls = 0; @@ -91,7 +92,9 @@ class TestBuild(BaseTest): require_true(view_cls != Nil, "GLFWContentView class not loaded"); require_true(context_cls != Nil, "GLFWTextInputContext class not loaded"); - id view = [[view_cls alloc] initWithFrame:NSMakeRect(0, 0, 10, 10)]; + SEL init_with_glfw_window = NSSelectorFromString(@"initWithGlfwWindow:"); + id view = ((id (*)(id, SEL, void *)) objc_msgSend)([view_cls alloc], init_with_glfw_window, NULL); + require_true(view != nil, "GLFWContentView initWithGlfwWindow: failed"); require_true([view respondsToSelector:start], "GLFWContentView does not expose startDictation:"); require_true([view respondsToSelector:stop], "GLFWContentView does not expose stopDictation:"); @@ -106,8 +109,9 @@ class TestBuild(BaseTest): require_true(start_calls == 2, "doCommandBySelector:startDictation: was swallowed"); require_true(last_sender == view, "doCommandBySelector:startDictation: should forward self as sender"); - id context = [[context_cls alloc] initWithClient:view]; - require_true(context != nil, "GLFWTextInputContext initWithClient: failed"); + id context = [view inputContext]; + require_true(context != nil, "GLFWContentView inputContext missing"); + require_true([context isKindOfClass:context_cls], "GLFWContentView inputContext has wrong class"); [context doCommandBySelector:stop]; require_true(stop_calls == 1, "GLFWTextInputContext did not forward stopDictation:"); require_true(last_sender == nil, "GLFWTextInputContext should forward nil sender"); From 30756bb819156990cd45b66eeb6e3a0dc00a6fd1 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 15 Mar 2026 08:38:59 +0530 Subject: [PATCH 42/70] Cleanup previous PR --- docs/changelog.rst | 2 ++ kitty_tests/check_build.py | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index ad743dab4..d7a3cad79 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -184,6 +184,8 @@ Detailed list of changes - Linux: Fix regression that broke drag and drop from GTK applications (:iss:`9656`) +- macOS: Fix using Fn key for start dictation not working (:iss:`9661`) + 0.46.0 [2026-03-11] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/kitty_tests/check_build.py b/kitty_tests/check_build.py index dd11e31c4..dfc9ed3a6 100644 --- a/kitty_tests/check_build.py +++ b/kitty_tests/check_build.py @@ -4,6 +4,7 @@ import json import os +import shutil import stat import subprocess import sys @@ -40,8 +41,8 @@ class TestBuild(BaseTest): def test_macos_dictation_forwarding(self) -> None: from kitty.constants import glfw_path, is_macos - if not is_macos: - self.skipTest('Dictation smoke test is macOS only') + if not is_macos or not shutil.which('clang'): + self.skipTest('Dictation smoke test is macOS only and requires clang') cocoa_module = glfw_path('cocoa') probe = textwrap.dedent('''\ #import From b93f1badd18d565a1a6cbc14d5922fd9aca20402 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 02:55:31 +0000 Subject: [PATCH 43/70] Fix tab bar border artifacts with background_opacity on non-cell-aligned windows Fixes #9663: visible gray/black bars appear at the left and right sides of the tab bar when background_opacity is set and the window width is not a multiple of the cell width. Three fixes in update_blank_rects: 1. Fix off-by-one: use g.right instead of g.right-1 for the right blank rect start position. The old code caused a 1-pixel overlap with tab content and spuriously added a right blank rect even for perfectly-aligned windows. 2. Fix blank rect colors: only use tab_bar_left/right_edge_color when tab_bar_margin_width > 0 (explicit configured margin). When margin_width=0 (default), use default_bg which blends with the window background instead of showing a solid tab-colored bar. 3. Fix inner margin BOTTOM_EDGE height: use tab_bar.top instead of vh so the inner margin blank rect covers only the inner margin area. Fixes #9664 Co-authored-by: kovidgoyal <1308621+kovidgoyal@users.noreply.github.com> --- kitty/tab_bar.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/kitty/tab_bar.py b/kitty/tab_bar.py index 4ecd9633e..aaf96f598 100644 --- a/kitty/tab_bar.py +++ b/kitty/tab_bar.py @@ -687,7 +687,7 @@ class TabBar: if opts.tab_bar_margin_height.outer: blank_rects.append(Border(0, tab_bar.bottom, vw, vh, bg)) if opts.tab_bar_margin_height.inner: - blank_rects.append(Border(0, central.bottom, vw, vh, bg)) + blank_rects.append(Border(0, central.bottom, vw, tab_bar.top, bg)) else: # top if opts.tab_bar_margin_height.outer: blank_rects.append(Border(0, 0, vw, tab_bar.top, bg)) @@ -695,13 +695,13 @@ class TabBar: blank_rects.append(Border(0, tab_bar.bottom, vw, central.top, bg)) g = self.window_geometry left_bg = right_bg = bg - if opts.tab_bar_margin_color is None or opts.tab_bar_margin_width == 0: + if opts.tab_bar_margin_color is None and opts.tab_bar_margin_width > 0: left_bg = BorderColor.tab_bar_left_edge_color right_bg = BorderColor.tab_bar_right_edge_color if g.left > 0: blank_rects.append(Border(0, g.top, g.left, g.bottom, left_bg)) - if g.right - 1 < vw: - blank_rects.append(Border(g.right - 1, g.top, vw, g.bottom, right_bg)) + if g.right < vw: + blank_rects.append(Border(g.right, g.top, vw, g.bottom, right_bg)) self.blank_rects = tuple(blank_rects) def layout(self) -> None: From b3b7d0596d0c3f81fe1d27accb23fe901fb0b4c3 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 15 Mar 2026 09:20:33 +0530 Subject: [PATCH 44/70] Dont use neighboring tab colors for tab bar margins on translucent OS windows The colors are rendered semi-transparent which effectively means they blend with background unlike the neighboring colors and end up not matching --- docs/changelog.rst | 2 ++ kitty/options/definition.py | 4 +++- kitty/tab_bar.py | 4 +++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index d7a3cad79..a79b2b873 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -186,6 +186,8 @@ Detailed list of changes - macOS: Fix using Fn key for start dictation not working (:iss:`9661`) +- Don't use neighboring tab colors for tab bar margins in translucent windows (:iss:`9663`) + 0.46.0 [2026-03-11] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 35845be98..e037a17af 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -1795,7 +1795,9 @@ opt('tab_bar_margin_color', 'none', long_text=''' Color for the tab bar margin area. Defaults to using the terminal background color for margins above and below the tab bar. For side margins the default -color is chosen to match the background color of the neighboring tab. +color is chosen to match the background color of the neighboring tab, unless +the window is translucent, in which case the default background is used as it +looks better. ''') opt( diff --git a/kitty/tab_bar.py b/kitty/tab_bar.py index aaf96f598..d42da165b 100644 --- a/kitty/tab_bar.py +++ b/kitty/tab_bar.py @@ -19,6 +19,7 @@ from .fast_data_types import ( Color, Region, Screen, + background_opacity_of, cell_size_for_window, get_boss, get_options, @@ -695,7 +696,8 @@ class TabBar: blank_rects.append(Border(0, tab_bar.bottom, vw, central.top, bg)) g = self.window_geometry left_bg = right_bg = bg - if opts.tab_bar_margin_color is None and opts.tab_bar_margin_width > 0: + if opts.tab_bar_margin_color is None and ( + opacity := background_opacity_of(self.os_window_id)) is not None and opacity >= 1: left_bg = BorderColor.tab_bar_left_edge_color right_bg = BorderColor.tab_bar_right_edge_color if g.left > 0: From 66ffb6895cb9df4475f1e35ff490553168e24ea4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 08:59:44 +0000 Subject: [PATCH 45/70] Fix macOS keyboard focus not restored when switching back from another space Fixes #9665 Fixes #9666 --- docs/changelog.rst | 2 ++ glfw/cocoa_init.m | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index a79b2b873..c778ba9ee 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -188,6 +188,8 @@ Detailed list of changes - Don't use neighboring tab colors for tab bar margins in translucent windows (:iss:`9663`) +- macOS: Fix OS window focus not restored when switching spaces (:iss:`9665`) + 0.46.0 [2026-03-11] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/glfw/cocoa_init.m b/glfw/cocoa_init.m index 8bac2837d..4ea90333f 100644 --- a/glfw/cocoa_init.m +++ b/glfw/cocoa_init.m @@ -311,6 +311,24 @@ static NSDictionary *global_shortcuts = nil; } } +- (void)applicationDidBecomeActive:(NSNotification *)notification { + (void)notification; + // When the application becomes active after switching spaces (e.g., swiping + // back from a fullscreen app on another space), macOS may not send + // windowDidBecomeKey: for the already-key window. This leaves GLFW thinking + // no window has focus (since windowDidResignKey: was sent when leaving). + // Ensure GLFW's focus state is updated to match the actual key window. + NSWindow *keyWindow = [NSApp keyWindow]; + if (keyWindow && !_glfw.focusedWindowId) { + for (_GLFWwindow *window = _glfw.windowListHead; window; window = window->next) { + if (window->ns.object == keyWindow) { + if (_glfw.focusedWindowId != window->id) _glfwInputWindowFocus(window, true); + break; + } + } + } +} + - (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender { (void)sender; From ddfd8d7f4528fe9f11bad14ea3c3c839fc28b979 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 16 Mar 2026 08:28:36 +0530 Subject: [PATCH 46/70] Wayland: Dont set window icons on layer shell windows as some compositors complain about them --- glfw/wl_window.c | 8 ++++++++ kitty/glfw.c | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/glfw/wl_window.c b/glfw/wl_window.c index 1d00917dd..398d96ec0 100644 --- a/glfw/wl_window.c +++ b/glfw/wl_window.c @@ -1588,6 +1588,14 @@ void _glfwPlatformSetWindowTitle(_GLFWwindow* window, const char* title) void _glfwPlatformSetWindowIcon(_GLFWwindow* window, int count, const GLFWimage* images) { + if (is_layer_shell(window)) { + _glfwInputError(GLFW_PLATFORM_ERROR, "Wayland: Cannot set window icon on layer shell surfaces"); + return; + } + if (!window->wl.xdg.toplevel) { + _glfwInputError(GLFW_PLATFORM_ERROR, "Wayland: Ignoring attempt to set window icon on window without a toplevel"); + return; + } if (!_glfw.wl.xdg_toplevel_icon_manager_v1) { static bool warned_once = false; if (!warned_once) { diff --git a/kitty/glfw.c b/kitty/glfw.c index 9b98d74d1..49c9778ed 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -1089,6 +1089,7 @@ set_os_window_icon(PyObject UNUSED *self, PyObject *args) { 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 (w->is_layer_shell && global_state.is_wayland) Py_RETURN_NONE; if (!what || what == Py_None) { glfwSetWindowIcon(os_window->handle, 0, NULL); Py_RETURN_NONE; @@ -1729,7 +1730,7 @@ create_os_window(PyObject UNUSED *self, PyObject *args, PyObject *kw) { glfwCocoaSetWindowResizeCallback(glfw_window, cocoa_os_window_resized); #endif send_prerendered_sprites_for_window(w); - if (logo.pixels && logo.width && logo.height) glfwSetWindowIcon(glfw_window, 1, &logo); + if (logo.pixels && logo.width && logo.height && (!lsc || !global_state.is_wayland)) glfwSetWindowIcon(glfw_window, 1, &logo); set_glfw_mouse_pointer_shape_in_window(glfw_window, OPT(default_pointer_shape)); update_os_window_viewport(w, false); glfwSetWindowPosCallback(glfw_window, window_pos_callback); From 1f2149eb6fce25e67b59ecbd45c92e23b9c1b9ac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 03:43:04 +0000 Subject: [PATCH 47/70] Bump golang.org/x/sys from 0.41.0 to 0.42.0 in the all-go-deps group Bumps the all-go-deps group with 1 update: [golang.org/x/sys](https://github.com/golang/sys). Updates `golang.org/x/sys` from 0.41.0 to 0.42.0 - [Commits](https://github.com/golang/sys/compare/v0.41.0...v0.42.0) --- updated-dependencies: - dependency-name: golang.org/x/sys dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-go-deps ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 88c59cbad..716e39fe9 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( github.com/zeebo/xxh3 v1.1.0 golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b golang.org/x/image v0.36.0 - golang.org/x/sys v0.41.0 + golang.org/x/sys v0.42.0 golang.org/x/text v0.34.0 howett.net/plist v1.0.1 ) diff --git a/go.sum b/go.sum index 3f9cc2516..288f16f21 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,8 @@ golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 0c90b66f913cbcced0dbce547bd9e54bd67dab7c Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 16 Mar 2026 09:18:50 +0530 Subject: [PATCH 48/70] ... --- kitty/glfw.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kitty/glfw.c b/kitty/glfw.c index 49c9778ed..75f8b232b 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -1089,7 +1089,7 @@ set_os_window_icon(PyObject UNUSED *self, PyObject *args) { 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 (w->is_layer_shell && global_state.is_wayland) Py_RETURN_NONE; + if (os_window->is_layer_shell && global_state.is_wayland) Py_RETURN_NONE; if (!what || what == Py_None) { glfwSetWindowIcon(os_window->handle, 0, NULL); Py_RETURN_NONE; From 40f9945ea7fcffb66831bcd7e4b499305b52f995 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 16 Mar 2026 13:20:03 +0530 Subject: [PATCH 49/70] version 0.46.1 --- docs/changelog.rst | 2 +- kitty/constants.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index c778ba9ee..dae147cf9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -165,7 +165,7 @@ consumption to do the same tasks. Detailed list of changes ------------------------------------- -0.46.1 [future] +0.46.1 [2026-03-16] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - diff kitten: Highlight moved lines using a different background color (:opt:`kitten-diff.mark_moved_lines`) (:iss:`3241`) diff --git a/kitty/constants.py b/kitty/constants.py index 9b6a07380..6ce35381a 100644 --- a/kitty/constants.py +++ b/kitty/constants.py @@ -22,7 +22,7 @@ class Version(NamedTuple): appname: str = 'kitty' kitty_face = '🐱' -version: Version = Version(0, 46, 0) +version: Version = Version(0, 46, 1) str_version: str = '.'.join(map(str, version)) _plat = sys.platform.lower() is_macos: bool = 'darwin' in _plat From 780b4337e43df5d0fe664366c7304f0e50cb39f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:18:13 +0000 Subject: [PATCH 50/70] Fix move_tab_forward/backward with tab_bar_filter When tab_bar_filter is used (e.g. with sessions), move_tab() was using self.active_tab_idx (index in the full tabs list) as an index into the filtered tabs list, causing incorrect behavior. Also fix the same bug in tab_at_location() for left/right locations. Fixes #9672 Fixes #9673 --- kitty/tabs.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/kitty/tabs.py b/kitty/tabs.py index 7174bd744..720f3f4cc 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -1337,8 +1337,13 @@ class TabManager: # {{{ return self.tab_for_id(self.active_tab_history[-1]) elif loc in ('left', 'right'): delta = -1 if loc == 'left' else 1 - idx = (len(tabs) + self.active_tab_idx + delta) % len(tabs) - return tabs[idx] + if (at := self.active_tab) is not None: + try: + active_idx = tabs.index(at) + except ValueError: + return None + idx = (len(tabs) + active_idx + delta) % len(tabs) + return tabs[idx] return None def goto_tab(self, tab_num: int) -> None: @@ -1439,8 +1444,14 @@ class TabManager: # {{{ def move_tab(self, delta: int = 1) -> None: tabs = tuple(self.tabs_to_be_shown_in_tab_bar) if len(tabs) > 1: - idx = self.active_tab_idx - new_active_tab = tabs[(idx + len(tabs) + delta) % len(tabs)] + if (at := self.active_tab) is None: + return + try: + filtered_idx = tabs.index(at) + except ValueError: + return + new_active_tab = tabs[(filtered_idx + len(tabs) + delta) % len(tabs)] + idx = self.tabs.index(at) nidx = self.tabs.index(new_active_tab) step = 1 if idx < nidx else -1 for i in range(idx, nidx, step): From d97850274f252093717af3f25b25c6ced0cb09c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:19:44 +0000 Subject: [PATCH 51/70] Implement Python vectorcall calling convention for Color type in colors.c Fixes #9674 --- kitty/colors.c | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/kitty/colors.c b/kitty/colors.c index 612e2dea2..776618211 100644 --- a/kitty/colors.c +++ b/kitty/colors.c @@ -682,6 +682,45 @@ new_color(PyTypeObject *type UNUSED, PyObject *args, PyObject *kwds) { return (PyObject*) alloc_color(r, g, b, a); } +static PyObject * +color_vectorcall(PyObject *type UNUSED, PyObject *const *args, size_t nargsf, PyObject *kwnames) { + const Py_ssize_t nargs = PyVectorcall_NARGS(nargsf); + if (nargs > 4) { + PyErr_SetString(PyExc_TypeError, "Color() takes at most 4 arguments"); + return NULL; + } + unsigned char rgba[4] = {0, 0, 0, 0}; + for (Py_ssize_t i = 0; i < nargs; i++) { + unsigned long val = PyLong_AsUnsignedLongMask(args[i]); + if (val == (unsigned long)-1 && PyErr_Occurred()) return NULL; + rgba[i] = (unsigned char)val; + } + if (kwnames) { + static const char *const kwnames_list[] = {"red", "green", "blue", "alpha"}; + const Py_ssize_t nkwargs = PyTuple_GET_SIZE(kwnames); + for (Py_ssize_t i = 0; i < nkwargs; i++) { + const char *name = PyUnicode_AsUTF8(PyTuple_GET_ITEM(kwnames, i)); + if (!name) return NULL; + int idx = -1; + for (int j = 0; j < 4; j++) { + if (strcmp(name, kwnames_list[j]) == 0) { idx = j; break; } + } + if (idx < 0) { + PyErr_Format(PyExc_TypeError, "Color() got an unexpected keyword argument '%s'", name); + return NULL; + } + if (idx < nargs) { + PyErr_Format(PyExc_TypeError, "Color() got multiple values for argument '%s'", name); + return NULL; + } + unsigned long val = PyLong_AsUnsignedLongMask(args[nargs + i]); + if (val == (unsigned long)-1 && PyErr_Occurred()) return NULL; + rgba[idx] = (unsigned char)val; + } + } + return (PyObject*)alloc_color(rgba[0], rgba[1], rgba[2], rgba[3]); +} + static PyObject* Color_as_int(Color *self) { return PyLong_FromUnsignedLong(self->color.val); @@ -1140,9 +1179,10 @@ PyTypeObject Color_Type = { PyVarObject_HEAD_INIT(NULL, 0) .tp_name = "kitty.fast_data_types.Color", .tp_basicsize = sizeof(Color), - .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_VECTORCALL, .tp_doc = "Color", .tp_new = new_color, + .tp_vectorcall = color_vectorcall, .tp_getset = color_getsetters, .tp_as_number = &color_number_methods, .tp_methods = color_methods, From 83160c10c4fc2d82b4d2bc5029b1094d5ff9742e Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 16 Mar 2026 17:38:14 +0530 Subject: [PATCH 52/70] Cleanup previous PR --- kitty/colors.c | 20 ++++++++++---------- kitty_tests/datatypes.py | 1 + 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/kitty/colors.c b/kitty/colors.c index 776618211..b708ee32a 100644 --- a/kitty/colors.c +++ b/kitty/colors.c @@ -696,19 +696,19 @@ color_vectorcall(PyObject *type UNUSED, PyObject *const *args, size_t nargsf, Py rgba[i] = (unsigned char)val; } if (kwnames) { - static const char *const kwnames_list[] = {"red", "green", "blue", "alpha"}; - const Py_ssize_t nkwargs = PyTuple_GET_SIZE(kwnames); - for (Py_ssize_t i = 0; i < nkwargs; i++) { + const unsigned num = PyTuple_GET_SIZE(kwnames); + for (unsigned i = 0; i < num; i++) { const char *name = PyUnicode_AsUTF8(PyTuple_GET_ITEM(kwnames, i)); if (!name) return NULL; - int idx = -1; - for (int j = 0; j < 4; j++) { - if (strcmp(name, kwnames_list[j]) == 0) { idx = j; break; } - } - if (idx < 0) { - PyErr_Format(PyExc_TypeError, "Color() got an unexpected keyword argument '%s'", name); - return NULL; + int idx; +#define C(ch, i, expected) case ch: idx = i; if (strcmp(name, expected) != 0) { \ + PyErr_Format(PyExc_TypeError, "Color() got an unexpected keyword argument '%s'", name); return NULL; }; break; + switch(name[0]) { + C('r', 0, "red"); C('g', 1, "green"); C('b', 2, "blue"); C('a', 3, "alpha"); + default: + PyErr_Format(PyExc_TypeError, "Color() got an unexpected keyword argument '%s'", name); return NULL; } +#undef C if (idx < nargs) { PyErr_Format(PyExc_TypeError, "Color() got multiple values for argument '%s'", name); return NULL; diff --git a/kitty_tests/datatypes.py b/kitty_tests/datatypes.py index 91497ceef..ca04e4610 100644 --- a/kitty_tests/datatypes.py +++ b/kitty_tests/datatypes.py @@ -66,6 +66,7 @@ class TestDataTypes(BaseTest): def c(spec, r=0, g=0, b=0, a=0): c = to_color(spec) self.ae(Color(r, g, b, a), c, spec) + self.ae(Color(r, green=g, alpha=a, blue=b), c, spec) c('#eee # comment', 0xee, 0xee, 0xee) c('#234567', 0x23, 0x45, 0x67) From 77821f6321a184b80d1fa47d1b8861e3a41dfcbb Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 17 Mar 2026 08:21:11 +0530 Subject: [PATCH 53/70] Fix #9679 --- docs/kittens/choose-files.rst | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/kittens/choose-files.rst b/docs/kittens/choose-files.rst index 1bde11733..1a0f3ed34 100644 --- a/docs/kittens/choose-files.rst +++ b/docs/kittens/choose-files.rst @@ -46,10 +46,15 @@ chosen in a command line, use, for example:: some-command $(kitten choose-file) -Note, that the above may not work in a complicated pipeline as it performs +Note that the above may not work in a complicated pipeline as it performs terminal I/O and needs exclusive access to the tty device while choosing a file. +.. note:: For content previews, this kitten uses some external programs. In + particular `ffmpeg `__ is needed for video + previews and `calibre `__ is needed + for ebook metadata and cover preiews. + Creating shortcuts to favorite/frequently used directories ------------------------------------------------------------ From c977d1edcb1b25872a19be1c1b30b0baec2a8c67 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 17 Mar 2026 08:22:45 +0530 Subject: [PATCH 54/70] ... --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a26ff354e..e810a30a7 100755 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ def check_version_info() -> None: else: is_ok = sys.version_info > q if not is_ok: - exit(f'calibre requires Python {minver}. Current Python version: {".".join(map(str, sys.version_info[:3]))}') + exit(f'kitty requires Python {minver}. Current Python version: {".".join(map(str, sys.version_info[:3]))}') check_version_info() From 02a34ecd046cd513d0dbff6f8d540facfca5a390 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 17 Mar 2026 08:32:38 +0530 Subject: [PATCH 55/70] Fix #9684 --- kitty/conf/types.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/kitty/conf/types.py b/kitty/conf/types.py index bbc18177c..a066d3d82 100644 --- a/kitty/conf/types.py +++ b/kitty/conf/types.py @@ -9,7 +9,7 @@ from collections.abc import Callable, Iterable, Iterator from functools import lru_cache from importlib import import_module from re import Match -from typing import Any, Optional, Union, cast +from typing import Any, Optional, Sequence, Union, cast import kitty.conf.utils as generic_parsers from kitty.constants import website_url @@ -244,7 +244,7 @@ class Option: def is_color_table_color(self) -> bool: return self.name.startswith('color') and self.name[5:].isdigit() - def as_conf(self, commented: bool = False, level: int = 0, option_group: list['Option'] = []) -> list[str]: + def as_conf(self, commented: bool = False, level: int = 0, option_group: Sequence['Option'] = ()) -> list[str]: ans: list[str] = [] a = ans.append if not self.documented: @@ -264,13 +264,13 @@ class Option: def as_rst( self, conf_name: str, shortcut_slugs: dict[str, tuple[str, str]], - kitty_mod: str, level: int = 0, option_group: list['Option'] = [] + kitty_mod: str, level: int = 0, option_group: Sequence['Option'] = () ) -> list[str]: ans: list[str] = [] a = ans.append if not self.documented: return ans - mopts = [self] + option_group + mopts = [self] + list(option_group) a('.. opt:: ' + ', '.join(f'{conf_name}.{mo.name}' for mo in mopts)) if any(mo.defval_as_string for mo in mopts): a('.. code-block:: conf') From 25f97f4ce55c2281e304f00b7d7e101150b2bcb4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:32:06 +0000 Subject: [PATCH 56/70] Implement on_quit event for global watchers Fixes #9682 --- docs/launch.rst | 8 ++++++++ kitty/boss.py | 28 +++++++++++++++++++++++++++- kitty/launch.py | 3 +++ kitty/window.py | 8 +++++++- 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/docs/launch.rst b/docs/launch.rst index fd03e13d5..6a984f118 100644 --- a/docs/launch.rst +++ b/docs/launch.rst @@ -185,6 +185,14 @@ create :file:`~/.config/kitty/mywatcher.py` and use :option:`launch --watcher` = # managing all tabs in a single OS Window. ... + def on_quit(boss: Boss, window: Window, data: dict[str, Any]) -> None: + # called when kitty is about to quit. This is called in *global watchers* + # only. It is called twice: once before the quit confirmation dialog is + # shown (data['confirmed'] will be False) and once after the user has + # confirmed quitting (data['confirmed'] will be True). Setting + # data['aborted'] to True will abort the quit in both cases. + ... + Every callback is passed a reference to the global ``Boss`` object as well as the ``Window`` object the action is occurring on. The ``data`` object is a dict diff --git a/kitty/boss.py b/kitty/boss.py index 3f53e34e3..0a1e143c3 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -173,7 +173,7 @@ from .utils import ( timed_debug_print, which, ) -from .window import CommandOutput, CwdRequest, Window +from .window import CommandOutput, CwdRequest, Window, global_watchers if TYPE_CHECKING: @@ -2090,6 +2090,24 @@ class Boss: quit_confirmation_window_id: int = 0 + def _call_on_quit_watchers(self, data: dict[str, Any]) -> bool: + w = self.active_window + if w is None: + for window in self.window_id_map.values(): + w = window + break + if w is None: + return True + for watcher in global_watchers().on_quit: + try: + watcher(self, w, data) + except Exception: + import traceback + traceback.print_exc() + if data.get('aborted'): + return False + return True + @ac('win', 'Quit, closing all windows') def quit(self, *args: Any) -> None: windows = [] @@ -2102,6 +2120,8 @@ class Boss: num = num_active_windows if x < 0 else len(windows) needs_confirmation = x != 0 and num >= abs(x) if not needs_confirmation: + if not self._call_on_quit_watchers({'confirmed': True}): + return set_application_quit_request(IMPERATIVE_CLOSE_REQUESTED) return if current_application_quit_request() == CLOSE_BEING_CONFIRMED: @@ -2116,6 +2136,8 @@ class Boss: tab.set_active_window(w) return return + if not self._call_on_quit_watchers({'confirmed': False}): + return msg = msg or _('It has {} windows.').format(num) w = self.confirm(_('Are you sure you want to quit kitty?') + ' ' + msg, self.handle_quit_confirmation, window=active_window, title=_('Quit kitty?')) self.quit_confirmation_window_id = w.id @@ -2123,6 +2145,10 @@ class Boss: def handle_quit_confirmation(self, confirmed: bool) -> None: self.quit_confirmation_window_id = 0 + if confirmed: + if not self._call_on_quit_watchers({'confirmed': True}): + set_application_quit_request(NO_CLOSE_REQUESTED) + return set_application_quit_request(IMPERATIVE_CLOSE_REQUESTED if confirmed else NO_CLOSE_REQUESTED) def notify_on_os_window_death(self, address: str) -> None: diff --git a/kitty/launch.py b/kitty/launch.py index 119a0c2c4..75401348c 100644 --- a/kitty/launch.py +++ b/kitty/launch.py @@ -548,6 +548,9 @@ def load_watch_modules(watchers: Iterable[str]) -> Watchers | None: w = m.get('on_tab_bar_dirty') if callable(w): ans.on_tab_bar_dirty.append(w) + w = m.get('on_quit') + if callable(w): + ans.on_quit.append(w) return ans diff --git a/kitty/window.py b/kitty/window.py index 92a4f80cc..b32662855 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -305,6 +305,7 @@ class Watchers: on_cmd_startstop: list[Watcher] on_color_scheme_preference_change: list[Watcher] on_tab_bar_dirty: list[Watcher] + on_quit: list[Watcher] def __init__(self) -> None: self.on_resize = [] @@ -315,6 +316,7 @@ class Watchers: self.on_cmd_startstop = [] self.on_color_scheme_preference_change = [] self.on_tab_bar_dirty = [] + self.on_quit = [] def add(self, others: 'Watchers') -> None: def merge(base: list[Watcher], other: list[Watcher]) -> None: @@ -329,12 +331,14 @@ class Watchers: merge(self.on_cmd_startstop, others.on_cmd_startstop) merge(self.on_color_scheme_preference_change, others.on_color_scheme_preference_change) merge(self.on_tab_bar_dirty, others.on_tab_bar_dirty) + merge(self.on_quit, others.on_quit) def clear(self) -> None: del self.on_close[:], self.on_resize[:], self.on_focus_change[:] del self.on_set_user_var[:], self.on_title_change[:], self.on_cmd_startstop[:] del self.on_color_scheme_preference_change[:] del self.on_tab_bar_dirty[:] + del self.on_quit[:] def copy(self) -> 'Watchers': ans = Watchers() @@ -346,12 +350,14 @@ class Watchers: ans.on_cmd_startstop = self.on_cmd_startstop[:] ans.on_color_scheme_preference_change = self.on_color_scheme_preference_change[:] ans.on_tab_bar_dirty = self.on_tab_bar_dirty[:] + ans.on_quit = self.on_quit[:] return ans @property def has_watchers(self) -> bool: return bool(self.on_close or self.on_resize or self.on_focus_change or self.on_color_scheme_preference_change - or self.on_set_user_var or self.on_title_change or self.on_cmd_startstop or self.on_tab_bar_dirty) + or self.on_set_user_var or self.on_title_change or self.on_cmd_startstop or self.on_tab_bar_dirty + or self.on_quit) def call_watchers(windowref: Callable[[], Optional['Window']], which: str, data: dict[str, Any]) -> None: From ad53a5bdff18269e2dda854460e5fcaf90957034 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 17 Mar 2026 08:40:56 +0530 Subject: [PATCH 57/70] Cleanup previous PR --- docs/changelog.rst | 5 +++++ kitty/boss.py | 1 + 2 files changed, 6 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index dae147cf9..6a64b40a0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -165,6 +165,11 @@ consumption to do the same tasks. Detailed list of changes ------------------------------------- +0.50.0 [future] +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Watchers: Add an `on_quit` event to global watchers (:iss:`9675`) + 0.46.1 [2026-03-16] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/kitty/boss.py b/kitty/boss.py index 0a1e143c3..0d8034baf 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -2098,6 +2098,7 @@ class Boss: break if w is None: return True + data['aborted'] = False for watcher in global_watchers().on_quit: try: watcher(self, w, data) From 877be654470f8a1a0d8f617f820c36277e38cdc4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:39:19 +0000 Subject: [PATCH 58/70] Fix crashes when moving tab between OS windows (issue #9677) Wayland (glfw/wl_window.c): - Fix out-of-bounds access in send_drag_data: look up item by MIME type instead of using the data-request index i to index _glfw.drag.items[]. The compositor calls drag_source_send once per target window entered, so _glfw.wl.drag.count grows independently of item_count, causing _glfw.drag.items[i] to be out-of-bounds on the second drag, yielding a garbage optional_data pointer that made write() fail with EFAULT. - Fix protocol error "Drag has not ended": change on_fail and the GLFW_DRAG_DATA_REQUEST error path to call finish_drag_write(i)+return instead of cancel_drag(), which was calling wl_data_source_destroy() before the compositor ended the drag, violating the Wayland protocol. - Fix double-free of dr.pending_data: null the pointer after free and add cleanup to finish_drag_write(). - Fix missing finish_drag_write() after a full write in data-request mode, which left the pipe open causing the target to wait for EOF. X11 (glfw/x11_window.c): - Wrap XSendEvent() calls in send_xdnd_enter/position/leave/drop with _glfwGrabErrorHandlerX11()/_glfwReleaseErrorHandlerX11(). A target window destroyed between discovery and message delivery produced a BadWindow error that hit the default X11 abort handler. Now handled gracefully by clearing current_target or cancelling the drag." Fixes #9677 Fixes #9683 --- glfw/wl_window.c | 52 +++++++++++++++++++++++++++++++---------------- glfw/x11_window.c | 22 +++++++++++++++++++- 2 files changed, 56 insertions(+), 18 deletions(-) diff --git a/glfw/wl_window.c b/glfw/wl_window.c index 398d96ec0..ff3b8b69c 100644 --- a/glfw/wl_window.c +++ b/glfw/wl_window.c @@ -3177,6 +3177,7 @@ finish_drag_write(size_t i) { dr.watch_id = 0; if (dr.fd > -1) safe_close(dr.fd); dr.fd = -1; + free(dr.pending_data); dr.pending_data = NULL; dr.sz = 0; dr.offset = 0; free((void*)dr.mime_type); dr.mime_type = NULL; } @@ -3198,31 +3199,49 @@ write_as_much_as_possible(int fd, const char *data, size_t sz) { static void send_drag_data(_GLFWwindow *window, size_t i) { ssize_t ret; - bool has_preset_data = _glfw.drag.items[i].data_size > 0; + // Find the item matching the mime type for this data request. + // We cannot use i directly to index _glfw.drag.items because the compositor + // may call drag_source_send multiple times (once per target entered), making + // data_requests grow independently of the items array. + size_t item_idx = _glfw.drag.item_count; // sentinel: not found + for (size_t j = 0; j < _glfw.drag.item_count; j++) { + if (dr.mime_type && _glfw.drag.items[j].mime_type && + strcmp(_glfw.drag.items[j].mime_type, dr.mime_type) == 0) { + item_idx = j; break; + } + } + if (item_idx == _glfw.drag.item_count) { + _glfwInputError(GLFW_PLATFORM_ERROR, + "Wayland: compositor requested data for unrecognised MIME type: %s", + dr.mime_type ? dr.mime_type : "(null)"); + } + bool has_preset_data = item_idx < _glfw.drag.item_count && _glfw.drag.items[item_idx].data_size > 0; + // On write error, only close this pipe; do NOT destroy the wl_data_source + // since the compositor must send cancelled/dnd_finished before that is safe. #define on_fail _glfwInputError(\ GLFW_PLATFORM_ERROR, "Wayland: failed to write drag source data to pipe with error: %s", strerror(errno)); \ - cancel_drag(GLFW_DRAG_CANCELLED); + finish_drag_write(i); return; if (dr.sz > dr.offset) { ret = write_as_much_as_possible(dr.fd, dr.pending_data + dr.offset, dr.sz - dr.offset); if (ret < 0) { on_fail; } else { dr.offset += ret; if (dr.offset >= dr.sz) { - free(dr.pending_data); dr.sz = 0; dr.offset = 0; - if (has_preset_data) finish_drag_write(i); + free(dr.pending_data); dr.pending_data = NULL; dr.sz = 0; dr.offset = 0; + finish_drag_write(i); } } } else if (has_preset_data) { - do { ret = write(dr.fd, _glfw.drag.items[i].optional_data, _glfw.drag.items[i].data_size); } while (ret < 0 && errno == EINTR); + do { ret = write(dr.fd, _glfw.drag.items[item_idx].optional_data, _glfw.drag.items[item_idx].data_size); } while (ret < 0 && errno == EINTR); if (ret < 0) { on_fail; } else { - if ((size_t)ret >= _glfw.drag.items[i].data_size) { + if ((size_t)ret >= _glfw.drag.items[item_idx].data_size) { finish_drag_write(i); } else { - void *pending = malloc(_glfw.drag.items[i].data_size - ret); - if (!pending) { on_fail; } else { - dr.pending_data = pending; dr.sz = _glfw.drag.items[i].data_size - ret; dr.offset = 0; + void *pending = malloc(_glfw.drag.items[item_idx].data_size - ret); + if (!pending) { errno = ENOMEM; on_fail; } else { + dr.pending_data = pending; dr.sz = _glfw.drag.items[item_idx].data_size - ret; dr.offset = 0; } } } @@ -3231,21 +3250,20 @@ send_drag_data(_GLFWwindow *window, size_t i) { _glfwInputDragSourceRequest(window, &ev); if (ev.err_num) { if (ev.err_num == EAGAIN) { removeWatch(&_glfw.wl.eventLoopData, dr.watch_id); dr.watch_id = 0; } - else cancel_drag(GLFW_DRAG_CANCELLED); + else { finish_drag_write(i); return; } } else { if (ev.data_sz) { ret = write_as_much_as_possible(dr.fd, ev.data, ev.data_sz); - if (ret >= 0) { - if ((size_t)ret < ev.data_sz) { - void *pending = malloc(ev.data_sz - ret); - if (!pending) { on_fail; } else { - dr.pending_data = pending; dr.sz = ev.data_sz - ret; dr.offset = 0; - memcpy(pending, ev.data + ret, dr.sz); - } + if (ret >= 0 && (size_t)ret < ev.data_sz) { + void *pending = malloc(ev.data_sz - ret); + if (!pending) { ret = -1; } else { + dr.pending_data = pending; dr.sz = ev.data_sz - ret; dr.offset = 0; + memcpy(pending, ev.data + ret, dr.sz); } } _glfwInputDragSourceRequest(window, &ev); if (ret < 0) { on_fail; } + else if ((size_t)ret >= ev.data_sz) { finish_drag_write(i); } } else finish_drag_write(i); } } diff --git a/glfw/x11_window.c b/glfw/x11_window.c index 7650fb90b..e57aefe66 100644 --- a/glfw/x11_window.c +++ b/glfw/x11_window.c @@ -4048,8 +4048,11 @@ send_xdnd_enter(Window target, int version) { } } + _glfwGrabErrorHandlerX11(); XSendEvent(_glfw.x11.display, target, False, NoEventMask, &event); XFlush(_glfw.x11.display); + _glfwReleaseErrorHandlerX11(); + if (_glfw.x11.errorCode == BadWindow) _glfw.x11.drag.current_target = None; } // Send XdndPosition message to target window @@ -4068,9 +4071,12 @@ send_xdnd_position(Window target, int root_x, int root_y, Time timestamp) { event.xclient.data.l[3] = timestamp; event.xclient.data.l[4] = _glfw.x11.drag.action_atom; + _glfwGrabErrorHandlerX11(); XSendEvent(_glfw.x11.display, target, False, NoEventMask, &event); XFlush(_glfw.x11.display); - _glfw.x11.drag.waiting_for_status = true; + _glfwReleaseErrorHandlerX11(); + if (_glfw.x11.errorCode == BadWindow) _glfw.x11.drag.current_target = None; + else _glfw.x11.drag.waiting_for_status = true; } // Send XdndLeave message to target window @@ -4085,8 +4091,11 @@ send_xdnd_leave(Window target) { event.xclient.format = 32; event.xclient.data.l[0] = _glfw.x11.drag.source_window; + _glfwGrabErrorHandlerX11(); XSendEvent(_glfw.x11.display, target, False, NoEventMask, &event); XFlush(_glfw.x11.display); + _glfwReleaseErrorHandlerX11(); + // BadWindow on leave is benign – the target window is already gone } // Send XdndDrop message to target window @@ -4103,8 +4112,19 @@ send_xdnd_drop(Window target, Time timestamp) { event.xclient.data.l[1] = 0; // Reserved event.xclient.data.l[2] = timestamp; + _glfwGrabErrorHandlerX11(); XSendEvent(_glfw.x11.display, target, False, NoEventMask, &event); XFlush(_glfw.x11.display); + _glfwReleaseErrorHandlerX11(); + if (_glfw.x11.errorCode == BadWindow) { + // Target window was destroyed; cancel the drag gracefully + _GLFWwindow *window = _glfwWindowForId(_glfw.drag.window_id); + if (window) { + GLFWDragEvent ev = {.type = GLFW_DRAG_CANCELLED}; + _glfwInputDragSourceRequest(window, &ev); + } + _glfwFreeDragSourceData(); + } } // Render thumbnail pixels into _glfw.x11.drag.thumbnail_pixmap / thumbnail_gc. From 6704f359a9c5bb982c2f95c353db59eb03884b3d Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 17 Mar 2026 08:57:30 +0530 Subject: [PATCH 59/70] Update changelog --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 6a64b40a0..81921d833 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -170,6 +170,9 @@ Detailed list of changes - Watchers: Add an `on_quit` event to global watchers (:iss:`9675`) +- Wayland: Fix a crash on some compositors when dragging a tab between OS + Windows (:iss:`9677`) + 0.46.1 [2026-03-16] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 8457258f26e21822e777218cc29ab3314159c765 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 17 Mar 2026 09:30:23 +0530 Subject: [PATCH 60/70] ... --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 81921d833..52a80f7d5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -165,7 +165,7 @@ consumption to do the same tasks. Detailed list of changes ------------------------------------- -0.50.0 [future] +0.47.0 [future] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Watchers: Add an `on_quit` event to global watchers (:iss:`9675`) From f57eb74dbe2c34dcd0a9dd758bdb3f7f94d3ecfc Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 17 Mar 2026 09:31:51 +0530 Subject: [PATCH 61/70] Update changelog --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 52a80f7d5..cdf0229d1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -173,6 +173,9 @@ Detailed list of changes - Wayland: Fix a crash on some compositors when dragging a tab between OS Windows (:iss:`9677`) +- Fix incorrect behavior when using the actions to move tab forward/backward + with a tab_bar_filter active (:iss:`9672`) + 0.46.1 [2026-03-16] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From e98f80d836cf189f12d4dab91382b29313f712e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 07:26:45 +0000 Subject: [PATCH 62/70] Prevent multiple stacking Rename tab overlay windows Fixes #9691 Fixes #9692 --- kitty/boss.py | 20 ++++++++++++++++---- kitty/tabs.py | 1 + 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/kitty/boss.py b/kitty/boss.py index 0d8034baf..956aa2362 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -1237,7 +1237,7 @@ class Boss: is_password: bool = False, initial_value: str = '', window_title: str = '', - ) -> None: + ) -> Window | None: result: str = '' def callback_(res: dict[str, Any], x: int, boss: Boss) -> None: @@ -1252,7 +1252,7 @@ class Boss: cmd.append('--default=' + initial_value) if window_title: cmd.append(f'--title={window_title}') - self.run_kitten_with_metadata( + return self.run_kitten_with_metadata( 'ask', cmd, window=window, custom_callback=callback_, default_data={'response': ''}, action_on_removal=on_popup_overlay_removal ) @@ -2377,12 +2377,24 @@ class Boss: title = '' tab.set_title(title) return + if tab.renaming_in_window and tab.renaming_in_window in self.window_id_map: + w = self.window_id_map[tab.renaming_in_window] + if w in tab: + tab.set_active_window(w) + return prefilled = tab.name or tab.title if title in ('" "', "' '"): prefilled = '' - self.get_line( + + def on_rename_done(new_title: str) -> None: + tab.renaming_in_window = 0 + tab.set_title(new_title) + + overlay_window = self.get_line( _('Enter the new title for this tab below. An empty title will cause the default title to be used.'), - tab.set_title, window=tab.active_window, initial_value=prefilled, window_title=_('Rename tab')) + on_rename_done, window=tab.active_window, initial_value=prefilled, window_title=_('Rename tab')) + if overlay_window is not None: + tab.renaming_in_window = overlay_window.id def create_special_window_for_show_error(self, title: str, msg: str, overlay_for: int | None = None) -> SpecialWindowInstance: ec = sys.exc_info() diff --git a/kitty/tabs.py b/kitty/tabs.py index 720f3f4cc..f3ee9e4cb 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -145,6 +145,7 @@ class Tab: # {{{ inactive_fg: int | None = None inactive_bg: int | None = None confirm_close_window_id: int = 0 + renaming_in_window: int = 0 num_of_windows_with_progress: int = 0 total_progress: int = 0 has_indeterminate_progress: bool = False From f2734fcfbd19522e3c72977f29fc2efaa6512d5a Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 17 Mar 2026 13:39:07 +0530 Subject: [PATCH 63/70] Cleanup copilot code --- docs/changelog.rst | 2 ++ kitty/boss.py | 21 ++++++++++----------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index cdf0229d1..a18ee4512 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -176,6 +176,8 @@ Detailed list of changes - Fix incorrect behavior when using the actions to move tab forward/backward with a tab_bar_filter active (:iss:`9672`) +- Prevent stacking of multiple rename tab windows (:iss:`9691`) + 0.46.1 [2026-03-16] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/kitty/boss.py b/kitty/boss.py index 956aa2362..74d556d24 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -1252,9 +1252,10 @@ class Boss: cmd.append('--default=' + initial_value) if window_title: cmd.append(f'--title={window_title}') - return self.run_kitten_with_metadata( + w = self.run_kitten_with_metadata( 'ask', cmd, window=window, custom_callback=callback_, default_data={'response': ''}, action_on_removal=on_popup_overlay_removal ) + return w if isinstance(w, Window) else None def get_save_filepath( self, msg: str, # can contain newlines and ANSI formatting @@ -2377,18 +2378,16 @@ class Boss: title = '' tab.set_title(title) return - if tab.renaming_in_window and tab.renaming_in_window in self.window_id_map: - w = self.window_id_map[tab.renaming_in_window] - if w in tab: - tab.set_active_window(w) - return - prefilled = tab.name or tab.title - if title in ('" "', "' '"): - prefilled = '' + if (w := self.window_id_map.get(tab.renaming_in_window)) is not None and w in tab: + tab.set_active_window(w) + return + prefilled = (tab.name or tab.title).strip() + tab_id = tab.id def on_rename_done(new_title: str) -> None: - tab.renaming_in_window = 0 - tab.set_title(new_title) + if (tab := self.tab_for_id(tab_id)) is not None: + tab.renaming_in_window = 0 + tab.set_title(new_title) overlay_window = self.get_line( _('Enter the new title for this tab below. An empty title will cause the default title to be used.'), From 3862ae26ccebde27c39041fd7aa3e92cdf36abe9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 08:47:56 +0000 Subject: [PATCH 64/70] Fix fuzzy match highlighting in choose_files kitten Fixes #9693 --- kittens/choose_files/results.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/kittens/choose_files/results.go b/kittens/choose_files/results.go index 1b6ea56a6..840cfd14c 100644 --- a/kittens/choose_files/results.go +++ b/kittens/choose_files/results.go @@ -112,12 +112,10 @@ func (h *Handler) render_match_with_positions(text string, add_ellipsis bool, po for chunk := range split_up_text(text, add_ellipsis, positions) { if chunk.text != "" { if chunk.emphasize { - h.lp.QueueWriteString(prefix) - defer func() { - h.lp.QueueWriteString(suffix) - }() + h.lp.QueueWriteString(prefix + chunk.text + suffix) + } else { + h.lp.QueueWriteString(chunk.text) } - h.lp.QueueWriteString(chunk.text) } } } From 0479962dd711ca6c5e6545e669051b4597cd1a7a Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 17 Mar 2026 14:29:56 +0530 Subject: [PATCH 65/70] Update changelog --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index a18ee4512..38ea1c9e5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -178,6 +178,8 @@ Detailed list of changes - Prevent stacking of multiple rename tab windows (:iss:`9691`) +- choose files kitten: Fix a regression that caused incorrect highlight of matched letters + 0.46.1 [2026-03-16] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 489473a8cfd05599b237dd5b7f549c1a1df398b2 Mon Sep 17 00:00:00 2001 From: Danny Dulai Date: Tue, 17 Mar 2026 10:06:57 -0400 Subject: [PATCH 66/70] Add macOS notch avoidance for traditional fullscreen Position the window below the notch by reducing the frame height by safeAreaInsets.top. Create a fullscreen child window behind it with a colored subview covering just the notch strip, matching the terminal background color and opacity. Also fix background_opacity not triggering a chrome update on config reload. --- glfw/cocoa_platform.h | 3 +++ glfw/cocoa_window.m | 54 ++++++++++++++++++++++++++++++++++++++++++- kitty/state.c | 1 + 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/glfw/cocoa_platform.h b/glfw/cocoa_platform.h index fc6e98ba6..8974b8cf6 100644 --- a/glfw/cocoa_platform.h +++ b/glfw/cocoa_platform.h @@ -141,6 +141,9 @@ typedef struct _GLFWwindowNS bool in_traditional_fullscreen; bool in_fullscreen_transition; bool suppress_frame_constraints; + id notch_cover_window; + unsigned int notch_cover_color; + float notch_cover_opacity; bool titlebar_hidden; unsigned long pre_full_screen_style_mask; CGRect pre_traditional_fullscreen_frame; diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index d7587b27d..d44df8fa2 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -595,6 +595,7 @@ static const NSRange kEmptyRange = { NSNotFound, 0 }; @end static void update_titlebar_button_visibility_after_fullscreen_transition(_GLFWwindow*, bool, bool); +static void _glfwUpdateNotchCover(_GLFWwindow*); @implementation GLFWWindowDelegate @@ -814,6 +815,7 @@ static void update_titlebar_button_visibility_after_fullscreen_transition(_GLFWw NSWindowStyleMask savedMask = window->ns.pre_full_screen_style_mask; CGRect savedFrame = window->ns.pre_traditional_fullscreen_frame; window->ns.in_traditional_fullscreen = false; + _glfwUpdateNotchCover(window); window->ns.suppress_frame_constraints = true; dispatch_async(dispatch_get_main_queue(), ^{ _GLFWwindow *w = NULL; @@ -2432,6 +2434,12 @@ void _glfwPlatformDestroyWindow(_GLFWwindow* window) if (_glfw.ns.disabledCursorWindow == window) _glfw.ns.disabledCursorWindow = NULL; free_drop_data(window); + if (window->ns.notch_cover_window) { + [w removeChildWindow:window->ns.notch_cover_window]; + [window->ns.notch_cover_window close]; + [window->ns.notch_cover_window release]; + window->ns.notch_cover_window = nil; + } [w orderOut:nil]; @@ -3358,6 +3366,41 @@ make_window_fullscreen_after_show(unsigned long long timer_id, void* data) { } } +static void +_glfwUpdateNotchCover(_GLFWwindow* w) { + NSWindow *window = w->ns.object; + if (w->ns.notch_cover_window) { + [window removeChildWindow:w->ns.notch_cover_window]; + [w->ns.notch_cover_window close]; + [w->ns.notch_cover_window release]; + w->ns.notch_cover_window = nil; + } + if (!w->ns.in_traditional_fullscreen) return; + if (@available(macOS 12.0, *)) { + CGFloat insetTop = window.screen.safeAreaInsets.top; + if (insetTop <= 0) return; + NSRect sf = [window.screen frame]; + NSWindow *bg_window = [[NSWindow alloc] initWithContentRect:sf styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO]; + [bg_window setBackgroundColor:[NSColor clearColor]]; + [bg_window setHasShadow:NO]; + [bg_window setOpaque:NO]; + [bg_window setIgnoresMouseEvents:YES]; + [bg_window setReleasedWhenClosed:NO]; + // Add a colored subview only in the notch strip area + NSView *notchView = [[NSView alloc] initWithFrame:NSMakeRect(0, sf.size.height - insetTop, sf.size.width, insetTop)]; + notchView.wantsLayer = YES; + unsigned int c = w->ns.notch_cover_color; + float a = w->ns.notch_cover_opacity; + notchView.layer.backgroundColor = [NSColor colorWithSRGBRed:((c >> 16) & 0xFF) / 255.0 + green:((c >> 8) & 0xFF) / 255.0 + blue:(c & 0xFF) / 255.0 + alpha:a].CGColor; + [bg_window.contentView addSubview:notchView]; + [window addChildWindow:bg_window ordered:NSWindowBelow]; + w->ns.notch_cover_window = bg_window; + } +} + bool _glfwPlatformToggleFullscreen(_GLFWwindow* w, unsigned int flags) { NSWindow *window = w->ns.object; bool made_fullscreen = true; @@ -3380,8 +3423,13 @@ bool _glfwPlatformToggleFullscreen(_GLFWwindow* w, unsigned int flags) { w->ns.pre_traditional_fullscreen_frame = [window frame]; [window setStyleMask: NSWindowStyleMaskBorderless]; [[NSApplication sharedApplication] setPresentationOptions: NSApplicationPresentationAutoHideMenuBar | NSApplicationPresentationAutoHideDock]; - [window setFrame:[window.screen frame] display:YES]; + NSRect screenFrame = [window.screen frame]; + if (@available(macOS 12.0, *)) { + screenFrame.size.height -= window.screen.safeAreaInsets.top; + } + [window setFrame:screenFrame display:YES]; w->ns.in_traditional_fullscreen = true; + _glfwUpdateNotchCover(w); } else { made_fullscreen = false; if (sm & NSWindowStyleMaskFullScreen) { @@ -3399,6 +3447,7 @@ bool _glfwPlatformToggleFullscreen(_GLFWwindow* w, unsigned int flags) { [window setStyleMask: w->ns.pre_full_screen_style_mask]; [[NSApplication sharedApplication] setPresentationOptions: NSApplicationPresentationDefault]; w->ns.in_traditional_fullscreen = false; + _glfwUpdateNotchCover(w); } } } else { @@ -3970,6 +4019,9 @@ GLFWAPI void glfwCocoaSetWindowChrome(GLFWwindow *w, unsigned int color, bool us // HACK: Changing the style mask can cause the first responder to be cleared [nsw makeFirstResponder:window->ns.view]; + window->ns.notch_cover_color = color; + window->ns.notch_cover_opacity = background_opacity; + if (window->ns.notch_cover_window) _glfwUpdateNotchCover(window); }} GLFWAPI uint32_t diff --git a/kitty/state.c b/kitty/state.c index b324ef234..22699909f 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -1246,6 +1246,7 @@ PYWRAP0(apply_options_update) { OSWindow *os_window = global_state.os_windows + o; get_platform_dependent_config_values(os_window->handle); os_window->background_opacity.alpha = OPT(background_opacity); + set_os_window_chrome(os_window); if (!os_window->redraw_count) os_window->redraw_count++; for (size_t t = 0; t < os_window->num_tabs; t++) { Tab *tab = os_window->tabs + t; From 0321ca04f0ad2714bf97c949d7274d91f245ecc2 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 17 Mar 2026 20:14:02 +0530 Subject: [PATCH 67/70] Cleanup previous PR Fix leak of sub view and ensure child window has correct color space --- docs/changelog.rst | 2 ++ glfw/cocoa_window.m | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 38ea1c9e5..7bad40b67 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -180,6 +180,8 @@ Detailed list of changes - choose files kitten: Fix a regression that caused incorrect highlight of matched letters +- macOS: When using :opt:`macos_traditional_fullscreen` do not render content under the notch (:pull:`9678`) + 0.46.1 [2026-03-16] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index d44df8fa2..73bdad025 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -3386,6 +3386,7 @@ _glfwUpdateNotchCover(_GLFWwindow* w) { [bg_window setOpaque:NO]; [bg_window setIgnoresMouseEvents:YES]; [bg_window setReleasedWhenClosed:NO]; + [bg_window setColorSpace:[window colorSpace]]; // Add a colored subview only in the notch strip area NSView *notchView = [[NSView alloc] initWithFrame:NSMakeRect(0, sf.size.height - insetTop, sf.size.width, insetTop)]; notchView.wantsLayer = YES; @@ -3398,6 +3399,7 @@ _glfwUpdateNotchCover(_GLFWwindow* w) { [bg_window.contentView addSubview:notchView]; [window addChildWindow:bg_window ordered:NSWindowBelow]; w->ns.notch_cover_window = bg_window; + [notchView release]; } } From 6360525a2a00290515a0c8879f36844b67b20c11 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 17 Mar 2026 21:07:32 +0530 Subject: [PATCH 68/70] Fix shadow being drawn over notch area child window --- glfw/cocoa_window.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index 73bdad025..8e5c3034b 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -3397,7 +3397,7 @@ _glfwUpdateNotchCover(_GLFWwindow* w) { blue:(c & 0xFF) / 255.0 alpha:a].CGColor; [bg_window.contentView addSubview:notchView]; - [window addChildWindow:bg_window ordered:NSWindowBelow]; + [window addChildWindow:bg_window ordered:NSWindowAbove]; w->ns.notch_cover_window = bg_window; [notchView release]; } From 61421c9763617ab2e5cf6215256b0e61e0ec0418 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 17 Mar 2026 21:27:20 +0530 Subject: [PATCH 69/70] ... --- glfw/cocoa_window.m | 1 + 1 file changed, 1 insertion(+) diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index 8e5c3034b..1e8890d58 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -3397,6 +3397,7 @@ _glfwUpdateNotchCover(_GLFWwindow* w) { blue:(c & 0xFF) / 255.0 alpha:a].CGColor; [bg_window.contentView addSubview:notchView]; + // must be above otherwise shadow of main window is rendered over bg_window [window addChildWindow:bg_window ordered:NSWindowAbove]; w->ns.notch_cover_window = bg_window; [notchView release]; From 226195d18cb805f01711afa3f83de3a1ed051999 Mon Sep 17 00:00:00 2001 From: Danny Dulai Date: Tue, 17 Mar 2026 12:25:44 -0400 Subject: [PATCH 70/70] Add title_overridden field to kitty @ ls output --- kitty/tabs.py | 2 ++ kitty/window.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/kitty/tabs.py b/kitty/tabs.py index f3ee9e4cb..ff3e4d3c4 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -93,6 +93,7 @@ class TabDict(TypedDict): is_focused: bool is_active: bool title: str + title_overridden: bool layout: str layout_state: dict[str, Any] layout_opts: dict[str, Any] @@ -1388,6 +1389,7 @@ class TabManager: # {{{ 'is_focused': tab is active_tab and tab.os_window_id == current_focused_os_window_id(), 'is_active': tab is active_tab, 'title': tab.name or tab.title, + 'title_overridden': bool(tab.name), 'layout': str(tab.current_layout.name), 'layout_state': tab.current_layout.serialize(tab.windows), 'layout_opts': tab.current_layout.layout_opts.serialized(), diff --git a/kitty/window.py b/kitty/window.py index b32662855..8b3f00cf7 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -238,6 +238,7 @@ class WindowDict(TypedDict): is_focused: bool is_active: bool title: str + title_overridden: bool pid: int | None cwd: str cmdline: list[str] @@ -2088,6 +2089,7 @@ class Window: 'is_focused': is_focused, 'is_active': is_active, 'title': self.title, + 'title_overridden': self.override_title is not None, 'pid': self.child.pid, 'cwd': self.child.current_cwd or self.child.cwd, 'cmdline': self.child.cmdline,