mirror of
https://github.com/kovidgoyal/kitty
synced 2026-06-14 04:28:00 +02:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b15e047a47 | ||
|
|
8be4ea67ac | ||
|
|
a0457f6141 | ||
|
|
cb1e64ffc5 | ||
|
|
d5f69fe853 | ||
|
|
8651e1fac5 | ||
|
|
cf630f1699 | ||
|
|
4c3b96ca40 | ||
|
|
8f2ad60f96 | ||
|
|
83677a2235 | ||
|
|
901b6bd1ed | ||
|
|
9b4c0281a2 | ||
|
|
dc36e21654 | ||
|
|
dbf52d4870 | ||
|
|
949a1e1fb1 | ||
|
|
11c2ccf00f | ||
|
|
238573e799 | ||
|
|
c126e227d3 | ||
|
|
424fe9991b | ||
|
|
1cc1a445e3 | ||
|
|
79a768ed55 | ||
|
|
55425e2e75 | ||
|
|
4ca6a20c7d | ||
|
|
07ccc19533 | ||
|
|
d9334a6149 | ||
|
|
60c31ba2ab | ||
|
|
d20fe4d4b5 | ||
|
|
314899b9aa | ||
|
|
45bd3a0f14 | ||
|
|
470a70db57 | ||
|
|
72c1ff6085 | ||
|
|
40ed8cfd3c | ||
|
|
6839281277 | ||
|
|
2d9e243847 | ||
|
|
522555a5b6 | ||
|
|
b2d70c899d | ||
|
|
3bd18be320 | ||
|
|
8996aa798c | ||
|
|
4aa4a5c056 | ||
|
|
9b89031a7f | ||
|
|
e6e5524f67 | ||
|
|
cb0f05c4e4 | ||
|
|
54ecc67339 | ||
|
|
3684838188 | ||
|
|
bff5af7052 | ||
|
|
d80fd1c23d | ||
|
|
7e96373515 | ||
|
|
6c586934f4 | ||
|
|
4043e99b75 | ||
|
|
26d255b27d | ||
|
|
905e1b77d1 | ||
|
|
de8870da47 | ||
|
|
73ac7b738f | ||
|
|
385d90c427 | ||
|
|
9c7b3d778a | ||
|
|
f388fdabdc | ||
|
|
ce4defcff4 | ||
|
|
fb4d05f7e8 | ||
|
|
6bd62a5242 | ||
|
|
4aa0cd6215 | ||
|
|
852fc4a662 | ||
|
|
2b7d8af55a | ||
|
|
9ffabc7caf | ||
|
|
52b44f4f1a | ||
|
|
d6b662e706 | ||
|
|
74b80e9a29 | ||
|
|
a6aa51e823 | ||
|
|
0fedf69d87 | ||
|
|
ee937bdd1b |
2
.github/workflows/ci.py
vendored
2
.github/workflows/ci.py
vendored
@@ -233,6 +233,8 @@ IGNORED_DEPENDENCY_CVES = [
|
||||
'CVE-2026-4519',
|
||||
'CVE-2026-1502',
|
||||
'CVE-2026-7210', # DoS in unused XML parser
|
||||
'CVE-2026-3276', # DoS in unicodedata.normalize()
|
||||
'CVE-2026-7774', # tarfile.data_filter path traversal bypass
|
||||
# github.com/nwaples/rardecode/v2
|
||||
'CVE-2025-11579', # rardecode is version 2.2.1, not vulnerable
|
||||
'CVE-2026-2673', # openssl fix not released
|
||||
|
||||
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 10
|
||||
persist-credentials: false
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
CFLAGS: -funsigned-char
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # needed for :commit: docs role
|
||||
persist-credentials: false
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
KITTY_BUNDLE: 1
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 10
|
||||
persist-credentials: false
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # needed for :commit: docs role
|
||||
persist-credentials: false
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 10
|
||||
persist-credentials: false
|
||||
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
steps:
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4.35.5
|
||||
uses: github/codeql-action/init@v4.36.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
trap-caching: false
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
run: python3 .github/workflows/ci.py build
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4.35.5
|
||||
uses: github/codeql-action/analyze@v4.36.0
|
||||
|
||||
- name: Run govulncheck
|
||||
if: matrix.language == 'go'
|
||||
|
||||
4
.github/workflows/depscan.yml
vendored
4
.github/workflows/depscan.yml
vendored
@@ -22,13 +22,13 @@ jobs:
|
||||
KITTY_BUNDLE: 1
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 10
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout bypy
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
@@ -30,10 +30,10 @@
|
||||
},
|
||||
|
||||
{
|
||||
"name": "openssl 3.5.6",
|
||||
"name": "openssl 3.5.7",
|
||||
"unix": {
|
||||
"file_extension": "tar.gz",
|
||||
"hash": "sha256:deae7c80cba99c4b4f940ecadb3c3338b13cb77418409238e57d7f31f2a3b736",
|
||||
"hash": "sha256:a8c0d28a529ca480f9f36cf5792e2cd21984552a3c8e4aa11a24aa31aeac98e8",
|
||||
"urls": ["https://www.openssl.org/source/{filename}"]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -173,6 +173,42 @@ consumption to do the same tasks.
|
||||
Detailed list of changes
|
||||
-------------------------------------
|
||||
|
||||
0.47.3 [2026-06-12]
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
- macOS: Show a key symbol on the active tab if the macOS Secure Input feature is enabled
|
||||
|
||||
- Fix regression that broke unserialization of splits layout in previous release (:iss:`10124`)
|
||||
|
||||
- Fix :opt:`focus_follows_mouse` switching the active window when returning to a desktop/space, even though the mouse did not move. Now the window under a stationary cursor is left alone, while moving the mouse across windows still switches focus as before.
|
||||
|
||||
- Sanitise responses to color control escape codes to avoid command injection for shells that do not use the kitty keyboard protocol (:cve:`2026-54057`)
|
||||
|
||||
- choose fonts kitten: Fix a rare timing based race causing kitten to crash at startup (:pull:`10128`)
|
||||
|
||||
- Wayland: Fix mouse input getting broken when starting a tab drag and releasing the mouse button before the drag is actually registered (:pull:`10136`)
|
||||
|
||||
|
||||
0.47.2 [2026-06-07]
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
- Allow dragging to move scrollbar after clicking on track when :opt:`scrollbar_jump_on_click` is enabled (:pull:`10085`)
|
||||
|
||||
- macOS: Fix regression in 0.47.0 that broke passing :kbd:`Cmd+C` on to terminal applications when no text is selected (:iss:`10087`)
|
||||
|
||||
- ``kitten @ set-background-image``: Fix ``--layout=configured`` changing layout to centered instead (:iss:`10089`)
|
||||
|
||||
- Splits layout: add an ``equalize`` action and an ``equalize_on_close`` option to redistribute split space proportionally (:iss:`3489`)
|
||||
|
||||
- Fix matching var/env on tabs not working as expected (:iss:`10095`)
|
||||
|
||||
- When watching for changed config files do not recursively watch all sub directories of the directory containing the config file (:iss:`10102`)
|
||||
|
||||
- File transfer protocol: use O_NOFOLLOW when opening regular files (:cve:`2026-54055`)
|
||||
|
||||
- dnd kitten: Protect against drops from malicious sources (:cve:`2026-54056`)
|
||||
|
||||
|
||||
0.47.1 [2026-05-28]
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ of the escape code is::
|
||||
<OSC>5522;metadata;payload<ST>
|
||||
|
||||
Here, *metadata* is a colon separated list of key-value pairs and payload is
|
||||
base64 encoded data. :code:`OSC` is :code:`<ESC>[`.
|
||||
base64 encoded data. :code:`OSC` is :code:`<ESC>]`.
|
||||
:code:`ST` is the string terminator, :code:`<ESC>\\`.
|
||||
|
||||
Reading data from the system clipboard
|
||||
@@ -29,11 +29,11 @@ For example, to read plain text and PNG data, the payload would be::
|
||||
|
||||
text/plain image/png
|
||||
|
||||
encoded as base64. To read from the primary selection instead of the
|
||||
encoded as :rfc:`base64 <4648>`. To read from the primary selection instead of the
|
||||
clipboard, add the key ``loc=primary`` to the metadata section.
|
||||
|
||||
To get the list of MIME types available on the clipboard the payload must be
|
||||
just a period (``.``), encoded as base64.
|
||||
just a period (``.``), encoded as :rfc:`base64 <4648>`.
|
||||
|
||||
The terminal emulator will reply with a sequence of escape codes of the form::
|
||||
|
||||
@@ -51,7 +51,8 @@ for an individual type, into chunks of size **no more** than 4096 bytes (4096
|
||||
is the size of a chunk *before* base64 encoding). All
|
||||
the chunks for a given type must be transmitted sequentially and only once they
|
||||
are done the chunks for the next type, if any, should be sent. The end of data
|
||||
is indicated by a ``status=DONE`` packet.
|
||||
is indicated by a ``status=DONE`` packet. base64 padding bytes are required at
|
||||
the end of every base64 encoded chunk when padding is needed.
|
||||
|
||||
If an error occurs, instead of the opening ``status=OK`` packet the terminal
|
||||
must send a ``status=ERRORCODE`` packet. The error code must be one of:
|
||||
|
||||
@@ -3,8 +3,6 @@ The Drag and Drop protocol
|
||||
|
||||
.. versionadded:: 0.47.0
|
||||
|
||||
.. warning:: This protocol is still under development, see :iss:`9984`.
|
||||
|
||||
This protocol enables drag and drop functionality for terminal programs
|
||||
that is as good as the drag and drop functionality available for GUI
|
||||
programs.
|
||||
|
||||
@@ -70,6 +70,11 @@ kitty graphics protocol.
|
||||
Markdown viewer that can render big headers with the text-sizing-protocol, and
|
||||
also render images with the kitty graphics protocol.
|
||||
|
||||
`kmv <https://github.com/parf/Kitty-Markdown-Viewer>`__
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
Markdown viewer that can render big headers with the text-sizing-protocol, and
|
||||
also render images with the kitty graphics protocol.
|
||||
|
||||
.. _tool_term_image:
|
||||
|
||||
`term-image <https://github.com/AnonymouX47/term-image>`__
|
||||
@@ -322,6 +327,13 @@ Various image viewing plugins for editors
|
||||
Scrollback manipulation
|
||||
-------------------------
|
||||
|
||||
.. tool_kitty_search_incremental:
|
||||
|
||||
`kitty-search-incremental <https://github.com/Mobinshahidi/kitty-search>`_
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
Incremental scrollback search with live results, regex and literal modes,
|
||||
case-sensitivity toggle, match counter, and auto-scroll to the matching line.
|
||||
|
||||
.. tool_kitty_scrollback_nvim:
|
||||
|
||||
`kitty-scrollback.nvim <https://github.com/mikesmithgh/kitty-scrollback.nvim>`_
|
||||
|
||||
@@ -194,6 +194,9 @@ define a few extra key bindings in :file:`kitty.conf`::
|
||||
# to restore the original layout.
|
||||
map ctrl+shift+up layout_action maximize vertical
|
||||
|
||||
# Equalize all splits so that windows share available space proportionally.
|
||||
map ctrl+shift+e layout_action equalize
|
||||
|
||||
|
||||
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
|
||||
@@ -201,9 +204,16 @@ 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.
|
||||
restores the original split sizes. The ``equalize`` action redistributes space
|
||||
so that all windows along each split axis receive an equal share.
|
||||
|
||||
This layout takes one option, ``split_axis`` that controls whether new windows
|
||||
This layout takes two options. ``equalize_on_close`` automatically equalizes
|
||||
split sizes whenever a window is closed, keeping remaining windows balanced
|
||||
without needing an explicit keybinding::
|
||||
|
||||
enabled_layouts splits:equalize_on_close=true
|
||||
|
||||
``split_axis`` controls whether new windows
|
||||
are placed into vertical or horizontal splits when a :option:`--location
|
||||
<launch --location>` is not specified. A value of ``horizontal`` (same as
|
||||
``--location=vsplit``) means when a new split is created the two windows will
|
||||
|
||||
8
glfw/wl_init.c
vendored
8
glfw/wl_init.c
vendored
@@ -102,6 +102,11 @@ pointerHandleEnter(
|
||||
|
||||
static void
|
||||
pointerHandleLeave(void* data UNUSED, struct wl_pointer* pointer UNUSED, uint32_t serial, struct wl_surface* surface) {
|
||||
// The pointer never leaves the surface during an implicit grab, so a
|
||||
// leave event means any implicit grab is over (e.g. the compositor took
|
||||
// over the pointer for drag-and-drop). The matching button releases will
|
||||
// never be delivered to us.
|
||||
_glfw.wl.pointer_button_count = 0;
|
||||
_GLFWwindow* window = _glfw.wl.pointerFocus;
|
||||
if (!window) return;
|
||||
_glfw.wl.serial = serial;
|
||||
@@ -138,6 +143,9 @@ static void pointerHandleButton(void* data UNUSED,
|
||||
{
|
||||
glfw_cancel_momentum_scroll();
|
||||
_glfw.wl.serial = serial; _glfw.wl.input_serial = serial; _glfw.wl.pointer_serial = serial;
|
||||
if (state == WL_POINTER_BUTTON_STATE_PRESSED) {
|
||||
if (_glfw.wl.pointer_button_count++ == 0) _glfw.wl.pointer_grab_serial = serial;
|
||||
} else if (_glfw.wl.pointer_button_count > 0) _glfw.wl.pointer_button_count--;
|
||||
|
||||
_GLFWwindow* window = _glfw.wl.pointerFocus;
|
||||
if (!window) return;
|
||||
|
||||
6
glfw/wl_platform.h
vendored
6
glfw/wl_platform.h
vendored
@@ -374,6 +374,12 @@ typedef struct _GLFWlibraryWayland
|
||||
struct wl_surface* cursorSurface;
|
||||
GLFWCursorShape cursorPreviousShape;
|
||||
uint32_t serial, input_serial, pointer_serial, pointer_enter_serial, keyboard_enter_serial;
|
||||
// serial of the button press that started the current pointer implicit
|
||||
// grab, and the number of currently pressed pointer buttons. Requests
|
||||
// such as wl_data_device.start_drag are silently ignored by compositors
|
||||
// unless made with the serial of an active implicit grab.
|
||||
uint32_t pointer_grab_serial;
|
||||
unsigned pointer_button_count;
|
||||
|
||||
int32_t keyboardRepeatRate;
|
||||
monotonic_t keyboardRepeatDelay;
|
||||
|
||||
17
glfw/wl_window.c
vendored
17
glfw/wl_window.c
vendored
@@ -476,7 +476,9 @@ inform_compositor_of_window_geometry(_GLFWwindow *window, const char *event) {
|
||||
#define geometry window->wl.decorations.geometry
|
||||
debug("Setting window %llu \"visible area\" geometry in %s event: x=%d y=%d %dx%d viewport: %dx%d\n",
|
||||
window->id, event, geometry.x, geometry.y, geometry.width, geometry.height, window->wl.width, window->wl.height);
|
||||
xdg_surface_set_window_geometry(window->wl.xdg.surface, geometry.x, geometry.y, geometry.width, geometry.height);
|
||||
// Layer-shell surfaces have no xdg_surface; geometry is managed via the
|
||||
// layer surface, so skip the xdg call to avoid a NULL proxy dereference.
|
||||
if (window->wl.xdg.surface) xdg_surface_set_window_geometry(window->wl.xdg.surface, geometry.x, geometry.y, geometry.width, geometry.height);
|
||||
if (window->wl.wp_viewport) wp_viewport_set_destination(window->wl.wp_viewport, window->wl.width, window->wl.height);
|
||||
#undef geometry
|
||||
}
|
||||
@@ -3494,6 +3496,17 @@ _glfwPlatformStartDrag(_GLFWwindow* window, const GLFWimage* thumbnail) {
|
||||
return ENOTSUP;
|
||||
}
|
||||
|
||||
if (_glfw.wl.pointer_button_count == 0) {
|
||||
// start_drag requires the serial of an active pointer implicit grab,
|
||||
// without one the compositor silently ignores the request and the
|
||||
// data source never receives any events, so fail early instead.
|
||||
// This can happen as drags are started asynchronously and the button
|
||||
// may have been released by the time we get here. EPERM matches what
|
||||
// start_window_drag() in kitty/glfw.c reports for this situation.
|
||||
_glfwInputError(GLFW_PLATFORM_ERROR, "Wayland: Refusing to start drag without an active pointer implicit grab");
|
||||
return EPERM;
|
||||
}
|
||||
|
||||
// Create the data source
|
||||
_glfw.wl.drag.source = wl_data_device_manager_create_data_source(_glfw.wl.dataDeviceManager);
|
||||
if (!_glfw.wl.drag.source) {
|
||||
@@ -3566,7 +3579,7 @@ _glfwPlatformStartDrag(_GLFWwindow* window, const GLFWimage* thumbnail) {
|
||||
wl_data_device_start_drag(
|
||||
_glfw.wl.dataDevice, _glfw.wl.drag.source, window->wl.surface,
|
||||
_glfw.wl.drag.toplevel_drag ? NULL : _glfw.wl.drag.drag_icon,
|
||||
_glfw.wl.pointer_serial);
|
||||
_glfw.wl.pointer_grab_serial);
|
||||
|
||||
if (_glfw.wl.drag.toplevel_drag) {
|
||||
// Attach the toplevel AFTER start_drag, otherwise doesnt work on mutter
|
||||
|
||||
17
go.mod
17
go.mod
@@ -2,14 +2,14 @@ module github.com/kovidgoyal/kitty
|
||||
|
||||
go 1.26.0
|
||||
|
||||
toolchain go1.26.3
|
||||
toolchain go1.26.4
|
||||
|
||||
require (
|
||||
github.com/ALTree/bigfloat v0.2.0
|
||||
github.com/alecthomas/chroma/v2 v2.24.1
|
||||
github.com/alecthomas/chroma/v2 v2.26.1
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0
|
||||
github.com/dlclark/regexp2 v1.12.0
|
||||
github.com/ebitengine/purego v0.10.0
|
||||
github.com/ebitengine/purego v0.10.1
|
||||
github.com/emmansun/base64 v0.9.0
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -21,14 +21,14 @@ require (
|
||||
github.com/kovidgoyal/imaging v1.8.21
|
||||
github.com/nwaples/rardecode/v2 v2.2.3
|
||||
github.com/seancfoley/ipaddress-go v1.7.1
|
||||
github.com/sgtdi/fswatcher v1.2.0
|
||||
github.com/shirou/gopsutil/v4 v4.26.4
|
||||
github.com/sgtdi/fswatcher v1.3.0
|
||||
github.com/shirou/gopsutil/v4 v4.26.5
|
||||
github.com/ulikunitz/xz v0.5.15
|
||||
github.com/zeebo/xxh3 v1.1.0
|
||||
golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b
|
||||
golang.org/x/image v0.40.0
|
||||
golang.org/x/sys v0.44.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.org/x/image v0.42.0
|
||||
golang.org/x/sys v0.45.0
|
||||
golang.org/x/text v0.38.0
|
||||
howett.net/plist v1.0.1
|
||||
)
|
||||
|
||||
@@ -39,6 +39,7 @@ require (
|
||||
// replace github.com/kovidgoyal/imaging => ../imaging
|
||||
|
||||
require (
|
||||
github.com/dlclark/regexp2/v2 v2.1.1 // 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
|
||||
|
||||
30
go.sum
30
go.sum
@@ -2,8 +2,8 @@ github.com/ALTree/bigfloat v0.2.0 h1:AwNzawrpFuw55/YDVlcPw0F0cmmXrmngBHhVrvdXPvM
|
||||
github.com/ALTree/bigfloat v0.2.0/go.mod h1:+NaH2gLeY6RPBPPQf4aRotPPStg+eXc8f9ZaE4vRfD4=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
|
||||
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
|
||||
github.com/alecthomas/chroma/v2 v2.26.1 h1:2X21EdxGZNv5GF9mG5u+uzc02GCFyGxbcBm3Grd9A78=
|
||||
github.com/alecthomas/chroma/v2 v2.26.1/go.mod h1:lxhRRa9H4hPmRLOOdYga4zkQIQjq3dtrrdwQeCfu78Y=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||
@@ -12,8 +12,10 @@ 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.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
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/dlclark/regexp2/v2 v2.1.1 h1:LCUGyd9Wf+r+VVOl8Ny38JTpWJcAsdVnCIuhhtthmKw=
|
||||
github.com/dlclark/regexp2/v2 v2.1.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/emmansun/base64 v0.9.0 h1:92dLrE7iro6g/yWuPsd7M9TzJpe9fEeqKH0H7MApDtE=
|
||||
github.com/emmansun/base64 v0.9.0/go.mod h1:hp0DxCkKt7bF26HOh4BzhcObvqfH1BVy2vznoGThW6Q=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
@@ -54,10 +56,10 @@ 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/sgtdi/fswatcher v1.2.0 h1:uSJuMc3/Eo/vaPnZWpJ42EFYb5j38cZENmkszOV0yhw=
|
||||
github.com/sgtdi/fswatcher v1.2.0/go.mod h1:smzXnaqu0SYJQNIwGLLkvRkpH4RdEACB7avMSsSaqjQ=
|
||||
github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY=
|
||||
github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/sgtdi/fswatcher v1.3.0 h1:2tFEnBml5EipRF4TvUP0x+T4ty2OSYlmvcnQ6dSTp04=
|
||||
github.com/sgtdi/fswatcher v1.3.0/go.mod h1:I4FUeG0e27WFw+ogs5OjZSgPKobnGrUa17EwjRjZQaY=
|
||||
github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.5/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=
|
||||
@@ -74,14 +76,14 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b h1:r+vk0EmXNmekl0S0BascoeeoHk/L7wmaW2QF90K+kYI=
|
||||
golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
|
||||
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -52,6 +52,9 @@ func (self *faces) draw_screen() (err error) {
|
||||
previews, found := self.preview_cache[key]
|
||||
if !found {
|
||||
self.preview_cache[key] = make(map[string]RenderedSampleTransmit)
|
||||
if self.handler.text_style.Foreground == "" || self.handler.text_style.Background == "" {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
var r map[string]RenderedSampleTransmit
|
||||
s := key.settings
|
||||
|
||||
@@ -122,7 +122,7 @@ func (self *FontList) draw_family_summary(start_x int, sz loop.ScreenSize) (err
|
||||
lp.QueueWriteString(line)
|
||||
y++
|
||||
}
|
||||
if self.handler.text_style.Background != "" {
|
||||
if self.handler.text_style.Foreground != "" && self.handler.text_style.Background != "" {
|
||||
return self.draw_preview(start_x, y, sz)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -139,9 +139,14 @@ func (h *handler) on_query_response(key, val string, valid bool) error {
|
||||
}
|
||||
case "foreground":
|
||||
h.text_style.Foreground = val
|
||||
if h.text_style.Background != "" {
|
||||
return h.draw_screen()
|
||||
}
|
||||
case "background":
|
||||
h.text_style.Background = val
|
||||
return h.draw_screen()
|
||||
if h.text_style.Foreground != "" {
|
||||
return h.draw_screen()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ func get_ssh_file(hostname, rpath string) (string, error) {
|
||||
return "", fmt.Errorf("Failed to ssh into remote host %s to get file %s with error: %w", hostname, rpath, err)
|
||||
}
|
||||
tf := tar.NewReader(bytes.NewReader(stdout))
|
||||
count, err := utils.ExtractAllFromTar(tf, tdir)
|
||||
count, err := utils.ExtractAllFromTar(tf, tdir, utils.TarExtractOptions{DontPreserveSuidAndSgid: true})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Failed to untar data from remote host %s to get file %s with error: %w", hostname, rpath, err)
|
||||
}
|
||||
|
||||
@@ -823,7 +823,7 @@ func (dnd *dnd) on_remote_drop_data(cmd DC) (err error) {
|
||||
e.item_type = cmd.Xp
|
||||
switch cmd.Xp {
|
||||
case 0:
|
||||
f, err := utils.CreateAt(e.base_dir.handle, e.name, 0o666)
|
||||
f, err := utils.CreateExclusiveAt(e.base_dir.handle, e.name, 0o666)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -77,7 +77,10 @@ choices=none,parent,all
|
||||
By default, this kitten will signal only the parent kitty instance it is
|
||||
running in to reload its config, after making changes. Use this option
|
||||
to instead either not reload the config at all or in all running
|
||||
kitty instances.
|
||||
kitty instances. Note that if you have config autoreload enabled
|
||||
in kitty.conf (it is enabled by default) then :code:`parent`
|
||||
is the same as :code:`all`, since all kitty instances will detect the config
|
||||
file change and reload automatically.
|
||||
|
||||
|
||||
--dump-theme
|
||||
|
||||
@@ -2046,8 +2046,9 @@ class Boss:
|
||||
self._move_window_to(window, target_os_window_id='new')
|
||||
return
|
||||
if (tab_id := int((data or {}).get(f'application/net.kovidgoyal.kitty-tab-{os.getpid()}', b'0').decode())
|
||||
) and get_tab_being_dragged()[0] == tab_id and (tab := self.tab_for_id(tab_id)):
|
||||
if needs_toplevel_on_wayland:
|
||||
) and get_tab_being_dragged()[0] == tab_id:
|
||||
tab = self.tab_for_id(tab_id)
|
||||
if tab is not None and needs_toplevel_on_wayland:
|
||||
for tm in self.all_tab_managers:
|
||||
if tm.tab_being_dropped:
|
||||
tm.on_tab_drop(0, 0, bypass_move=True)
|
||||
@@ -2055,7 +2056,7 @@ class Boss:
|
||||
set_tab_being_dragged()
|
||||
for tm in self.all_tab_managers:
|
||||
tm.on_tab_drop_move()
|
||||
if was_dropped: # detach tab into new OS Window
|
||||
if was_dropped and tab is not None: # detach tab into new OS Window
|
||||
self._move_tab_to(tab)
|
||||
|
||||
@ac('win', '''
|
||||
@@ -3743,5 +3744,6 @@ class Boss:
|
||||
|
||||
def copy_or_noop(self) -> bool | None:
|
||||
if w := self.active_window:
|
||||
return w.copy_or_noop()
|
||||
ans = w.copy_or_noop()
|
||||
return ans
|
||||
return True
|
||||
|
||||
@@ -51,6 +51,7 @@ extern CGSConnectionID _CGSDefaultConnection(void);
|
||||
CFArrayRef CGSCopySpacesForWindows(CGSConnectionID Connection, CGSSpaceSelector Type, CFArrayRef Windows);
|
||||
|
||||
static NSMenuItem* title_menu = NULL;
|
||||
static NSMenuItem* secure_input_title_menu = NULL;
|
||||
static bool application_has_finished_launching = false;
|
||||
|
||||
|
||||
@@ -217,6 +218,25 @@ find_app_name(void) {
|
||||
@end
|
||||
// }}}
|
||||
|
||||
static void
|
||||
update_secure_input_menu_bar_indicator(BOOL enabled) {
|
||||
if (enabled) {
|
||||
if (secure_input_title_menu == NULL) {
|
||||
NSMenu *bar = [NSApp mainMenu];
|
||||
secure_input_title_menu = [bar addItemWithTitle:@"" action:NULL keyEquivalent:@""];
|
||||
NSMenu *m = [[NSMenu alloc] initWithTitle:@"[Secure input]"];
|
||||
[secure_input_title_menu setSubmenu:m];
|
||||
[m release];
|
||||
}
|
||||
} else {
|
||||
if (secure_input_title_menu != NULL) {
|
||||
NSMenu *bar = [NSApp mainMenu];
|
||||
[bar removeItem:secure_input_title_menu];
|
||||
secure_input_title_menu = NULL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@interface UserMenuItem : NSMenuItem
|
||||
@property (nonatomic) size_t action_index;
|
||||
@end
|
||||
@@ -323,6 +343,7 @@ PENDING(copy_or_noop, COPY_OR_NOOP)
|
||||
sharedGlobalMenuTarget = [[GlobalMenuTarget alloc] init];
|
||||
SecureKeyboardEntryController *k = [SecureKeyboardEntryController sharedInstance];
|
||||
if (!k.isDesired && [[NSUserDefaults standardUserDefaults] boolForKey:@"SecureKeyboardEntry"]) [k toggle];
|
||||
update_secure_input_menu_bar_indicator(k.isDesired);
|
||||
}
|
||||
return sharedGlobalMenuTarget;
|
||||
}
|
||||
@@ -932,7 +953,14 @@ cocoa_recreate_global_menu(void) {
|
||||
[bar removeItem:title_menu];
|
||||
}
|
||||
title_menu = NULL;
|
||||
if (secure_input_title_menu != NULL) {
|
||||
NSMenu *bar = [NSApp mainMenu];
|
||||
[bar removeItem:secure_input_title_menu];
|
||||
}
|
||||
secure_input_title_menu = NULL;
|
||||
cocoa_create_global_menu();
|
||||
SecureKeyboardEntryController *k = [SecureKeyboardEntryController sharedInstance];
|
||||
update_secure_input_menu_bar_indicator(k.isDesired);
|
||||
}
|
||||
|
||||
|
||||
@@ -952,6 +980,7 @@ cocoa_toggle_secure_keyboard_entry(void) {
|
||||
SecureKeyboardEntryController *k = [SecureKeyboardEntryController sharedInstance];
|
||||
[k toggle];
|
||||
[[NSUserDefaults standardUserDefaults] setBool:k.isDesired forKey:@"SecureKeyboardEntry"];
|
||||
update_secure_input_menu_bar_indicator(k.isDesired);
|
||||
}
|
||||
|
||||
void
|
||||
@@ -1383,6 +1412,12 @@ cocoa_clear_dock_badge_if_set(void) {
|
||||
|
||||
// }}}
|
||||
|
||||
static PyObject*
|
||||
cocoa_is_secure_input_enabled(PyObject *self UNUSED, PyObject *args UNUSED) {
|
||||
SecureKeyboardEntryController *k = [SecureKeyboardEntryController sharedInstance];
|
||||
return Py_NewRef(k.isDesired ? Py_True : Py_False);
|
||||
}
|
||||
|
||||
static PyObject*
|
||||
cocoa_get_machine_id(PyObject *self UNUSED, PyObject *args UNUSED) {
|
||||
static char ans[1024] = {0};
|
||||
@@ -1410,6 +1445,7 @@ static PyMethodDef module_methods[] = {
|
||||
{"cocoa_play_system_sound_by_id_async", play_system_sound_by_id_async, METH_O, ""},
|
||||
{"cocoa_get_lang", (PyCFunction)cocoa_get_lang, METH_NOARGS, ""},
|
||||
{"cocoa_get_machine_id", (PyCFunction)cocoa_get_machine_id, METH_NOARGS, ""},
|
||||
{"cocoa_is_secure_input_enabled", (PyCFunction)cocoa_is_secure_input_enabled, METH_NOARGS, ""},
|
||||
{"cocoa_set_global_shortcut", (PyCFunction)cocoa_set_global_shortcut, METH_VARARGS, ""},
|
||||
{"cocoa_send_notification", (PyCFunction)(void(*)(void))cocoa_send_notification, METH_VARARGS | METH_KEYWORDS, ""},
|
||||
{"cocoa_remove_delivered_notification", (PyCFunction)cocoa_remove_delivered_notification, METH_O, ""},
|
||||
|
||||
@@ -22,7 +22,7 @@ class Version(NamedTuple):
|
||||
|
||||
appname: str = 'kitty'
|
||||
kitty_face = '🐱'
|
||||
version: Version = Version(0, 47, 1)
|
||||
version: Version = Version(0, 47, 3)
|
||||
str_version: str = '.'.join(map(str, version))
|
||||
_plat = sys.platform.lower()
|
||||
is_macos: bool = 'darwin' in _plat
|
||||
|
||||
@@ -329,7 +329,7 @@ bool colorprofile_pop_colors(ColorProfile*, unsigned int);
|
||||
void colorprofile_report_stack(ColorProfile*, unsigned int*, unsigned int*);
|
||||
|
||||
void set_mouse_cursor(MouseShape);
|
||||
void enter_event(int modifiers);
|
||||
void enter_event(int modifiers, bool cursor_moved);
|
||||
void leave_event(int modifiers);
|
||||
void mouse_event(const int, int, int);
|
||||
void focus_in_event(void);
|
||||
|
||||
@@ -423,7 +423,7 @@ class FontConfigPattern(TypedDict):
|
||||
scalable: bool
|
||||
outline: bool
|
||||
color: bool
|
||||
matrix: tuple[float, float, float, float]
|
||||
matrix: NotRequired[tuple[float, float, float, float]]
|
||||
variable: bool
|
||||
named_instance: bool
|
||||
|
||||
|
||||
@@ -547,7 +547,7 @@ class DestFile:
|
||||
if self.actual_file is None:
|
||||
self.make_parent_dirs()
|
||||
self.unlink_existing_if_needed()
|
||||
flags = os.O_RDWR | os.O_CREAT | os.O_TRUNC | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_BINARY', 0)
|
||||
flags = os.O_RDWR | os.O_CREAT | os.O_TRUNC | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_BINARY', 0) | getattr(os, 'O_NOFOLLOW', 0)
|
||||
self.actual_file = open(os.open(self.name, flags, self.permissions), mode='r+b', closefd=True)
|
||||
af = self.actual_file
|
||||
if decompressed or is_last:
|
||||
@@ -1172,7 +1172,8 @@ class FileTransmission:
|
||||
window = boss.window_id_map.get(self.window_id)
|
||||
if window is not None:
|
||||
boss.confirm(_(
|
||||
'The remote machine wants to read some files from this computer. Do you want to allow the transfer?'),
|
||||
'The remote machine wants to read some files from this computer.'
|
||||
' Only allow transfers to computers you trust. Do you want to allow the transfer?'),
|
||||
self.handle_receive_confirmation, asd_id, window=window,
|
||||
)
|
||||
|
||||
@@ -1202,7 +1203,8 @@ class FileTransmission:
|
||||
window = boss.window_id_map.get(self.window_id)
|
||||
if window is not None:
|
||||
boss.confirm(_(
|
||||
'The remote machine wants to send some files to this computer. Do you want to allow the transfer?'),
|
||||
'The remote machine wants to send some files to this computer.'
|
||||
' Only allow transfers from computers you trust. Do you want to allow the transfer?'),
|
||||
self.handle_send_confirmation, ar_id, window=window,
|
||||
)
|
||||
|
||||
|
||||
@@ -330,7 +330,7 @@ _native_fc_match(FcPattern *pat, FontConfigFace *ans) {
|
||||
FcChar8 *out;
|
||||
#define g(func, prop, output) if (func(match, prop, 0, &output) != FcResultMatch) { PyErr_SetString(PyExc_ValueError, "No " #prop " found in fontconfig match result"); goto end; }
|
||||
g(FcPatternGetString, FC_FILE, out);
|
||||
g(FcPatternGetInteger, FC_INDEX, ans->index);
|
||||
if (FcPatternGetInteger(match, FC_INDEX, 0, &ans->index) != FcResultMatch) ans->index = 0; // ignore missing index assume it is zero
|
||||
g(FcPatternGetInteger, FC_HINT_STYLE, ans->hintstyle);
|
||||
g(FcPatternGetBool, FC_HINTING, ans->hinting);
|
||||
#undef g
|
||||
|
||||
@@ -871,11 +871,33 @@ pt_to_px(double pt, double dpi) {
|
||||
return ((long)round((pt * (dpi / 72.0))));
|
||||
}
|
||||
|
||||
static void
|
||||
apply_cairo_font_size(Face *self, unsigned sz_px) {
|
||||
// The cairo path uses self->face_for_cairo (a second FT_Face opened in
|
||||
// ensure_cairo_resources), which never receives the FT_Set_Transform set
|
||||
// on self->face in face_from_descriptor. cairo owns FT_Set_Transform on
|
||||
// its face and derives it from the font matrix on every render
|
||||
// (_cairo_ft_unscaled_font_set_scale in cairo-ft-font.c), so the only
|
||||
// channel that reaches glyph rasterization is the cairo font matrix
|
||||
// itself. Encode FC_MATRIX there.
|
||||
// FT_Matrix is xx,xy,yx,yy (row-major); cairo_matrix_init takes
|
||||
// xx,yx,xy,yy. Same matrix, transposed argument order.
|
||||
if (!self->has_matrix) { cairo_set_font_size(self->cairo.cr, sz_px); return; }
|
||||
double s = (double)sz_px;
|
||||
double xx = self->matrix.xx / 65536.0;
|
||||
double xy = self->matrix.xy / 65536.0;
|
||||
double yx = self->matrix.yx / 65536.0;
|
||||
double yy = self->matrix.yy / 65536.0;
|
||||
cairo_matrix_t m;
|
||||
cairo_matrix_init(&m, xx * s, yx * s, xy * s, yy * s, 0, 0);
|
||||
cairo_set_font_matrix(self->cairo.cr, &m);
|
||||
}
|
||||
|
||||
static void
|
||||
set_cairo_font_size(Face *self, double size_in_pts) {
|
||||
unsigned sz_px = pt_to_px(size_in_pts, (self->xdpi + self->ydpi) / 2.0);
|
||||
if (self->cairo.size_in_px == sz_px) return;
|
||||
cairo_set_font_size(self->cairo.cr, sz_px);
|
||||
apply_cairo_font_size(self, sz_px);
|
||||
self->cairo.size_in_px = sz_px;
|
||||
}
|
||||
|
||||
@@ -885,7 +907,7 @@ fit_cairo_glyph(Face *self, cairo_glyph_t *g, cairo_text_extents_t *bb, cairo_sc
|
||||
double ratio = MIN(width / bb->width, height / bb->height);
|
||||
unsigned sz = (unsigned)(ratio * self->cairo.size_in_px);
|
||||
if (sz >= self->cairo.size_in_px) sz = self->cairo.size_in_px - 2;
|
||||
cairo_set_font_size(self->cairo.cr, sz);
|
||||
apply_cairo_font_size(self, sz);
|
||||
sf = cairo_get_scaled_font(self->cairo.cr);
|
||||
cairo_scaled_font_glyph_extents(sf, g, 1, bb);
|
||||
self->cairo.size_in_px = sz;
|
||||
|
||||
14
kitty/glfw.c
14
kitty/glfw.c
@@ -539,12 +539,20 @@ cursor_enter_callback(GLFWwindow *w, int entered) {
|
||||
glfwGetCursorPos(w, &x, &y);
|
||||
monotonic_t now = monotonic();
|
||||
global_state.callback_os_window->last_mouse_activity_at = now;
|
||||
global_state.callback_os_window->mouse_x = x * global_state.callback_os_window->viewport_x_ratio;
|
||||
global_state.callback_os_window->mouse_y = y * global_state.callback_os_window->viewport_y_ratio;
|
||||
double new_mouse_x = x * global_state.callback_os_window->viewport_x_ratio;
|
||||
double new_mouse_y = y * global_state.callback_os_window->viewport_y_ratio;
|
||||
// focus_follows_mouse should react to the mouse moving, not to a window
|
||||
// appearing under a stationary cursor (such as when returning to this
|
||||
// desktop/space). Detect genuine motion by comparing against the last
|
||||
// known cursor position so an enter caused by mouse motion still switches
|
||||
// focus, while a stationary reappearance does not.
|
||||
bool cursor_moved = new_mouse_x != global_state.callback_os_window->mouse_x || new_mouse_y != global_state.callback_os_window->mouse_y;
|
||||
global_state.callback_os_window->mouse_x = new_mouse_x;
|
||||
global_state.callback_os_window->mouse_y = new_mouse_y;
|
||||
if (entered) {
|
||||
debug_input("Mouse cursor entered window: %llu at %fx%f\n", global_state.callback_os_window->id, x, y);
|
||||
cursor_active_callback(now);
|
||||
if (is_window_ready_for_callbacks()) enter_event(global_state.mods_at_last_key_or_button_event);
|
||||
if (is_window_ready_for_callbacks()) enter_event(global_state.mods_at_last_key_or_button_event, cursor_moved);
|
||||
} else {
|
||||
debug_input("Mouse cursor left window: %llu\n", global_state.callback_os_window->id);
|
||||
if (is_window_ready_for_callbacks()) leave_event(global_state.mods_at_last_key_or_button_event);
|
||||
|
||||
@@ -218,6 +218,13 @@ class Mappings:
|
||||
# the shortcuts in the global menubar will have been bypassed so trigger them here
|
||||
key_action = global_key_action
|
||||
else:
|
||||
# On macOS copy_or_noop is mapped to Cmd+C by default and gets
|
||||
# disabled when there is no copyable text so special case it
|
||||
# and pass it on.
|
||||
if is_macos and (w := get_boss().active_window):
|
||||
for action in global_key_action:
|
||||
if action.definition == 'copy_or_noop' and not w.screen.has_selection():
|
||||
return False
|
||||
return True
|
||||
if key_action is None:
|
||||
if is_modifier_key(ev.key):
|
||||
|
||||
@@ -492,6 +492,9 @@ class Layout:
|
||||
def layout_action(self, action_name: str, args: Sequence[str], all_windows: WindowList) -> bool | None:
|
||||
pass
|
||||
|
||||
def on_window_removed(self, all_windows: WindowList) -> bool:
|
||||
return False
|
||||
|
||||
def layout_state(self) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from collections.abc import Collection, Generator, Iterator, Sequence
|
||||
from typing import Any, Optional, TypedDict, Union
|
||||
|
||||
from kitty.borders import BorderColor
|
||||
from kitty.conf.utils import to_bool
|
||||
from kitty.fast_data_types import BOTTOM_EDGE, LEFT_EDGE, RIGHT_EDGE, TOP_EDGE
|
||||
from kitty.types import Edges, NeighborsMap, WindowGeometry, WindowMapper, WindowResizeDragData
|
||||
from kitty.typing_compat import EdgeLiteral, WindowType
|
||||
@@ -13,6 +14,12 @@ from kitty.window_list import WindowGroup, WindowList
|
||||
from .base import BorderLine, DragOverlayMode, Layout, LayoutOpts, blank_rects_for_window, lgd, window_geometry_from_layouts
|
||||
|
||||
|
||||
def child_axis_units(child: 'Pair | int | None', horizontal: bool) -> int:
|
||||
if isinstance(child, Pair):
|
||||
return child.count_axis_units(horizontal)
|
||||
return 1 if child is not None else 0
|
||||
|
||||
|
||||
class SerializedPair(TypedDict, total=False):
|
||||
horizontal: bool # default to True if absent
|
||||
bias: float # default to 0.5 if absent
|
||||
@@ -82,6 +89,11 @@ class Pair:
|
||||
if isinstance(self.two, Pair):
|
||||
yield from self.two.self_and_descendants()
|
||||
|
||||
def count_axis_units(self, horizontal: bool) -> int:
|
||||
if self.horizontal != horizontal:
|
||||
return 1
|
||||
return child_axis_units(self.one, horizontal) + child_axis_units(self.two, horizontal)
|
||||
|
||||
def pair_for_window(self, window_id: int) -> Optional['Pair']:
|
||||
if self.one == window_id or self.two == window_id:
|
||||
return self
|
||||
@@ -544,6 +556,7 @@ class Pair:
|
||||
class SplitsLayoutOpts(LayoutOpts):
|
||||
|
||||
default_axis_is_horizontal: bool | None = True
|
||||
equalize_on_close: bool = False
|
||||
|
||||
def __init__(self, data: dict[str, str]):
|
||||
q = data.get('split_axis', 'horizontal')
|
||||
@@ -551,9 +564,13 @@ class SplitsLayoutOpts(LayoutOpts):
|
||||
self.default_axis_is_horizontal = None
|
||||
else:
|
||||
self.default_axis_is_horizontal = q == 'horizontal'
|
||||
self.equalize_on_close = to_bool(data.get('equalize_on_window_close', 'n'))
|
||||
|
||||
def serialized(self) -> dict[str, Any]:
|
||||
return {'default_axis_is_horizontal': self.default_axis_is_horizontal}
|
||||
def serialized(self) -> dict[str, str]:
|
||||
return {
|
||||
'split_axis': 'auto' if self.default_axis_is_horizontal is None else ('horizontal' if self.default_axis_is_horizontal else 'vertical'),
|
||||
'equalize_on_window_close': 'y' if self.equalize_on_close else 'n',
|
||||
}
|
||||
|
||||
|
||||
class Splits(Layout):
|
||||
@@ -669,6 +686,20 @@ class Splits(Layout):
|
||||
pair.bias = 0.5
|
||||
return True
|
||||
|
||||
def equalize_biases(self) -> bool:
|
||||
for pair in self.pairs_root.self_and_descendants():
|
||||
left = child_axis_units(pair.one, pair.horizontal)
|
||||
right = child_axis_units(pair.two, pair.horizontal)
|
||||
total = left + right
|
||||
if total > 0:
|
||||
pair.bias = left / total
|
||||
return True
|
||||
|
||||
def on_window_removed(self, all_windows: WindowList) -> bool:
|
||||
if self.layout_opts.equalize_on_close:
|
||||
return self.equalize_biases()
|
||||
return False
|
||||
|
||||
def minimal_borders(self, all_windows: WindowList) -> Iterator[BorderLine]:
|
||||
groups = tuple(all_windows.iter_all_layoutable_groups())
|
||||
window_count = len(groups)
|
||||
@@ -850,6 +881,8 @@ class Splits(Layout):
|
||||
maximized_biases[key] = saved_biases
|
||||
self._maximized_biases = maximized_biases
|
||||
return True
|
||||
elif action_name == 'equalize':
|
||||
return self.equalize_biases()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ update_scrollbar_hover_state(Window *w, bool hovering) {
|
||||
}
|
||||
|
||||
static void
|
||||
set_currently_hovered_window(id_type window_id, int modifiers) {
|
||||
set_currently_hovered_window(id_type window_id, int modifiers, bool focus_follows) {
|
||||
if (global_state.mouse_hover_in_window != window_id) {
|
||||
Window *left_window = window_for_id(global_state.mouse_hover_in_window);
|
||||
global_state.mouse_hover_in_window = window_id;
|
||||
@@ -196,7 +196,7 @@ set_currently_hovered_window(id_type window_id, int modifiers) {
|
||||
debug("Sent mouse leave event to window: %llu currently hovering: %llu\n", left_window->id, window_id);
|
||||
}
|
||||
}
|
||||
if (window_id && OPT(focus_follows_mouse).on_cross && global_state.callback_os_window && global_state.callback_os_window->num_tabs) {
|
||||
if (focus_follows && window_id && OPT(focus_follows_mouse).on_cross && global_state.callback_os_window && global_state.callback_os_window->num_tabs) {
|
||||
Tab *t = global_state.callback_os_window->tabs + global_state.callback_os_window->active_tab;
|
||||
for (unsigned i = 0; i < t->num_windows; i++) {
|
||||
if (t->windows[i].id == window_id) {
|
||||
@@ -645,6 +645,11 @@ handle_scrollbar_mouse(Window *w, int button, MouseAction action, int modifiers
|
||||
start_scrollbar_drag(w, mouse_y);
|
||||
global_state.active_drag_in_window = w->id;
|
||||
global_state.active_drag_button = button;
|
||||
} else if (hit_type == SCROLLBAR_HIT_TRACK && OPT(scrollbar_jump_on_click)) {
|
||||
handle_scrollbar_track_click(w, mouse_y);
|
||||
start_scrollbar_drag(w, mouse_y);
|
||||
global_state.active_drag_in_window = w->id;
|
||||
global_state.active_drag_button = button;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -929,7 +934,7 @@ currently_pressed_button(void) {
|
||||
HANDLER(handle_event) {
|
||||
modifiers &= ~GLFW_LOCK_MASK;
|
||||
set_mouse_cursor_for_screen(w->render_data.screen);
|
||||
set_currently_hovered_window(w->id, modifiers);
|
||||
set_currently_hovered_window(w->id, modifiers, true);
|
||||
if (button == -1) {
|
||||
button = currently_pressed_button();
|
||||
handle_move_event(w, button, modifiers, window_idx);
|
||||
@@ -950,8 +955,21 @@ handle_window_title_bar_mouse(Window *w, int button, int modifiers, int action)
|
||||
|
||||
static void
|
||||
handle_tab_bar_mouse(int button, int modifiers, int action) {
|
||||
set_currently_hovered_window(0, modifiers);
|
||||
set_currently_hovered_window(0, modifiers, false);
|
||||
OSWindow *w = global_state.callback_os_window;
|
||||
if (button == GLFW_MOUSE_BUTTON_LEFT && action == GLFW_RELEASE && global_state.tab_being_dragged.id
|
||||
&& global_state.tab_being_dragged.drag_started && !global_state.drag_source.is_active) {
|
||||
// Once a system drag and drop is active the release is consumed by it
|
||||
// and never delivered to us, so getting one here means the drag never
|
||||
// became a system DND: either glfwStartDrag failed/was not called yet
|
||||
// or the compositor silently ignored it (Wayland with a stale serial).
|
||||
// Clear the drag state so mouse handling is not redirected to the tab
|
||||
// bar forever, and swallow the release as it ended an aborted drag.
|
||||
zero_at_ptr(&global_state.tab_being_dragged);
|
||||
// re-render the tab bar in case it was drawn without the dragged tab
|
||||
if (w) w->tab_bar_data_updated = false;
|
||||
return;
|
||||
}
|
||||
// dont report motion events, as they are expensive and useless
|
||||
if (w && (button > -1 || global_state.tab_being_dragged.id)) {
|
||||
call_boss(handle_tab_bar_mouse, "Kddiii", w->id, w->mouse_x, w->mouse_y, button, modifiers, action);
|
||||
@@ -1143,11 +1161,11 @@ update_mouse_pointer_shape(void) {
|
||||
void
|
||||
leave_event(int modifiers) {
|
||||
if (global_state.redirect_mouse_handling || global_state.active_drag_in_window || global_state.tracked_drag_in_window) return;
|
||||
set_currently_hovered_window(0, modifiers);
|
||||
set_currently_hovered_window(0, modifiers, false);
|
||||
}
|
||||
|
||||
void
|
||||
enter_event(int modifiers) {
|
||||
enter_event(int modifiers, bool cursor_moved) {
|
||||
#ifdef __APPLE__
|
||||
// On cocoa there is no way to configure the window manager to
|
||||
// focus windows on mouse enter, so we do it ourselves
|
||||
@@ -1165,7 +1183,7 @@ enter_event(int modifiers) {
|
||||
if (global_state.redirect_mouse_handling || global_state.active_drag_in_window || global_state.tracked_drag_in_window) return;
|
||||
MouseRegion r = mouse_region(false, false);
|
||||
Window *w = r.window;
|
||||
set_currently_hovered_window(w ? w->id : 0, modifiers);
|
||||
set_currently_hovered_window(w ? w->id : 0, modifiers, cursor_moved);
|
||||
if (!w || r.in_tab_bar || r.in_title_bar) return;
|
||||
|
||||
if (handle_scrollbar_mouse(w, -1, MOVE, modifiers)) return;
|
||||
@@ -1354,7 +1372,7 @@ mouse_event(const int button, int modifiers, int action) {
|
||||
}
|
||||
MouseRegion r = mouse_region(true, true);
|
||||
w = r.window; window_idx = r.window_idx;
|
||||
set_currently_hovered_window(w && !r.window_border && !r.in_title_bar ? w->id : 0, modifiers);
|
||||
set_currently_hovered_window(w && !r.window_border && !r.in_title_bar ? w->id : 0, modifiers, true);
|
||||
|
||||
if (r.in_tab_bar || global_state.tab_being_dragged.id) {
|
||||
mouse_cursor_shape = POINTER_POINTER;
|
||||
|
||||
@@ -1753,7 +1753,7 @@ A value of zero means that no limit is applied.
|
||||
'''
|
||||
)
|
||||
|
||||
opt('tab_title_template', '"{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.tab}{tab.last_focused_progress_percent}{title}"',
|
||||
opt('tab_title_template', '"{fmt.fg.red}{bell_symbol}{activity_symbol}{secure_input_symbol}{fmt.fg.tab}{tab.last_focused_progress_percent}{title}"',
|
||||
option_type='tab_title_template',
|
||||
long_text='''
|
||||
A template to render the tab title. The default just renders the title with
|
||||
@@ -1776,6 +1776,8 @@ use :code:`{sup.index}`. All data available is:
|
||||
The number of windows in the tab.
|
||||
:code:`num_window_groups`
|
||||
The number of window groups (a window group is a window and all of its overlay windows) in the tab.
|
||||
:code:`secure_input_symbol`
|
||||
A key symbol when secure input is enabled on macOS and the tab is the active tab, empty otherwise.
|
||||
:code:`tab.active_wd`
|
||||
The working directory of the currently active window in the tab
|
||||
(expensive, requires syscall). Use :code:`tab.active_oldest_wd` to get
|
||||
|
||||
@@ -33,7 +33,7 @@ layout_choices = 'tiled,scaled,mirror-tiled,clamped,centered,cscaled,configured'
|
||||
|
||||
class SetBackgroundImage(RemoteCommand):
|
||||
|
||||
protocol_spec = __doc__ = '''
|
||||
protocol_spec = __doc__ = f'''
|
||||
data+/str: Chunk of at most 512 bytes of PNG data, base64 encoded. Must send an empty chunk to indicate end of image. \
|
||||
Or the special value - to indicate image must be removed. Or the value index:idx to change image.
|
||||
match/str: Window to change opacity in
|
||||
@@ -144,6 +144,8 @@ failed, the command will exit with a success code.
|
||||
windows = self.windows_for_payload(boss, window, payload_get, window_match_name='match')
|
||||
os_windows = tuple({w.os_window_id for w in windows if w})
|
||||
layout = payload_get('layout')
|
||||
if layout == 'configured':
|
||||
layout = None
|
||||
try:
|
||||
boss.set_background_image(
|
||||
path, os_windows, payload_get('configured'), layout, tfile.getvalue(),
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import (
|
||||
)
|
||||
|
||||
from .borders import Border, BorderColor
|
||||
from .constants import config_dir
|
||||
from .constants import config_dir, is_macos
|
||||
from .fast_data_types import (
|
||||
BOTTOM_EDGE,
|
||||
DECAWM,
|
||||
@@ -35,6 +35,8 @@ from .types import WindowGeometry, run_once
|
||||
from .typing_compat import EdgeLiteral, PowerlineStyle
|
||||
from .utils import color_as_int, log_error, sgr_sanitizer_pat
|
||||
|
||||
if is_macos:
|
||||
from .fast_data_types import cocoa_is_secure_input_enabled # type: ignore
|
||||
|
||||
class TabBarData(NamedTuple):
|
||||
title: str
|
||||
@@ -268,11 +270,13 @@ def apply_title_template(draw_data: DrawData, tab: TabBarData, index: int, max_t
|
||||
if tab.tab_id < 0:
|
||||
return tab.title # synthetic tab — render title literally, skip user template
|
||||
ta = TabAccessor(tab.tab_id)
|
||||
si = ('🔑' if cocoa_is_secure_input_enabled() else '') if is_macos and tab.is_active else ''
|
||||
data = {
|
||||
'index': index,
|
||||
'layout_name': tab.layout_name,
|
||||
'num_windows': tab.num_windows,
|
||||
'num_window_groups': tab.num_window_groups,
|
||||
'secure_input_symbol': si,
|
||||
'title': tab.title,
|
||||
'tab': ta,
|
||||
}
|
||||
|
||||
112
kitty/tabs.py
112
kitty/tabs.py
@@ -841,7 +841,10 @@ class Tab: # {{{
|
||||
|
||||
def post_window_removal_update(self) -> None:
|
||||
self.mark_tab_bar_dirty()
|
||||
self.relayout()
|
||||
self.relayout() # prunes the closed window from the layout's internal tree
|
||||
# equalize_on_close rebalances the pruned tree, requiring a second relayout
|
||||
if self.current_layout.on_window_removed(self.windows):
|
||||
self.relayout()
|
||||
active_window = self.active_window
|
||||
if active_window:
|
||||
self.title_changed(active_window)
|
||||
@@ -1070,49 +1073,57 @@ class Tab: # {{{
|
||||
self, field: str, query: str, active_tab_manager: Optional['TabManager'] = None,
|
||||
active_session: str = '', most_recent_session: str = ''
|
||||
) -> bool:
|
||||
if field == 'title':
|
||||
return re.search(query, self.effective_title) is not None
|
||||
if field == 'id':
|
||||
return query == str(self.id)
|
||||
if field in ('window_id', 'window_title'):
|
||||
field = field.partition('_')[-1]
|
||||
for w in self:
|
||||
if w.matches_query(field, query):
|
||||
return True
|
||||
return False
|
||||
if field == 'index':
|
||||
if active_tab_manager and len(active_tab_manager.tabs):
|
||||
idx = (int(query) + len(active_tab_manager.tabs)) % len(active_tab_manager.tabs)
|
||||
return active_tab_manager.tabs[idx] is self
|
||||
return False
|
||||
if field == 'recent':
|
||||
if active_tab_manager and len(active_tab_manager.tabs):
|
||||
return self is active_tab_manager.nth_active_tab(int(query))
|
||||
return False
|
||||
if field == 'state':
|
||||
if query == 'active':
|
||||
tm = self.tab_manager_ref()
|
||||
return tm is not None and self is tm.active_tab
|
||||
if query == 'focused':
|
||||
return active_tab_manager is not None and self is active_tab_manager.active_tab and self.os_window_id == last_focused_os_window_id()
|
||||
if query == 'needs_attention':
|
||||
match field:
|
||||
case 'title':
|
||||
return re.search(query, self.effective_title) is not None
|
||||
case 'id':
|
||||
return query == str(self.id)
|
||||
case 'window_id' | 'window_title':
|
||||
field = field.partition('_')[-1]
|
||||
for w in self:
|
||||
if w.needs_attention:
|
||||
if w.matches_query(field, query):
|
||||
return True
|
||||
if query == 'parent_active':
|
||||
return active_tab_manager is not None and self.tab_manager_ref() is active_tab_manager
|
||||
if query == 'parent_focused':
|
||||
return active_tab_manager is not None and self.tab_manager_ref() is active_tab_manager and self.os_window_id == last_focused_os_window_id()
|
||||
if query == 'focused_os_window':
|
||||
return self.os_window_id == last_focused_os_window_id()
|
||||
return False
|
||||
if field == 'session':
|
||||
match query:
|
||||
case '.':
|
||||
return self.created_in_session_name == active_session
|
||||
case '~':
|
||||
return self.created_in_session_name == active_session or self.created_in_session_name == most_recent_session
|
||||
return re.search(query, self.created_in_session_name) is not None
|
||||
return False
|
||||
case 'var' | 'env':
|
||||
for w in self:
|
||||
if w.matches_query(field, query):
|
||||
return True
|
||||
return False
|
||||
case 'index':
|
||||
if active_tab_manager and len(active_tab_manager.tabs):
|
||||
idx = (int(query) + len(active_tab_manager.tabs)) % len(active_tab_manager.tabs)
|
||||
return active_tab_manager.tabs[idx] is self
|
||||
return False
|
||||
case 'recent':
|
||||
if active_tab_manager and len(active_tab_manager.tabs):
|
||||
return self is active_tab_manager.nth_active_tab(int(query))
|
||||
return False
|
||||
case 'state':
|
||||
match query:
|
||||
case 'active':
|
||||
tm = self.tab_manager_ref()
|
||||
return tm is not None and self is tm.active_tab
|
||||
case 'focused':
|
||||
return active_tab_manager is not None and self is active_tab_manager.active_tab and self.os_window_id == last_focused_os_window_id()
|
||||
case 'needs_attention':
|
||||
for w in self:
|
||||
if w.needs_attention:
|
||||
return True
|
||||
case 'parent_active':
|
||||
return active_tab_manager is not None and self.tab_manager_ref() is active_tab_manager
|
||||
case 'parent_focused':
|
||||
return active_tab_manager is not None and \
|
||||
self.tab_manager_ref() is active_tab_manager and self.os_window_id == last_focused_os_window_id()
|
||||
case 'focused_os_window':
|
||||
return self.os_window_id == last_focused_os_window_id()
|
||||
return False
|
||||
case 'session':
|
||||
match query:
|
||||
case '.':
|
||||
return self.created_in_session_name == active_session
|
||||
case '~':
|
||||
return self.created_in_session_name == active_session or self.created_in_session_name == most_recent_session
|
||||
return re.search(query, self.created_in_session_name) is not None
|
||||
return False
|
||||
|
||||
def __iter__(self) -> Iterator[Window]:
|
||||
@@ -1721,6 +1732,9 @@ class TabManager: # {{{
|
||||
if (td := self.tab_being_dropped) is None:
|
||||
return
|
||||
if (tab := get_boss().tab_for_id(td.data.tab_id)) is None:
|
||||
self.tab_being_dropped = None
|
||||
set_tab_being_dragged()
|
||||
self.layout_tab_bar()
|
||||
return
|
||||
if not bypass_move:
|
||||
self.on_tab_drop_move(td.data.tab_id, True, x, y)
|
||||
@@ -1768,7 +1782,12 @@ class TabManager: # {{{
|
||||
drag_data = {
|
||||
f'application/net.kovidgoyal.kitty-tab-{os.getpid()}': str(tab.id).encode(),
|
||||
}
|
||||
start_drag_with_data(self.os_window_id, drag_data, thumbnails)
|
||||
try:
|
||||
start_drag_with_data(self.os_window_id, drag_data, thumbnails)
|
||||
except OSError as e:
|
||||
log_error(f'Failed to start tab drag: {e}')
|
||||
set_tab_being_dragged()
|
||||
self.mark_tab_bar_dirty() # re-render the tab bar in case it was drawn without the dragged tab
|
||||
break
|
||||
else:
|
||||
set_tab_being_dragged()
|
||||
@@ -1786,16 +1805,21 @@ class TabManager: # {{{
|
||||
|
||||
tab_id_at_x = self.tab_bar.tab_id_at(int(x))
|
||||
self.recent_tab_bar_mouse_events.add(button, modifiers, action, x, y, tab_id_at_x)
|
||||
drag_started = get_tab_being_dragged()[1]
|
||||
is_left_release = button == GLFW_MOUSE_BUTTON_LEFT and action == GLFW_RELEASE
|
||||
if tab_id_at_x < 0: # synthetic tab (e.g. "+" new-tab button)
|
||||
if is_left_release and not drag_started:
|
||||
set_tab_being_dragged() # clear potential drag from a press on a tab
|
||||
if self.recent_tab_bar_mouse_events.click_count(GLFW_MOUSE_BUTTON_LEFT) == 1:
|
||||
self.new_tab()
|
||||
self.recent_tab_bar_mouse_events.clear()
|
||||
return
|
||||
drag_started = get_tab_being_dragged()[1]
|
||||
if drag_started:
|
||||
return
|
||||
tab = self.tab_for_id(tab_id_at_x)
|
||||
if tab is None:
|
||||
if is_left_release:
|
||||
set_tab_being_dragged() # clear potential drag from a press on a tab
|
||||
if self.recent_tab_bar_mouse_events.click_count(GLFW_MOUSE_BUTTON_LEFT) == 2:
|
||||
self.new_tab()
|
||||
self.recent_tab_bar_mouse_events.clear()
|
||||
|
||||
@@ -551,6 +551,8 @@ def color_control(cp: ColorProfile, code: int, value: str | bytes | memoryview =
|
||||
if isinstance(value, (bytes, memoryview)):
|
||||
value = str(value, 'utf-8', 'replace')
|
||||
responses: dict[str, str] = {}
|
||||
# Only printable ASCII payload allowed as it is echoed back
|
||||
value = re.sub(r'[^ -~]', '', value)
|
||||
for rec in value.split(';'):
|
||||
key, sep, val = rec.partition('=')
|
||||
if key.startswith('transparent_background_color'):
|
||||
|
||||
@@ -5,7 +5,7 @@ from kitty.config import defaults
|
||||
from kitty.fast_data_types import BOTTOM_EDGE, LEFT_EDGE, RIGHT_EDGE, TOP_EDGE, Region
|
||||
from kitty.layout.base import layout_dimension, lgd
|
||||
from kitty.layout.interface import Grid, Horizontal, Splits, Stack, Tall
|
||||
from kitty.layout.splits import Pair
|
||||
from kitty.layout.splits import Pair, SplitsLayoutOpts
|
||||
from kitty.types import WindowGeometry
|
||||
from kitty.window import EdgeWidths
|
||||
from kitty.window_list import WindowList, reset_group_id_counter
|
||||
@@ -325,6 +325,138 @@ class TestLayout(BaseTest):
|
||||
self.assertTrue(result)
|
||||
self.ae(root.bias, root_bias_before)
|
||||
|
||||
def test_splits_equalize(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='vsplit')
|
||||
# Tree: root(H) -> w1, Pair(H) -> w2, w3
|
||||
# Proportional equalize: root.bias=1/3, inner.bias=0.5
|
||||
root = q.pairs_root
|
||||
inner = root.two if isinstance(root.two, Pair) else root.one
|
||||
self.assertIsInstance(inner, Pair)
|
||||
|
||||
# Skew biases so equalize has something to fix
|
||||
root.bias = 0.8
|
||||
inner.bias = 0.8
|
||||
|
||||
result = q.layout_action('equalize', (), all_windows)
|
||||
self.assertTrue(result)
|
||||
self.assertAlmostEqual(root.bias, 1 / 3, places=5)
|
||||
self.assertAlmostEqual(inner.bias, 0.5, places=5)
|
||||
|
||||
# Single window — equalize should still succeed
|
||||
q2 = create_layout(Splits)
|
||||
aw2 = create_windows(q2, num=0)
|
||||
q2.add_window(aw2, Window(10))
|
||||
result = q2.layout_action('equalize', (), aw2)
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_splits_equalize_mixed(self):
|
||||
# One vsplit then three hsplits, each from the freshly added window:
|
||||
# root(H) -> w1, inner1(V) -> w2, inner2(V) -> w3, inner3(V) -> w4, w5
|
||||
# Equalize should give each of w2-w5 an equal share of the right column.
|
||||
q = create_layout(Splits)
|
||||
all_windows = create_windows(q, num=0)
|
||||
q.add_window(all_windows, Window(1))
|
||||
q.add_window(all_windows, Window(2), location='vsplit')
|
||||
q.add_window(all_windows, Window(3), location='hsplit')
|
||||
q.add_window(all_windows, Window(4), location='hsplit')
|
||||
q.add_window(all_windows, Window(5), location='hsplit')
|
||||
|
||||
root = q.pairs_root
|
||||
inner1 = root.two
|
||||
self.assertIsInstance(inner1, Pair)
|
||||
inner2 = inner1.two
|
||||
self.assertIsInstance(inner2, Pair)
|
||||
inner3 = inner2.two
|
||||
self.assertIsInstance(inner3, Pair)
|
||||
|
||||
for pair in root.self_and_descendants():
|
||||
pair.bias = 0.9
|
||||
|
||||
result = q.layout_action('equalize', (), all_windows)
|
||||
self.assertTrue(result)
|
||||
self.assertAlmostEqual(root.bias, 0.5, places=5) # w1 vs right column: 1:1
|
||||
self.assertAlmostEqual(inner1.bias, 1/4, places=5) # w2 vs [w3,w4,w5]: 1:3
|
||||
self.assertAlmostEqual(inner2.bias, 1/3, places=5) # w3 vs [w4,w5]: 1:2
|
||||
self.assertAlmostEqual(inner3.bias, 0.5, places=5) # w4 vs w5: 1:1
|
||||
|
||||
def test_splits_equalize_after_remove(self):
|
||||
# 1 vsplit + 2 hsplits: root(H) -> w1, inner1(V) -> w2, inner2(V) -> w3, w4
|
||||
q = create_layout(Splits)
|
||||
all_windows = create_windows(q, num=0)
|
||||
w1, w2, w3, w4 = Window(1), Window(2), Window(3), Window(4)
|
||||
q.add_window(all_windows, w1)
|
||||
q.add_window(all_windows, w2, location='vsplit')
|
||||
q.add_window(all_windows, w3, location='hsplit')
|
||||
q.add_window(all_windows, w4, location='hsplit')
|
||||
|
||||
root = q.pairs_root
|
||||
inner1 = root.two
|
||||
inner2 = inner1.two
|
||||
self.assertIsInstance(inner1, Pair)
|
||||
self.assertIsInstance(inner2, Pair)
|
||||
|
||||
result = q.layout_action('equalize', (), all_windows)
|
||||
self.assertTrue(result)
|
||||
self.assertAlmostEqual(root.bias, 0.5, places=5) # w1 vs right column: 1:1
|
||||
self.assertAlmostEqual(inner1.bias, 1/3, places=5) # w2 vs [w3,w4]: 1:2 → RHS in thirds
|
||||
self.assertAlmostEqual(inner2.bias, 0.5, places=5) # w3 vs w4: 1:1
|
||||
|
||||
# Remove w4 — inner2 collapses: inner1.two becomes grp_w3 leaf
|
||||
g4 = all_windows.group_for_window(w4)
|
||||
q.remove_windows(g4.id)
|
||||
|
||||
self.assertNotIsInstance(inner1.two, Pair) # collapsed to a leaf
|
||||
|
||||
result = q.layout_action('equalize', (), all_windows)
|
||||
self.assertTrue(result)
|
||||
self.assertAlmostEqual(root.bias, 0.5, places=5) # w1 vs right column: 1:1
|
||||
self.assertAlmostEqual(inner1.bias, 0.5, places=5) # w2 vs w3 top/bottom: 1:1
|
||||
|
||||
def test_layout_opts_serialization(self):
|
||||
opts = SplitsLayoutOpts({})
|
||||
s = opts.serialized()
|
||||
self.ae(s, SplitsLayoutOpts(s).serialized())
|
||||
|
||||
def test_splits_equalize_on_close(self):
|
||||
q = create_layout(Splits)
|
||||
q.layout_opts = SplitsLayoutOpts({})
|
||||
q.layout_opts.equalize_on_close = True
|
||||
all_windows = create_windows(q, num=0)
|
||||
w1, w2, w3 = Window(1), Window(2), Window(3)
|
||||
q.add_window(all_windows, w1)
|
||||
q.add_window(all_windows, w2, location='vsplit')
|
||||
q.add_window(all_windows, w3, location='vsplit')
|
||||
|
||||
root = q.pairs_root
|
||||
root.bias = 0.9
|
||||
inner = root.two if isinstance(root.two, Pair) else root.one
|
||||
self.assertIsInstance(inner, Pair)
|
||||
inner.bias = 0.9
|
||||
|
||||
g3 = all_windows.group_for_window(w3)
|
||||
q.remove_windows(g3.id)
|
||||
|
||||
result = q.on_window_removed(all_windows)
|
||||
self.assertTrue(result)
|
||||
self.assertAlmostEqual(root.bias, 0.5, places=5)
|
||||
|
||||
# equalize_on_close=false (default) must not trigger equalization
|
||||
q2 = create_layout(Splits)
|
||||
aw2 = create_windows(q2, num=0)
|
||||
q2.add_window(aw2, Window(10))
|
||||
q2.add_window(aw2, Window(11), location='vsplit')
|
||||
q2.pairs_root.bias = 0.9
|
||||
result = q2.on_window_removed(aw2)
|
||||
self.assertFalse(result)
|
||||
self.assertAlmostEqual(q2.pairs_root.bias, 0.9, places=5)
|
||||
|
||||
def test_layout_dimension_no_negative_cells(self):
|
||||
# Regression test for issue #9946: when window padding exceeds the
|
||||
# available space (e.g. after maximize sets a window to minimum width),
|
||||
|
||||
@@ -79,6 +79,12 @@ func CreateAt(dirFile *os.File, name string, permissions os.FileMode) (*os.File,
|
||||
return openAt(dirFile, name, unix.O_RDWR|unix.O_CREAT|unix.O_TRUNC, permissions)
|
||||
}
|
||||
|
||||
// CreateExclusiveAt creates a file relative to the directory pointed to by
|
||||
// dirFile. Fails if a directory entry with the same name already exists.
|
||||
func CreateExclusiveAt(dirFile *os.File, name string, permissions os.FileMode) (*os.File, error) {
|
||||
return openAt(dirFile, name, unix.O_RDWR|unix.O_CREAT|unix.O_EXCL, permissions)
|
||||
}
|
||||
|
||||
// Create the specified directory, open it and return the file object. If the
|
||||
// directory already exists, it is opened and returned, without changing its
|
||||
// permissions, matching the behavior of CreateAt().
|
||||
|
||||
@@ -18,6 +18,7 @@ var _ = fmt.Print
|
||||
|
||||
type TarExtractOptions struct {
|
||||
DontPreservePermissions bool
|
||||
DontPreserveSuidAndSgid bool
|
||||
}
|
||||
|
||||
func volnamelen(path string) int {
|
||||
@@ -189,6 +190,9 @@ func ExtractAllFromTar(tr *tar.Reader, dest_path string, optss ...TarExtractOpti
|
||||
set_metadata := func(chmod func(mode fs.FileMode) error, hdr_mode int64) (err error) {
|
||||
if !opts.DontPreservePermissions && chmod != nil {
|
||||
perms := mode(hdr_mode)
|
||||
if opts.DontPreserveSuidAndSgid {
|
||||
perms = perms &^ (os.ModeSetuid | os.ModeSetgid)
|
||||
}
|
||||
if err = chmod(perms); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -21,27 +21,16 @@ import (
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
// watch_dir starts fswatcher in a background goroutine and pipes events to a custom channel.
|
||||
func watch_dirs(ctx context.Context, paths []string, debounce time.Duration, eventChan chan<- fswatcher.WatchEvent) error {
|
||||
opts := []fswatcher.WatcherOpt{
|
||||
fswatcher.WithCooldown(debounce),
|
||||
// get_parent_dirs returns a deduplicated list of the immediate parent directory for each path.
|
||||
// Unlike get_unique_directories it does not filter out subdirectories, so every unique
|
||||
// parent is returned even when some are descendants of others. This is the correct
|
||||
// set of directories to pass to a non-recursive (top-level) file-system watcher.
|
||||
func get_parent_dirs(paths []string) []string {
|
||||
dirSet := utils.NewSet[string](len(paths))
|
||||
for _, p := range paths {
|
||||
dirSet.Add(filepath.Dir(p))
|
||||
}
|
||||
for _, path := range paths {
|
||||
if unix.Access(path, unix.R_OK|unix.X_OK) == nil {
|
||||
opts = append(opts, fswatcher.WithPath(path))
|
||||
}
|
||||
}
|
||||
w, err := fswatcher.New(opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go w.Watch(ctx)
|
||||
go func() {
|
||||
for event := range w.Events() {
|
||||
eventChan <- event
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
return dirSet.AsSlice()
|
||||
}
|
||||
|
||||
// returns the closest unique parent directories for a list of paths.
|
||||
@@ -104,6 +93,10 @@ func safe_eval_symlinks(path string) string {
|
||||
func get_set_of_config_files(config_paths []string) *utils.Set[string] {
|
||||
cp := config.ConfigParser{
|
||||
AllIncludedFiles: utils.NewSet[string](), LineHandler: func(k, v string) error { return nil }}
|
||||
config_paths = utils.Filter(config_paths, func(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
})
|
||||
cp.ParseFiles(config_paths...)
|
||||
// Resolve symlinks in all paths collected by the parser (important on macOS
|
||||
// where /tmp -> /private/tmp causes mismatches with FSEvents-reported paths).
|
||||
@@ -122,33 +115,91 @@ func get_set_of_config_files(config_paths []string) *utils.Set[string] {
|
||||
return result
|
||||
}
|
||||
|
||||
// watch_for_config_changes watches the directories derived from config_paths and calls action
|
||||
// whenever a watched config file (including includes and auto color scheme files) changes.
|
||||
// watch_for_config_changes watches the parent directories of every conf file (main configs,
|
||||
// includes, and auto color-scheme files) and calls action whenever one of those files changes.
|
||||
// Watching is non-recursive (top-level only): only the immediate parent directories are added.
|
||||
// When a conf file change is detected the full set of conf files is re-scanned so that newly
|
||||
// added or removed include directives are reflected in the watched-directory set.
|
||||
// It runs until ctx is cancelled.
|
||||
func watch_for_config_changes(ctx context.Context, action func() error, debounce_time time.Duration, config_paths []string) error {
|
||||
event_chan := make(chan fswatcher.WatchEvent)
|
||||
|
||||
all_paths := get_set_of_config_files(config_paths)
|
||||
dirs_to_watch := get_unique_directories(all_paths.AsSlice())
|
||||
if len(dirs_to_watch) == 0 {
|
||||
|
||||
// desired_dirs is the full set of parent directories we want to watch
|
||||
// (one per conf file, including files that may not yet exist).
|
||||
desired_dirs := utils.NewSet[string]()
|
||||
for _, p := range all_paths.AsSlice() {
|
||||
desired_dirs.Add(filepath.Dir(p))
|
||||
}
|
||||
if desired_dirs.Len() == 0 {
|
||||
return fmt.Errorf("No directories to watch provided")
|
||||
}
|
||||
|
||||
filtered_action := func(ev fswatcher.WatchEvent) error {
|
||||
all_paths := get_set_of_config_files(config_paths)
|
||||
if all_paths.Has(resolve_path(ev.Path)) {
|
||||
return action()
|
||||
// Create the watcher with top-level (non-recursive) depth.
|
||||
opts := []fswatcher.WatcherOpt{fswatcher.WithCooldown(debounce_time)}
|
||||
watched_dirs := utils.NewSet[string]()
|
||||
for _, dir := range desired_dirs.AsSlice() {
|
||||
if unix.Access(dir, unix.R_OK|unix.X_OK) == nil {
|
||||
opts = append(opts, fswatcher.WithPath(dir, fswatcher.WithDepth(fswatcher.WatchTopLevel)))
|
||||
watched_dirs.Add(dir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if watched_dirs.Len() == 0 {
|
||||
return fmt.Errorf("No directories to watch provided")
|
||||
}
|
||||
|
||||
if err := watch_dirs(ctx, dirs_to_watch, debounce_time, event_chan); err != nil {
|
||||
w, err := fswatcher.New(opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go w.Watch(ctx)
|
||||
go func() {
|
||||
for event := range w.Events() {
|
||||
event_chan <- event
|
||||
}
|
||||
}()
|
||||
|
||||
// sync_watched_dirs reconciles watched_dirs with desired_dirs: any desired directory
|
||||
// that now exists is added to the watcher, and any watched directory that is no longer
|
||||
// desired is dropped.
|
||||
sync_watched_dirs := func() {
|
||||
desired_dirs.ForEach(func(d string) {
|
||||
if !watched_dirs.Has(d) && unix.Access(d, unix.R_OK|unix.X_OK) == nil {
|
||||
if err := w.AddPath(d, fswatcher.WithDepth(fswatcher.WatchTopLevel)); err == nil {
|
||||
watched_dirs.Add(d)
|
||||
}
|
||||
}
|
||||
})
|
||||
watched_dirs.ForEach(func(d string) {
|
||||
if !desired_dirs.Has(d) {
|
||||
_ = w.DropPath(d)
|
||||
watched_dirs.Discard(d)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case event := <-event_chan:
|
||||
if err := filtered_action(event); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to signal kitty in event: %s with error: %s\n", event, err)
|
||||
// On every event try to activate any desired directories that may have been
|
||||
// created since the last check (e.g. a new include directory was mkdir'd).
|
||||
sync_watched_dirs()
|
||||
|
||||
new_all_paths := get_set_of_config_files(config_paths)
|
||||
if new_all_paths.Has(resolve_path(event.Path)) {
|
||||
// A conf file changed: rebuild desired_dirs from the new include set and
|
||||
// sync the watcher so new include directories are watched and stale ones dropped.
|
||||
new_desired := utils.NewSet[string]()
|
||||
for _, p := range new_all_paths.AsSlice() {
|
||||
new_desired.Add(filepath.Dir(p))
|
||||
}
|
||||
desired_dirs = new_desired
|
||||
sync_watched_dirs()
|
||||
|
||||
if err := action(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to signal kitty in event: %s with error: %s\n", event, err)
|
||||
}
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
|
||||
@@ -15,6 +15,30 @@ import (
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
// prime_watcher repeatedly writes to path until the watcher delivers an event and
|
||||
// the action fires, confirming the watcher goroutine is ready. A single write may
|
||||
// race the watcher's initialisation so retrying ensures at least one write lands
|
||||
// while the watcher is active. After success it waits one full debounce period
|
||||
// so the cooldown window is clear before returning.
|
||||
// Returns the current action count after settling.
|
||||
func prime_watcher(t *testing.T, path string, counter *atomic.Int32, debounce time.Duration) int32 {
|
||||
t.Helper()
|
||||
before := counter.Load()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
n := 0
|
||||
for time.Now().Before(deadline) {
|
||||
write_file(t, path, fmt.Sprintf("# prime %d\n", n))
|
||||
n++
|
||||
if wait_for_count(counter, before+1, debounce+100*time.Millisecond) > before {
|
||||
// Action fired — clear the debounce window before returning.
|
||||
time.Sleep(debounce + 20*time.Millisecond)
|
||||
return counter.Load()
|
||||
}
|
||||
}
|
||||
t.Fatal("watcher failed to become ready: no action fired within 5 seconds of repeated prime writes")
|
||||
return 0
|
||||
}
|
||||
|
||||
func write_file(t *testing.T, path string, data string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||
@@ -22,6 +46,68 @@ func write_file(t *testing.T, path string, data string) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetParentDirs(t *testing.T) {
|
||||
type tc struct {
|
||||
name string
|
||||
input []string
|
||||
expect []string
|
||||
}
|
||||
cases := []tc{
|
||||
{
|
||||
name: "nil input",
|
||||
input: nil,
|
||||
expect: nil,
|
||||
},
|
||||
{
|
||||
name: "single file",
|
||||
input: []string{"/a/b/file.conf"},
|
||||
expect: []string{"/a/b"},
|
||||
},
|
||||
{
|
||||
name: "files in same directory",
|
||||
input: []string{"/a/b/file1.conf", "/a/b/file2.conf"},
|
||||
expect: []string{"/a/b"},
|
||||
},
|
||||
{
|
||||
name: "sibling directories",
|
||||
input: []string{"/a/b/file.conf", "/a/c/file.conf"},
|
||||
expect: []string{"/a/b", "/a/c"},
|
||||
},
|
||||
{
|
||||
name: "parent and child directory both returned",
|
||||
// Unlike get_unique_directories, subdirectories are NOT filtered out.
|
||||
input: []string{"/a/file.conf", "/a/b/file.conf"},
|
||||
expect: []string{"/a", "/a/b"},
|
||||
},
|
||||
{
|
||||
name: "deeply nested all returned",
|
||||
input: []string{
|
||||
"/a/file.conf",
|
||||
"/a/b/file.conf",
|
||||
"/a/b/c/file.conf",
|
||||
},
|
||||
expect: []string{"/a", "/a/b", "/a/b/c"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := get_parent_dirs(tc.input)
|
||||
sort.Strings(result)
|
||||
sort.Strings(tc.expect)
|
||||
if tc.expect == nil {
|
||||
if len(result) != 0 {
|
||||
t.Fatalf("expected empty result, got %v", result)
|
||||
}
|
||||
return
|
||||
}
|
||||
if diff := cmp.Diff(tc.expect, result); diff != "" {
|
||||
t.Fatalf("get_parent_dirs mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUniqueDirectories(t *testing.T) {
|
||||
// Empty input
|
||||
if result := get_unique_directories(nil); result != nil {
|
||||
@@ -135,22 +221,35 @@ func wait_for_count(counter *atomic.Int32, target int32, timeout time.Duration)
|
||||
return counter.Load()
|
||||
}
|
||||
|
||||
// TestWatchForConfigChanges consolidates all watcher integration tests into a single
|
||||
// function that starts the watcher once and confirms readiness via prime_watcher
|
||||
// instead of a blind time.Sleep. Include-watching scenarios (file changes, include
|
||||
// added/removed from main config, include added to an already-included file) run as
|
||||
// sequential subtests.
|
||||
func TestWatchForConfigChanges(t *testing.T) {
|
||||
tdir := t.TempDir()
|
||||
tdir := resolve_path(t.TempDir())
|
||||
subdir := filepath.Join(tdir, "sub")
|
||||
if err := os.Mkdir(subdir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
extradir := filepath.Join(tdir, "extra")
|
||||
extra2dir := filepath.Join(tdir, "extra2")
|
||||
for _, d := range []string{subdir, extradir, extra2dir} {
|
||||
if err := os.Mkdir(d, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
main_conf := filepath.Join(tdir, "kitty.conf")
|
||||
included_conf := filepath.Join(subdir, "included.conf")
|
||||
dark_theme := filepath.Join(tdir, "dark-theme.auto.conf")
|
||||
unrelated := filepath.Join(tdir, "unrelated.txt")
|
||||
extra_conf := filepath.Join(extradir, "custom.conf")
|
||||
extra2_conf := filepath.Join(extra2dir, "another.conf")
|
||||
|
||||
write_file(t, main_conf, "include sub/included.conf\n")
|
||||
write_file(t, included_conf, "background black\n")
|
||||
write_file(t, dark_theme, "background #000000\n")
|
||||
write_file(t, unrelated, "this file should not trigger action\n")
|
||||
write_file(t, extra_conf, "background black\n")
|
||||
write_file(t, extra2_conf, "background black\n")
|
||||
|
||||
var action_count atomic.Int32
|
||||
action := func() error {
|
||||
@@ -167,47 +266,102 @@ func TestWatchForConfigChanges(t *testing.T) {
|
||||
done <- watch_for_config_changes(ctx, action, debounce, []string{main_conf})
|
||||
}()
|
||||
|
||||
// Give the watcher time to start
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
// Confirm the watcher is ready by writing to the dark-theme auto conf (an
|
||||
// always-watched file that does not affect the include graph) and waiting for
|
||||
// an action to fire. This replaces the blind time.Sleep used previously.
|
||||
prime_watcher(t, dark_theme, &action_count, debounce)
|
||||
|
||||
t.Run("main config change triggers action", func(t *testing.T) {
|
||||
before := action_count.Load()
|
||||
write_file(t, main_conf, "include sub/included.conf\nfont_size 13\n")
|
||||
count := wait_for_count(&action_count, before+1, 2*time.Second)
|
||||
if count <= before {
|
||||
t.Fatalf("Expected action to be called after main config change, count=%d", count)
|
||||
if wait_for_count(&action_count, before+1, 2*time.Second) <= before {
|
||||
t.Fatalf("Expected action to be called after main config change, count=%d", action_count.Load())
|
||||
}
|
||||
})
|
||||
time.Sleep(debounce + 20*time.Millisecond)
|
||||
|
||||
t.Run("included file change triggers action", func(t *testing.T) {
|
||||
before := action_count.Load()
|
||||
write_file(t, included_conf, "background white\n")
|
||||
count := wait_for_count(&action_count, before+1, 2*time.Second)
|
||||
if count <= before {
|
||||
t.Fatalf("Expected action to be called after included file change, count=%d", count)
|
||||
if wait_for_count(&action_count, before+1, 2*time.Second) <= before {
|
||||
t.Fatalf("Expected action to be called after included file change, count=%d", action_count.Load())
|
||||
}
|
||||
})
|
||||
time.Sleep(debounce + 20*time.Millisecond)
|
||||
|
||||
t.Run("auto color scheme file change triggers action", func(t *testing.T) {
|
||||
before := action_count.Load()
|
||||
write_file(t, dark_theme, "background #111111\n")
|
||||
count := wait_for_count(&action_count, before+1, 2*time.Second)
|
||||
if count <= before {
|
||||
t.Fatalf("Expected action to be called after dark-theme.auto.conf change, count=%d", count)
|
||||
if wait_for_count(&action_count, before+1, 2*time.Second) <= before {
|
||||
t.Fatalf("Expected action to be called after dark-theme.auto.conf change, count=%d", action_count.Load())
|
||||
}
|
||||
})
|
||||
time.Sleep(debounce + 20*time.Millisecond)
|
||||
|
||||
t.Run("unrelated file change does not trigger action", func(t *testing.T) {
|
||||
before := action_count.Load()
|
||||
write_file(t, unrelated, "still unrelated\n")
|
||||
// Wait debounce + a bit more to ensure no spurious call
|
||||
time.Sleep(debounce + 200*time.Millisecond)
|
||||
after := action_count.Load()
|
||||
if after != before {
|
||||
if after := action_count.Load(); after != before {
|
||||
t.Fatalf("Expected action NOT to be called for unrelated file, count went from %d to %d", before, after)
|
||||
}
|
||||
})
|
||||
|
||||
// include added to main config: extradir must become watched.
|
||||
// sync_watched_dirs() runs before action() in the event loop, so by the time
|
||||
// wait_for_count returns the new directory is already registered.
|
||||
t.Run("include added to main config is watched", func(t *testing.T) {
|
||||
before := action_count.Load()
|
||||
write_file(t, main_conf, "include sub/included.conf\ninclude extra/custom.conf\n")
|
||||
if wait_for_count(&action_count, before+1, 2*time.Second) <= before {
|
||||
t.Fatalf("Expected action after kitty.conf gained include directive")
|
||||
}
|
||||
// Let the debounce window clear before writing to the newly watched file.
|
||||
time.Sleep(debounce + 20*time.Millisecond)
|
||||
before = action_count.Load()
|
||||
write_file(t, extra_conf, "background white\n")
|
||||
if wait_for_count(&action_count, before+1, 2*time.Second) <= before {
|
||||
t.Fatalf("Expected action after modifying newly included file")
|
||||
}
|
||||
})
|
||||
time.Sleep(debounce + 20*time.Millisecond)
|
||||
|
||||
// include added to an already-included file: extra2dir must become watched.
|
||||
t.Run("include added to already-included file adds its parent dir", func(t *testing.T) {
|
||||
// Add an include to sub/included.conf that points into extra2dir, which is
|
||||
// not currently watched. The watcher re-scans the full include graph on
|
||||
// every conf-file change, so extra2dir must be added to the watch set.
|
||||
before := action_count.Load()
|
||||
write_file(t, included_conf, "include ../extra2/another.conf\n")
|
||||
if wait_for_count(&action_count, before+1, 2*time.Second) <= before {
|
||||
t.Fatalf("Expected action after sub/included.conf gained include directive")
|
||||
}
|
||||
time.Sleep(debounce + 20*time.Millisecond)
|
||||
// extra2dir is now watched; a change to extra2/another.conf must fire.
|
||||
before = action_count.Load()
|
||||
write_file(t, extra2_conf, "background blue\n")
|
||||
if wait_for_count(&action_count, before+1, 2*time.Second) <= before {
|
||||
t.Fatalf("Expected action after modifying file included from an already-included conf file")
|
||||
}
|
||||
})
|
||||
time.Sleep(debounce + 20*time.Millisecond)
|
||||
|
||||
// include removed from main config: extradir must be dropped from the watch set.
|
||||
t.Run("include removed from main config is no longer watched", func(t *testing.T) {
|
||||
before := action_count.Load()
|
||||
write_file(t, main_conf, "include sub/included.conf\n")
|
||||
if wait_for_count(&action_count, before+1, 2*time.Second) <= before {
|
||||
t.Fatalf("Expected action after kitty.conf lost include directive")
|
||||
}
|
||||
time.Sleep(debounce + 20*time.Millisecond)
|
||||
before = action_count.Load()
|
||||
write_file(t, extra_conf, "background green\n")
|
||||
time.Sleep(debounce + 200*time.Millisecond)
|
||||
if after := action_count.Load(); after != before {
|
||||
t.Fatalf("Expected NO action after modifying removed-include file, count went from %d to %d", before, after)
|
||||
}
|
||||
})
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
@@ -239,29 +393,31 @@ func TestWatchForConfigChangesDebounce(t *testing.T) {
|
||||
done <- watch_for_config_changes(ctx, action, debounce, []string{main_conf})
|
||||
}()
|
||||
|
||||
// Give the watcher time to start
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
// Confirm the watcher is ready before sending the burst.
|
||||
prime_watcher(t, main_conf, &action_count, debounce)
|
||||
|
||||
// Write to the file several times rapidly within the debounce window.
|
||||
// The fswatcher debouncer drops events that occur within the cooldown period
|
||||
// after the first event, so only the first write should produce an action call.
|
||||
before_burst := action_count.Load()
|
||||
for i := range 5 {
|
||||
write_file(t, main_conf, fmt.Sprintf("font_size %d\n", 12+i))
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Wait for up to one full debounce period for the first action to fire
|
||||
count_after_burst := wait_for_count(&action_count, 1, debounce+500*time.Millisecond)
|
||||
// Wait for up to one full debounce period for the first action to fire.
|
||||
count_after_burst := wait_for_count(&action_count, before_burst+1, debounce+500*time.Millisecond)
|
||||
action_calls_in_burst := count_after_burst - before_burst
|
||||
|
||||
// Debouncing should have collapsed the burst into at most 2 calls.
|
||||
// The fswatcher debouncer uses leading-edge logic: the first event fires
|
||||
// immediately and subsequent events within the cooldown window are dropped.
|
||||
// A trailing event may fire at the end of the cooldown window, giving at most 2.
|
||||
if count_after_burst == 0 {
|
||||
if action_calls_in_burst == 0 {
|
||||
t.Fatalf("Expected at least one action call after burst of writes, got 0")
|
||||
}
|
||||
if count_after_burst > 2 {
|
||||
t.Fatalf("Expected debouncing to collapse burst: want ≤2 calls, got %d", count_after_burst)
|
||||
if action_calls_in_burst > 2 {
|
||||
t.Fatalf("Expected debouncing to collapse burst: want ≤2 calls, got %d", action_calls_in_burst)
|
||||
}
|
||||
|
||||
// After waiting well past the debounce window, a new write should trigger exactly one more call.
|
||||
|
||||
Reference in New Issue
Block a user