Compare commits

...

69 Commits

Author SHA1 Message Date
Kovid Goyal
b15e047a47 version 0.47.3 2026-06-12 09:57:55 +05:30
Kovid Goyal
8be4ea67ac Mark dnd protocol as stable
No significant feedback received post release of protocol in 0.47.0
almost a month ago.

Protocol can now only change in backward compatible ways, barring
security issues.

Fixes #9984
2026-06-12 09:50:41 +05:30
Kovid Goyal
a0457f6141 Update changelog 2026-06-12 09:47:35 +05:30
Kovid Goyal
cb1e64ffc5 Merge branch 'fix-tabs-mouse-handling-stuck' of https://github.com/ttys3/kitty 2026-06-12 09:46:23 +05:30
Kovid Goyal
d5f69fe853 Update openssl for CVEs 2026-06-12 07:29:08 +05:30
Kovid Goyal
8651e1fac5 Update changelog 2026-06-12 07:08:29 +05:30
Kovid Goyal
cf630f1699 ... 2026-06-11 06:00:16 +05:30
Kovid Goyal
4c3b96ca40 Update changelog 2026-06-10 19:02:54 +05:30
Kovid Goyal
8f2ad60f96 Merge branch 'fix/choose-fonts-color-race' of https://github.com/noctilust/kitty 2026-06-10 19:02:09 +05:30
Forrest
83677a2235 fix(choose-fonts): guard preview rendering against partial terminal color query
The choose-fonts kitten queries the terminal for the user's foreground and
background colors at startup by sending a DCS request. The terminal responds
with separate DCS response strings — one per field — which arrive
asynchronously through on_query_response.

The background handler unconditionally called draw_screen() after storing its
value. If the terminal's DCS response for "background" arrived before the
response for "foreground", draw_screen() fired with text_style.Foreground
still at its Go zero value ("").

The font list preview (list.go) only guarded against empty Background, not
empty Foreground, so it passed the check and sent a render_family_samples
command to the Python backend with foreground="". The backend's to_color('')
raised ValueError: Invalid color name: '', crashing the kitten.

The faces pane (faces.go) launched a render_family_samples goroutine
unconditionally when its preview cache was empty, with no guard at all.

Fix
---
1. ui.go — Only trigger draw_screen() from the foreground or background
   handler when the counterpart field is already populated, so the UI
   never renders with a partial TextStyle.

2. list.go — Require both Foreground and Background to be non-empty
   before rendering the font preview, not just Background alone.

3. faces.go — Skip the render_family_samples goroutine if either color
   field is still empty. The preview cache stays empty, so the next
   redraw (triggered when the counterpart query response arrives, or
   by on_wakeup) retries with full colors.
2026-06-10 02:18:18 -07:00
Kovid Goyal
901b6bd1ed Add kmv to integrations 2026-06-10 12:54:45 +05:30
Kovid Goyal
9b4c0281a2 Update changelog 2026-06-10 09:33:45 +05:30
ttyS3
dc36e21654 fix(tabs): mouse handling stuck after aborted tab drag on Wayland
A quick click-and-flick on a tab could leave all of kitty with mouse
input permanently redirected to the tab bar, making every window
unclickable and text selection impossible.

Starting a tab drag is asynchronous: the drag thumbnail is rendered on
the next frame before glfwStartDrag is called. If the button is
released in that window, wl_data_device_start_drag is sent with a stale
serial that no longer matches an active pointer implicit grab, so the
compositor silently ignores it. The wl_data_source then never receives
any event, on_drag_source_finished never runs, and the
tab_being_dragged state is stuck forever, hijacking all mouse events.

Fix in layers:
- glfw/Wayland: track the implicit grab (serial of the first button
  press and pressed-button count), use that serial for start_drag and
  refuse with EAGAIN when there is no active implicit grab instead of
  letting the compositor silently drop the request
- mouse.c: a left button release arriving while a tab drag is marked
  started but no system DND is active means the drag never launched
  (an active DND consumes the release on all platforms), so clear the
  drag state instead of waiting for DND events that will never come
- tabs.py: handle OSError from start_drag_with_data for tab drags the
  same way window drags already do; clear the potential-drag state when
  the release lands on the new-tab button or empty tab bar area
- tabs.py/boss.py: clear drag state on drag finish/drop even when the
  dragged tab has already been closed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 03:43:25 +00:00
Kovid Goyal
dbf52d4870 Mark matrix as not required in the fontconfig typeddict as the C code adds it only when the pattern contains a matrix 2026-06-10 08:32:14 +05:30
Kovid Goyal
949a1e1fb1 Merge branch 'fix/focus-follows-mouse-desktop-switch-v2' of https://github.com/lxcode/kitty 2026-06-10 08:05:57 +05:30
Kovid Goyal
11c2ccf00f Add tests for splits layout serialization 2026-06-10 07:41:02 +05:30
Kovid Goyal
238573e799 Fix splits layout options serialization
Fixes #10124
2026-06-10 07:34:47 +05:30
Kovid Goyal
c126e227d3 Bump dep version 2026-06-09 06:23:34 +05:30
Kovid Goyal
424fe9991b Sanitise color control responses for shells that still dont use the kitty keyboard protocol 2026-06-09 06:21:15 +05:30
David Thiel
1cc1a445e3 Fix focus_follows_mouse switching active window on desktop/space return
When focus_follows_mouse is enabled, returning to a desktop/space fired an
enter event that switched the active window to whichever one was under the
cursor, even though the mouse had not crossed a window boundary.

Distinguish genuine mouse motion into a window from a window reappearing
under a stationary cursor by checking, in cursor_enter_callback, whether the
cursor position actually changed. focus_follows_mouse now switches focus only
when the cursor moved, so motion into a window still switches focus while a
stationary reappearance does not.
2026-06-08 14:46:40 +01:00
Kovid Goyal
79a768ed55 Merge branch 'followup-b-cairo-matrix' of https://github.com/Strykar/kitty 2026-06-08 12:26:06 +05:30
Kovid Goyal
55425e2e75 Show secure input indicator on active tab 2026-06-08 11:22:54 +05:30
Kovid Goyal
4ca6a20c7d Merge branch 'dependabot/github_actions/actions-e6ee9d7de3' of https://github.com/kovidgoyal/kitty 2026-06-08 09:41:39 +05:30
Kovid Goyal
07ccc19533 Merge branch 'dependabot/go_modules/all-go-deps-44a001e8d2' of https://github.com/kovidgoyal/kitty 2026-06-08 09:41:18 +05:30
dependabot[bot]
d9334a6149 Bump actions/checkout from 6 to 6.0.2 in the actions group
Bumps the actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 6.0.2
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v6.0.2)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 03:44:39 +00:00
dependabot[bot]
60c31ba2ab Bump the all-go-deps group with 2 updates
Bumps the all-go-deps group with 2 updates: [github.com/alecthomas/chroma/v2](https://github.com/alecthomas/chroma) and [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil).


Updates `github.com/alecthomas/chroma/v2` from 2.24.1 to 2.26.1
- [Release notes](https://github.com/alecthomas/chroma/releases)
- [Commits](https://github.com/alecthomas/chroma/compare/v2.24.1...v2.26.1)

Updates `github.com/shirou/gopsutil/v4` from 4.26.4 to 4.26.5
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.26.4...v4.26.5)

---
updated-dependencies:
- dependency-name: github.com/alecthomas/chroma/v2
  dependency-version: 2.26.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-go-deps
- dependency-name: github.com/shirou/gopsutil/v4
  dependency-version: 4.26.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-go-deps
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 03:42:55 +00:00
Kovid Goyal
d20fe4d4b5 version 0.47.2 2026-06-07 09:37:24 +05:30
Kovid Goyal
314899b9aa Update changelog 2026-06-07 09:22:28 +05:30
Kovid Goyal
45bd3a0f14 Add a note about interaction of --reload-in and auto reload 2026-06-07 08:55:48 +05:30
Kovid Goyal
470a70db57 Remind user to not transfer files from machines they dont trust 2026-06-07 06:52:27 +05:30
Kovid Goyal
72c1ff6085 Ignore inapplicable CVE 2026-06-06 18:53:13 +05:30
Kovid Goyal
40ed8cfd3c fontconfig: ignore failure to return FC_INDEX assume its zero
Sigh. The number of creative ways Linux systems can be broken.
See #10112
2026-06-06 18:46:15 +05:30
Kovid Goyal
6839281277 Fix #10113 2026-06-06 18:36:29 +05:30
Kovid Goyal
2d9e243847 Ignore inappplicable CVE 2026-06-06 06:12:03 +05:30
Kovid Goyal
522555a5b6 ... 2026-06-05 06:31:08 +05:30
Kovid Goyal
b2d70c899d Merge branch 'master' of https://github.com/Mobinshahidi/kitty 2026-06-04 21:12:59 +05:30
Mobin shahidi
3bd18be320 docs: add kitty-search to integrations 2026-06-04 18:56:41 +03:30
Kovid Goyal
8996aa798c dnd kitten: Create regular files with O_EXCL to avoid symlink attacks
This is not really needed as the terminal emulator should be de
duplicating directory entries anyway but no harm in defense in depth.
2026-06-03 12:17:38 +05:30
Kovid Goyal
4aa4a5c056 File transfer protocol: use O_NOFOLLOW when opening regular files 2026-06-03 06:18:31 +05:30
Kovid Goyal
9b89031a7f Bump Go toolchain version for CVEs 2026-06-03 05:58:36 +05:30
Kovid Goyal
e6e5524f67 diff kitten: Strip suid/sgid bits from extracted files 2026-06-03 05:45:04 +05:30
Kovid Goyal
cb0f05c4e4 Fix parsing of config set aborting on non-existent files 2026-06-02 21:19:04 +05:30
Kovid Goyal
54ecc67339 Update changelog
Fixes #10102
2026-06-02 21:08:28 +05:30
Kovid Goyal
3684838188 Merge branch 'copilot/modify-watch-for-config-changes' of https://github.com/kovidgoyal/kitty 2026-06-02 21:06:58 +05:30
copilot-swe-agent[bot]
bff5af7052 address review: fix comment wording and rename burst_actions variable 2026-06-02 12:46:10 +00:00
copilot-swe-agent[bot]
d80fd1c23d consolidate watch tests and add include-in-included-file test
- Merge TestWatchForConfigChangesIncludeAdded and
  TestWatchForConfigChangesIncludeRemoved into the main
  TestWatchForConfigChanges function, which now starts the watcher
  once and shares it across all integration subtests.
- Add prime_watcher helper that retries writes until an action fires,
  replacing the blind time.Sleep(200ms) watcher-startup wait.
- Add new subtest "include added to already-included file adds its
  parent dir": writes an include directive into sub/included.conf
  (itself included from kitty.conf), then verifies that changes to
  the newly referenced file trigger the action, confirming its parent
  directory was added to the watch set.
- Fix TestWatchForConfigChangesDebounce to use prime_watcher instead
  of time.Sleep for startup; capture before_burst baseline before the
  burst loop so the burst-action count is computed correctly.
2026-06-02 12:45:00 +00:00
copilot-swe-agent[bot]
7e96373515 watch: non-recursive config file watching with dynamic include tracking 2026-06-02 08:23:05 +00:00
Kovid Goyal
6c586934f4 ... 2026-06-02 13:31:14 +05:30
Kovid Goyal
4043e99b75 Merge branch 'copilot/show-secure-input-in-menubar' of https://github.com/kovidgoyal/kitty 2026-06-01 13:11:30 +05:30
Kovid Goyal
26d255b27d Merge branch 'dependabot/github_actions/actions-754f0868f5' of https://github.com/kovidgoyal/kitty 2026-06-01 13:04:24 +05:30
Kovid Goyal
905e1b77d1 Merge branch 'dependabot/go_modules/all-go-deps-c4d8eed042' of https://github.com/kovidgoyal/kitty 2026-06-01 13:01:52 +05:30
dependabot[bot]
de8870da47 Bump github/codeql-action from 4.35.5 to 4.36.0 in the actions group
Bumps the actions group with 1 update: [github/codeql-action](https://github.com/github/codeql-action).


Updates `github/codeql-action` from 4.35.5 to 4.36.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4.35.5...v4.36.0)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-01 06:48:15 +00:00
dependabot[bot]
73ac7b738f Bump the all-go-deps group with 3 updates
Bumps the all-go-deps group with 3 updates: [github.com/ebitengine/purego](https://github.com/ebitengine/purego), [github.com/sgtdi/fswatcher](https://github.com/sgtdi/fswatcher) and [golang.org/x/sys](https://github.com/golang/sys).


Updates `github.com/ebitengine/purego` from 0.10.0 to 0.10.1
- [Release notes](https://github.com/ebitengine/purego/releases)
- [Commits](https://github.com/ebitengine/purego/compare/v0.10.0...v0.10.1)

Updates `github.com/sgtdi/fswatcher` from 1.2.0 to 1.3.0
- [Release notes](https://github.com/sgtdi/fswatcher/releases)
- [Commits](https://github.com/sgtdi/fswatcher/compare/v1.2.0...v1.3.0)

Updates `golang.org/x/sys` from 0.44.0 to 0.45.0
- [Commits](https://github.com/golang/sys/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: github.com/ebitengine/purego
  dependency-version: 0.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-go-deps
- dependency-name: github.com/sgtdi/fswatcher
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-go-deps
- dependency-name: golang.org/x/sys
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-go-deps
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-01 06:40:37 +00:00
Kovid Goyal
385d90c427 Fix matching of var/env against tabs
Fixes #10095
2026-06-01 11:49:45 +05:30
Kovid Goyal
9c7b3d778a Switch to match/case 2026-06-01 11:47:14 +05:30
copilot-swe-agent[bot]
f388fdabdc Show [Secure input] indicator in macOS global menubar title area when secure keyboard entry is enabled 2026-06-01 00:57:43 +00:00
Kovid Goyal
ce4defcff4 Merge branch 'wayland-titlebar-crash' of https://github.com/cwelsys/kitty 2026-05-31 11:03:13 +05:30
Connor Welsh
fb4d05f7e8 Wayland: Fix crash hiding titlebar on layer-shell windows
inform_compositor_of_window_geometry() calls xdg_surface_set_window_geometry()
unconditionally, but layer-shell surfaces (e.g. those from `kitten panel`) have
no xdg_surface. With hide_window_decorations set to titlebar-only, the panel hits
this geometry call during creation and dereferences the NULL proxy, crashing in
wl_proxy_get_version() with SIGSEGV.

Guard on window->wl.xdg.surface: layer-shell surfaces manage geometry via the
layer surface, so the xdg call is skipped. Normal toplevels are unaffected.
2026-05-30 16:14:55 -04:00
Kovid Goyal
6bd62a5242 Merge branch 'master' of https://github.com/codeasone/kitty 2026-05-30 07:35:10 +05:30
Kovid Goyal
4aa0cd6215 bump go image mod version for CVE 2026-05-30 05:17:19 +05:30
Mark Stuart
852fc4a662 Add equalize action and equalize_on_close option to Splits layout
Adds an `equalize` layout action that redistributes split sizes so each
window receives a proportional share of space along each axis.

Also adds an `equalize_on_close` layout option that automatically
equalizes splits whenever a window is closed, keeping the remaining
windows balanced without requiring an explicit key binding.

These two features compose well. For example, to keep splits balanced
at all times - equalizing on every open and close:

    enabled_layouts splits:equalize_on_close=true
    map ctrl+' combine : launch --location=hsplit --cwd=current : layout_action equalize
    map ctrl+/ combine : launch --location=vsplit --cwd=current : layout_action equalize

A standalone key binding for manual rebalancing is also supported:

    map ctrl+shift+e layout_action equalize
2026-05-29 20:02:42 +01:00
Kovid Goyal
2b7d8af55a Fix #10089 2026-05-29 14:55:14 +05:30
Kovid Goyal
9ffabc7caf ... 2026-05-29 14:52:30 +05:30
Kovid Goyal
52b44f4f1a Ensure we dont double copy 2026-05-29 07:52:38 +05:30
Kovid Goyal
d6b662e706 macOS: Fix regression in 0.47.0 that broke passing Cmd+C on to terminal applications when no text is selected
Fixes #10087
2026-05-29 07:47:29 +05:30
Kovid Goyal
74b80e9a29 Update changelog 2026-05-29 07:14:28 +05:30
Kovid Goyal
a6aa51e823 Merge branch 'master' of https://github.com/pakhromov/kitty 2026-05-29 07:13:17 +05:30
Pavel
0fedf69d87 scrollbar: start drag after track click when jump_on_click is enabled
When scrollbar_jump_on_click is on and the user clicks the track, immediately
jump to the clicked position and begin a drag so holding the button and
moving the mouse continues scrolling. Matches the behavior of Firefox and
GTK apps.
2026-05-28 21:19:10 +02:00
Strykar
ee937bdd1b freetype: route FC_MATRIX through cairo for color glyphs
Color glyphs (COLR/CBDT/SVG) go through cairo on a second FT_Face,
self->face_for_cairo, opened in ensure_cairo_resources. That face
never receives the FT_Set_Transform installed on self->face in
face_from_descriptor (#9990): cairo owns FT_Set_Transform on its own
face and derives it from the font matrix on every render
(_cairo_ft_unscaled_font_set_scale in cairo-ft-font.c). The only
channel that reaches color-glyph rasterization is the cairo font
matrix, not the FT face transform, so FC_MATRIX is silently dropped on
that path. Stock fontconfig rules do not apply FC_MATRIX to color
fonts, so this is a hand-built config edge case.

Factor the two cairo_set_font_size() call sites in set_cairo_font_size
and fit_cairo_glyph into apply_cairo_font_size(), which calls
cairo_set_font_matrix() with size * FC_MATRIX when has_matrix is set,
and falls through to cairo_set_font_size() otherwise. The non-matrix
path is bit-identical to before. For pure shears (xx=1, yy=1)
cairo_scaled_font_glyph_extents reads the post-shear bbox so the
shrink loop bounds the destination correctly; the user-visible effect
is that a sheared color glyph reports a wider bbox and shrinks more
aggressively to fit a cell than its upright sibling. Acceptable for
the hand-built edge case this PR scopes to.

FT_Matrix stores xx,xy,yx,yy in row-major order;
cairo_matrix_init takes xx,yx,xy,yy. Same matrix, transposed argument
order - pinned with a comment because it is easy to flip.

Refs: #9990
2026-05-13 19:36:36 +05:30
44 changed files with 793 additions and 179 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -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}"]
}
},

View File

@@ -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]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@@ -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:

View File

@@ -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.

View File

@@ -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>`_

View File

@@ -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
View File

@@ -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
View File

@@ -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
View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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, ""},

View File

@@ -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

View File

@@ -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);

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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;

View File

@@ -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);

View File

@@ -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):

View File

@@ -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 {}

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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(),

View File

@@ -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,
}

View File

@@ -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()

View File

@@ -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'):

View File

@@ -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),

View File

@@ -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().

View File

@@ -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
}

View File

@@ -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

View File

@@ -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.