From b3e7c3e71760ef4eb0327f0b917ffffa491d1ddb Mon Sep 17 00:00:00 2001 From: Strykar <2946372+Strykar@users.noreply.github.com> Date: Sun, 10 May 2026 02:48:29 +0530 Subject: [PATCH 01/25] Read FC_MATRIX from fontconfig pattern_as_dict() in fontconfig.c never read FC_MATRIX, so any per-font transform set by fontconfig was silently dropped. fontconfig ships a default rule (90-synthetic.conf) that applies a slant matrix to any roman-only font when italic is requested, which is why italic CJK has been rendering upright in kitty. Read the matrix, carry it on the descriptor as a 4-tuple of doubles, apply it once in face_from_descriptor() via FT_Set_Transform, also inform HarfBuzz via hb_font_set_synthetic_slant + hb_ft_font_changed so shaping reflects the slanted rendering. Extend face_equals_descriptor() to compare the matrix so the per-FontGroup fallback cache returns the right face when upright and italic share a font file. The FT transform is sticky on the face, so subsequent FT_Load_Glyph calls inherit it with no per-call overhead, and the per-Face glyph atlas cache stays correct because the matrix is set at init and never changes. Pure shears (xx=1, yy=1) preserve horizontal advance and do not disturb monospace cell width. The HB synthetic_slant call is gated on HB_VERSION_ATLEAST(4,0,0) since setup.py allows down to 1.5.0. hb_ft_font_changed runs unconditionally to invalidate any populated caches. Refs #9857, #9700. --- kitty/fontconfig.c | 10 +++++++++ kitty/freetype.c | 55 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/kitty/fontconfig.c b/kitty/fontconfig.c index 143150702..a6ef9707e 100644 --- a/kitty/fontconfig.c +++ b/kitty/fontconfig.c @@ -41,6 +41,7 @@ static struct {PyObject *face, *descriptor;} builtin_nerd_font = {0}; #define FcPatternAddInteger dynamically_loaded_fc_symbol.PatternAddInteger #define FcPatternCreate dynamically_loaded_fc_symbol.PatternCreate #define FcPatternGetBool dynamically_loaded_fc_symbol.PatternGetBool +#define FcPatternGetMatrix dynamically_loaded_fc_symbol.PatternGetMatrix #define FcPatternAddCharSet dynamically_loaded_fc_symbol.PatternAddCharSet #define FcConfigAppFontAddFile dynamically_loaded_fc_symbol.ConfigAppFontAddFile @@ -66,6 +67,7 @@ static struct { FcBool (*PatternAddInteger) (FcPattern *p, const char *object, int i); FcPattern * (*PatternCreate) (void); FcResult (*PatternGetBool) (const FcPattern *p, const char *object, int n, FcBool *b); + FcResult (*PatternGetMatrix) (const FcPattern *p, const char *object, int n, FcMatrix **m); FcBool (*PatternAddCharSet) (FcPattern *p, const char *object, const FcCharSet *c); FcBool (*ConfigAppFontAddFile) (FcConfig *config, const FcChar8 *file); } dynamically_loaded_fc_symbol = {0}; @@ -117,6 +119,7 @@ load_fontconfig_lib(void) { LOAD_FUNC(PatternAddInteger); LOAD_FUNC(PatternCreate); LOAD_FUNC(PatternGetBool); + LOAD_FUNC(PatternGetMatrix); LOAD_FUNC(PatternAddCharSet); LOAD_FUNC(ConfigAppFontAddFile); } @@ -210,6 +213,13 @@ pattern_as_dict(FcPattern *pat) { B(FC_OUTLINE, outline); B(FC_COLOR, color); E(FC_SPACING, spacing, pyspacing); + { + FcMatrix *mtx = NULL; + if (FcPatternGetMatrix(pat, FC_MATRIX, 0, &mtx) == FcResultMatch && mtx) { + RAII_PyObject(t, Py_BuildValue("(dddd)", mtx->xx, mtx->xy, mtx->yx, mtx->yy)); + if (!t || PyDict_SetItemString(ans, "matrix", t) != 0) return NULL; + } + } Py_INCREF(ans); return ans; diff --git a/kitty/freetype.c b/kitty/freetype.c index a1ec1e1b7..7da2b1dab 100644 --- a/kitty/freetype.c +++ b/kitty/freetype.c @@ -66,6 +66,8 @@ typedef struct { PyObject *name_lookup_table; FontFeatures font_features; unsigned short dark_palette_index, light_palette_index, palettes_scanned; + FT_Matrix matrix; + bool has_matrix; } Face; PyTypeObject Face_Type; @@ -284,6 +286,29 @@ set_load_error(const char *path, int error) { return NULL; } +static bool +read_matrix_from_descriptor(PyObject *descriptor, FT_Matrix *out, bool *out_has) { + *out_has = false; + PyObject *mt = PyDict_GetItemString(descriptor, "matrix"); + if (!mt || !PyTuple_Check(mt) || PyTuple_GET_SIZE(mt) != 4) return true; + double v[4]; + for (int i = 0; i < 4; i++) { + v[i] = PyFloat_AsDouble(PyTuple_GET_ITEM(mt, i)); + if (PyErr_Occurred()) return false; + if (!isfinite(v[i])) { + PyErr_SetString(PyExc_ValueError, "matrix contains non-finite value"); + return false; + } + } + if (v[0] == 1.0 && v[1] == 0.0 && v[2] == 0.0 && v[3] == 1.0) return true; + out->xx = (FT_Fixed)(v[0] * 0x10000); + out->xy = (FT_Fixed)(v[1] * 0x10000); + out->yx = (FT_Fixed)(v[2] * 0x10000); + out->yy = (FT_Fixed)(v[3] * 0x10000); + *out_has = true; + return true; +} + bool face_equals_descriptor(PyObject *face_, PyObject *descriptor) { Face *face = (Face*)face_; @@ -292,6 +317,13 @@ face_equals_descriptor(PyObject *face_, PyObject *descriptor) { if (PyObject_RichCompareBool(face->path, t, Py_EQ) != 1) return false; t = PyDict_GetItemString(descriptor, "index"); if (t && PyLong_AsLong(t) != face->face->face_index) return false; + FT_Matrix dmat = {0}; + bool d_has = false; + if (!read_matrix_from_descriptor(descriptor, &dmat, &d_has)) { PyErr_Clear(); return false; } + if (d_has != face->has_matrix) return false; + if (d_has && ( + face->matrix.xx != dmat.xx || face->matrix.xy != dmat.xy || + face->matrix.yx != dmat.yx || face->matrix.yy != dmat.yy)) return false; return true; } @@ -339,6 +371,29 @@ face_from_descriptor(PyObject *descriptor, FONTS_DATA_HANDLE fg) { if ((error = FT_Set_Var_Design_Coordinates(self->face, sz, coords))) return set_load_error(path, error); } if (!create_features_for_face(postscript_name_for_face((PyObject*)self), PyDict_GetItemString(descriptor, "features"), &self->font_features)) return NULL; + if (!read_matrix_from_descriptor(descriptor, &self->matrix, &self->has_matrix)) return NULL; + if (self->has_matrix) { + FT_Set_Transform(self->face, &self->matrix, NULL); + if (self->harfbuzz_font) { +#if HB_VERSION_ATLEAST(4,0,0) + // Inform HarfBuzz so shaping (mark positioning, cluster + // boundaries) matches the slanted rendering. The HB API + // models a horizontal shear ratio, which equals xy/xx for + // matrix [[xx,xy],[yx,yy]]. Both operands are FT_Fixed at + // the same 16.16 scale so the factor cancels in the + // division. Guard against xx==0 (degenerate transform). + // Pure scales (s,0,0,s) yield slant=0, which is correct. + // Rotations and shear+rotation composites produce a slant + // value HB can't represent faithfully, but stock fontconfig + // only ever emits horizontal shear here. + if (self->matrix.xx != 0) { + float slant = (float)self->matrix.xy / (float)self->matrix.xx; + hb_font_set_synthetic_slant(self->harfbuzz_font, slant); + } +#endif + hb_ft_font_changed(self->harfbuzz_font); + } + } } Py_XINCREF(retval); return retval; From 07ec00738867aa0aee6d1f27553fc89700f09f5a Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 10 May 2026 05:04:46 +0530 Subject: [PATCH 02/25] Start work on porting code to new remote drag source protocol --- kitty/dnd.c | 172 +++++++++++++++++++++++---------------------- kitty_tests/dnd.py | 15 ++-- 2 files changed, 93 insertions(+), 94 deletions(-) diff --git a/kitty/dnd.c b/kitty/dnd.c index d98e5be38..4652e2eb5 100644 --- a/kitty/dnd.c +++ b/kitty/dnd.c @@ -1452,6 +1452,56 @@ expand_png_data(Window *w, size_t idx) { static size_t last_total_image_size = 0; +static char** +parse_uri_list(Window *w, char *data, const ssize_t sz, size_t *num_uris_out) { + *num_uris_out = 0; + // First pass: count non-comment, non-empty lines + size_t count = 0; + char *p = data; + while (p - data <= sz) { + char *eol = p + strcspn(p, "\r\n"); + char saved = *eol; *eol = '\0'; + char *end = eol; + while (end > p && (end[-1] == ' ' || end[-1] == '\t')) end--; + char saved_end = *end; *end = '\0'; + if (*p && *p != '#') count++; + *end = saved_end; + *eol = saved; + if (saved == '\0') break; + p = eol + 1; + while (*p == '\r' || *p == '\n') p++; + } + + char **result = calloc((count + 1), sizeof(const char*)); + if (!result) { cancel_drag(w, ENOMEM, "out of memory parsing uri list"); return NULL; } + + // Second pass: fill in decoded URI strings + size_t idx = 0; + p = data; + while (p - data < sz && idx < count) { + char *eol = p + strcspn(p, "\r\n"); + char saved = *eol; *eol = '\0'; + char *end = eol; + while (end > p && (end[-1] == ' ' || end[-1] == '\t')) end--; + *end = '\0'; + if (*p && *p != '#') { + char *decoded = strdup(p); + if (!decoded) { + for (size_t k = 0; k < idx; k++) free((char*)result[k]); + free(result); cancel_drag(w, ENOMEM, "out of memory parsing uri list"); return NULL; + } + result[idx++] = decoded; + } + *eol = saved; + if (saved == '\0') break; + p = eol + 1; + while (*p == '\r' || *p == '\n') p++; + } + *num_uris_out = idx; + return result; +} + + void drag_start(Window *w) { if (ds.state != DRAG_SOURCE_BEING_BUILT) abrt(EINVAL, "cannot start drag as drag source is not being built"); @@ -1530,6 +1580,13 @@ drag_start(Window *w) { // Free images and optional_data but keep the items array for later // data requests from the drop target for (size_t i = 0; i < ds.num_mimes; i++) { + if (ds.is_remote_client && ds.items[i].is_uri_list) { + if (ds.items[i].optional_data && ds.items[i].data_size) { + ds.items[i].uri_list = parse_uri_list( + w, (char*)ds.items[i].optional_data, ds.items[i].data_size, &ds.items[i].num_uris); + if (!ds.items[i].uri_list) return; + } else abrt(EINVAL, "remote client must pre-send text/uri-list data"); + } free(ds.items[i].optional_data); ds.items[i].optional_data = NULL; ds.items[i].data_size = 0; @@ -1637,11 +1694,16 @@ drag_get_data(Window *w, const char *mime_type, size_t *sz, int *err_code) { // No fd yet, request data from the client if (!ds.items[i].data_requested_from_client) { char buf[128]; - ds.items[i].requested_remote_files = ds.is_remote_client && ds.items[i].is_uri_list; - int header_sz = snprintf(buf, sizeof(buf), "\x1b]%d;t=e:x=%d:y=%zu:Y=%d", - DND_CODE, DRAG_NOTIFY_FINISHED + 2, i, ds.items[i].requested_remote_files); - queue_payload_to_child(w->id, w->drag_source.client_id, &w->drag_source.pending, buf, header_sz, NULL, 0, false); ds.items[i].data_requested_from_client = true; + ds.items[i].requested_remote_files = ds.is_remote_client && ds.items[i].is_uri_list; + if (ds.items[i].requested_remote_files) { + // TODO: send remote file requests + } else { + int header_sz = snprintf(buf, sizeof(buf), "\x1b]%d;t=e:x=%d:y=%zu:Y=%d", + DND_CODE, DRAG_NOTIFY_FINISHED + 2, i, ds.items[i].requested_remote_files); + queue_payload_to_child( + w->id, w->drag_source.client_id, &w->drag_source.pending, buf, header_sz, NULL, 0, false); + } } *err_code = EAGAIN; return NULL; @@ -1752,73 +1814,6 @@ drag_process_item_data(Window *w, size_t idx, int has_more, const uint8_t *paylo } } -static char** -parse_uri_list(Window *w, int fd, size_t *num_uris_out) { - *num_uris_out = 0; - // Determine file size and read all data - off_t file_size = lseek(fd, 0, SEEK_END); - if (file_size < 0) { cancel_drag(w, EIO, "failed to read cached uri-list data"); return NULL; } - if (lseek(fd, 0, SEEK_SET) < 0) { cancel_drag(w, EIO, "failed to read cached uri-list data"); return NULL; } - RAII_ALLOC(char, buf, malloc((size_t)file_size + 1)); - if (!buf) { cancel_drag(w, ENOMEM, "out of memory processing uri list data"); return NULL; } - size_t total = 0; - while (total < (size_t)file_size) { - ssize_t n = read(fd, buf + total, (size_t)file_size - total); - if (n < 0) { - if (errno == EINTR) continue; - cancel_drag(w, EIO, "failed to read cached uri-list data"); return NULL; - } - if (n == 0) break; - total += (size_t)n; - } - buf[total] = '\0'; - - // First pass: count non-comment, non-empty lines - size_t count = 0; - char *p = buf; - while (*p) { - char *eol = p + strcspn(p, "\r\n"); - char saved = *eol; *eol = '\0'; - char *end = eol; - while (end > p && (end[-1] == ' ' || end[-1] == '\t')) end--; - char saved_end = *end; *end = '\0'; - if (*p && *p != '#') count++; - *end = saved_end; - *eol = saved; - if (saved == '\0') break; - p = eol + 1; - while (*p == '\r' || *p == '\n') p++; - } - - char **result = calloc((count + 1), sizeof(const char*)); - if (!result) { cancel_drag(w, ENOMEM, "out of memory parsing uri list"); return NULL; } - - // Second pass: fill in decoded URI strings - size_t idx = 0; - p = buf; - while (*p && idx < count) { - char *eol = p + strcspn(p, "\r\n"); - char saved = *eol; *eol = '\0'; - char *end = eol; - while (end > p && (end[-1] == ' ' || end[-1] == '\t')) end--; - *end = '\0'; - if (*p && *p != '#') { - char *decoded = strdup(p); - if (!decoded) { - for (size_t k = 0; k < idx; k++) free((char*)result[k]); - free(result); cancel_drag(w, ENOMEM, "out of memory parsing uri list"); return NULL; - } - result[idx++] = decoded; - } - *eol = saved; - if (saved == '\0') break; - p = eol + 1; - while (*p == '\r' || *p == '\n') p++; - } - *num_uris_out = idx; - return result; -} - static int write_all(int fd, const void *buf, size_t sz) { size_t pos = 0; const char *p = buf; @@ -1832,6 +1827,11 @@ write_all(int fd, const void *buf, size_t sz) { static void finish_remote_data(Window *w, size_t item_idx) { + if (!ds.items[item_idx].fd_plus_one) { + int fd = open_item_tmpfile(); + if (fd < 0) abrt(EIO, "failed to open temp file to store modified uri list"); + ds.items[item_idx].fd_plus_one = fd + 1; + } const int fd = ds.items[item_idx].fd_plus_one - 1; ds.items[item_idx].requested_remote_files = false; if (safe_ftruncate(fd, 0) != 0) abrt(errno, "error updating uri list after all remote data received"); @@ -2109,11 +2109,7 @@ drag_remote_file_data( item_idx = i; break; } } - if (item_idx == ds.num_mimes || ds.items[item_idx].fd_plus_one == 0) abrt(EINVAL, "drag source remote file item index out of bounds"); - if (ds.items[item_idx].uri_list == NULL) { - ds.items[item_idx].uri_list = parse_uri_list(w, ds.items[item_idx].fd_plus_one-1, &ds.items[item_idx].num_uris); - if (!ds.items[item_idx].uri_list) return; - } + if (item_idx == ds.num_mimes) abrt(EINVAL, "drag source remote file item index out of bounds"); if (X < 0) abrt(EINVAL, "drag source remote item X cannot be negative"); if (!x && !y && !Y) { finish_remote_data(w, item_idx); return; } if (!Y) toplevel_data_for_drag(w, item_idx, x - 1, X, has_more, payload, payload_sz); @@ -2294,18 +2290,26 @@ dnd_test_force_drag_dropped(PyObject *self UNUSED, PyObject *args) { Window *w = window_for_window_id((id_type)window_id); if (!w) { PyErr_SetString(PyExc_ValueError, "Window not found"); return NULL; } // Simulate what drag_start does on success, without calling start_window_drag +#define ds w->drag_source for (size_t i = 0; i < w->drag_source.num_mimes; i++) { - free(w->drag_source.items[i].optional_data); - w->drag_source.items[i].optional_data = NULL; - w->drag_source.items[i].data_size = 0; - w->drag_source.items[i].data_capacity = 0; - w->drag_source.items[i].data_decode_initialized = false; + if (ds.is_remote_client && ds.items[i].is_uri_list) { + if (ds.items[i].optional_data && ds.items[i].data_size) { + ds.items[i].uri_list = parse_uri_list( + w, (char*)ds.items[i].optional_data, ds.items[i].data_size, &ds.items[i].num_uris); + } + } + free(ds.items[i].optional_data); + ds.items[i].optional_data = NULL; + ds.items[i].data_size = 0; + ds.items[i].data_capacity = 0; + ds.items[i].data_decode_initialized = false; } for (size_t i = 0; i < arraysz(w->drag_source.images); i++) { - if (w->drag_source.images[i].data) free(w->drag_source.images[i].data); - zero_at_ptr(w->drag_source.images + i); + if (ds.images[i].data) free(w->drag_source.images[i].data); + zero_at_ptr(ds.images + i); } - w->drag_source.state = DRAG_SOURCE_DROPPED; + ds.state = DRAG_SOURCE_DROPPED; +#undef ds Py_RETURN_NONE; } diff --git a/kitty_tests/dnd.py b/kitty_tests/dnd.py index 6eb449434..089f2a446 100644 --- a/kitty_tests/dnd.py +++ b/kitty_tests/dnd.py @@ -2340,18 +2340,13 @@ class TestDnDProtocol(BaseTest): # Register with a different machine_id to make is_remote_client=True parse_bytes(screen, _osc(f'{DND_CODE};t=o:x=1;different-machine-id')) parse_bytes(screen, client_drag_offer_mimes(operations, mimes, client_id=client_id)) - cap.consume() - dnd_test_force_drag_dropped(cap.window_id) - # Find the index of text/uri-list mime_list = mimes.split() uri_idx = mime_list.index('text/uri-list') - dnd_test_request_drag_data(cap.window_id, uri_idx) - # Send the uri-list data - b64 = standard_b64encode(uri_list_data).decode() - parse_bytes(screen, client_drag_send_data(uri_idx, b64, client_id=client_id)) - # End of data - parse_bytes(screen, client_drag_send_data(uri_idx, '', client_id=client_id)) + b64 = standard_b64encode(uri_list_data).decode().rstrip('=') + parse_bytes(screen, client_drag_pre_send(uri_idx, b64, client_id=client_id)) cap.consume() + dnd_test_force_drag_dropped(cap.window_id) + dnd_test_request_drag_data(cap.window_id, uri_idx) def test_remote_drag_single_file(self) -> None: """Transfer a single regular file via t=k.""" @@ -2780,7 +2775,7 @@ class TestDnDProtocol(BaseTest): def test_remote_drag_dos_present_data_cap_on_directory(self) -> None: """Directory listing data exceeding PRESENT_DATA_CAP triggers EMFILE error.""" uri_list = b'file:///home/user/dir\r\n' - with dnd_test_window(present_data_cap=20) as (screen, cap): + with dnd_test_window(present_data_cap=100) as (screen, cap): self._setup_remote_drag(screen, cap, uri_list) # Send a directory listing that will exceed the cap big_listing = b'\x00'.join([f'file{i}.txt'.encode() for i in range(100)]) From 15f71ebd37c69ff9b40bc51162d08770a2d931fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 03:48:34 +0000 Subject: [PATCH 03/25] Bump the all-go-deps group with 4 updates Bumps the all-go-deps group with 4 updates: [github.com/alecthomas/chroma/v2](https://github.com/alecthomas/chroma), [github.com/klauspost/compress](https://github.com/klauspost/compress), [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) and [golang.org/x/sys](https://github.com/golang/sys). Updates `github.com/alecthomas/chroma/v2` from 2.23.1 to 2.24.1 - [Release notes](https://github.com/alecthomas/chroma/releases) - [Commits](https://github.com/alecthomas/chroma/compare/v2.23.1...v2.24.1) Updates `github.com/klauspost/compress` from 1.18.5 to 1.18.6 - [Release notes](https://github.com/klauspost/compress/releases) - [Commits](https://github.com/klauspost/compress/compare/v1.18.5...v1.18.6) Updates `github.com/shirou/gopsutil/v4` from 4.26.3 to 4.26.4 - [Release notes](https://github.com/shirou/gopsutil/releases) - [Commits](https://github.com/shirou/gopsutil/compare/v4.26.3...v4.26.4) Updates `golang.org/x/sys` from 0.43.0 to 0.44.0 - [Commits](https://github.com/golang/sys/compare/v0.43.0...v0.44.0) --- updated-dependencies: - dependency-name: github.com/alecthomas/chroma/v2 dependency-version: 2.24.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-go-deps - dependency-name: github.com/klauspost/compress dependency-version: 1.18.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-go-deps - dependency-name: github.com/shirou/gopsutil/v4 dependency-version: 4.26.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-go-deps - dependency-name: golang.org/x/sys dependency-version: 0.44.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-go-deps ... Signed-off-by: dependabot[bot] --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 37eb6028a..056dc2f12 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.26.3 require ( github.com/ALTree/bigfloat v0.2.0 - github.com/alecthomas/chroma/v2 v2.23.1 + github.com/alecthomas/chroma/v2 v2.24.1 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/dlclark/regexp2 v1.12.0 github.com/ebitengine/purego v0.10.0 @@ -14,7 +14,7 @@ require ( github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b - github.com/klauspost/compress v1.18.5 + github.com/klauspost/compress v1.18.6 github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1 github.com/kovidgoyal/go-parallel v1.1.1 github.com/kovidgoyal/go-shm v1.0.0 @@ -22,12 +22,12 @@ require ( github.com/nwaples/rardecode/v2 v2.2.2 github.com/seancfoley/ipaddress-go v1.7.1 github.com/sgtdi/fswatcher v1.2.0 - github.com/shirou/gopsutil/v4 v4.26.3 + github.com/shirou/gopsutil/v4 v4.26.4 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.39.0 - golang.org/x/sys v0.43.0 + golang.org/x/sys v0.44.0 golang.org/x/text v0.36.0 howett.net/plist v1.0.1 ) diff --git a/go.sum b/go.sum index c4c9a3a48..0e6767435 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/ALTree/bigfloat v0.2.0 h1:AwNzawrpFuw55/YDVlcPw0F0cmmXrmngBHhVrvdXPvM github.com/ALTree/bigfloat v0.2.0/go.mod h1:+NaH2gLeY6RPBPPQf4aRotPPStg+eXc8f9ZaE4vRfD4= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= -github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +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/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= @@ -28,8 +28,8 @@ github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1p github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1 h1:rMY/hWfcVzBm6BLX6YLA+gLJEpuXBed/VP6YEkXt8R4= @@ -56,8 +56,8 @@ github.com/seancfoley/ipaddress-go v1.7.1 h1:fDWryS+L8iaaH5RxIKbY0xB5Z+Zxk8xoXLN 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.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= -github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +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/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= @@ -78,8 +78,8 @@ golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= 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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 9993f82d645ff2cc0c9809ae978998320d256339 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 03:52:22 +0000 Subject: [PATCH 04/25] Bump github/codeql-action from 4.35.2 to 4.35.3 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.2 to 4.35.3 - [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.2...v4.35.3) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.35.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 52ac1f9e2..b006113ec 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -54,7 +54,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.35.2 + uses: github/codeql-action/init@v4.35.3 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.2 + uses: github/codeql-action/analyze@v4.35.3 - name: Run govulncheck if: matrix.language == 'go' From 43b028bd6a1713b832283681131a47ce76c6f391 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 11 May 2026 13:39:43 +0530 Subject: [PATCH 05/25] Finish terminal side port of new dnd sub protocol --- docs/dnd-protocol.rst | 5 -- kitty/dnd.c | 133 +++++++++++++++++++++++++++++++++--------- kitty/state.h | 3 +- kitty_tests/dnd.py | 87 +++++++++------------------ 4 files changed, 133 insertions(+), 95 deletions(-) diff --git a/docs/dnd-protocol.rst b/docs/dnd-protocol.rst index 8e2ba929d..b422b06f4 100644 --- a/docs/dnd-protocol.rst +++ b/docs/dnd-protocol.rst @@ -419,11 +419,6 @@ adding ``Y=parent-handle:y=num`` to the escape codes above. Here is the one based index into the list of entries in the directory. Thus, the set of keys ``x, y, Y`` uniquely determine an entry. -Once all data for a dirctory is transmitted, the client informs the terminal emulator of -completion with:: - - OSC _dnd_code ; t=k:Y=handle ; ST - If any error occurs in the client while reading the data, it can inform the terminal using:: diff --git a/kitty/dnd.c b/kitty/dnd.c index 4652e2eb5..ae06b8d69 100644 --- a/kitty/dnd.c +++ b/kitty/dnd.c @@ -1638,6 +1638,30 @@ drag_free_data(Window *w, const char *mime_type, const char* data, size_t sz) { return 0; } +static bool +is_file_url(const char *url) { + return url != NULL && strlen(url) > sizeof("file://")-1 && memcmp(url, "file://", sizeof("file://")-1) == 0; +} + +static bool +request_remote_files(Window *w, size_t i) { +#define mi ds.items[i] + char buf[128]; + mi.remote_items = calloc(mi.num_uris, sizeof(mi.remote_items[0])); + if (!mi.remote_items) return false; + mi.num_remote_items = mi.num_uris; + for (size_t k = 0; k < mi.num_remote_items; k++) { + if (is_file_url(mi.uri_list[k])) { + int header_sz = snprintf(buf, sizeof(buf), "\x1b]%d;t=k:x=%zu", DND_CODE, k + 1); + queue_payload_to_child( + w->id, w->drag_source.client_id, &w->drag_source.pending, buf, header_sz, NULL, 0, false); + mi.remote_items[k].waiting_for_completion = true; + } + } + return true; +#undef mi +} + const char* drag_get_data(Window *w, const char *mime_type, size_t *sz, int *err_code) { *err_code = ENOENT; *sz = 0; @@ -1693,14 +1717,14 @@ drag_get_data(Window *w, const char *mime_type, size_t *sz, int *err_code) { } // No fd yet, request data from the client if (!ds.items[i].data_requested_from_client) { - char buf[128]; ds.items[i].data_requested_from_client = true; ds.items[i].requested_remote_files = ds.is_remote_client && ds.items[i].is_uri_list; if (ds.items[i].requested_remote_files) { - // TODO: send remote file requests + if (!request_remote_files(w, i)) { *err_code = ENOMEM; return NULL; } } else { - int header_sz = snprintf(buf, sizeof(buf), "\x1b]%d;t=e:x=%d:y=%zu:Y=%d", - DND_CODE, DRAG_NOTIFY_FINISHED + 2, i, ds.items[i].requested_remote_files); + char buf[128]; + int header_sz = snprintf(buf, sizeof(buf), + "\x1b]%d;t=e:x=%d:y=%zu", DND_CODE, DRAG_NOTIFY_FINISHED + 2, i); queue_payload_to_child( w->id, w->drag_source.client_id, &w->drag_source.pending, buf, header_sz, NULL, 0, false); } @@ -1919,10 +1943,12 @@ populate_dir_entries(Window *w, DragRemoteItem *ri) { while (ptr < end) { const char *p = memchr(ptr, 0, (size_t)(end - ptr)); size_t len = p ? (size_t)(p - ptr) : (size_t)(end - ptr); + DragRemoteItem *child = ri->children + ri->children_sz++; + child->parent = ri; if (len > 0) { char *name = strndup(ptr, len); if (!name) abrt(ENOMEM, "out of memory processing drag source item directory entries"); - ri->children[ri->children_sz++].dir_entry_name = name; + child->dir_entry_name = name; } ptr = p ? p + 1 : end; } @@ -1996,11 +2022,6 @@ toplevel_data_for_drag( Window *w, unsigned mime_item_idx, unsigned uri_item_idx, unsigned item_type, bool has_more, const uint8_t *payload, size_t payload_sz ) { - if (!mi.remote_items) { - mi.remote_items = calloc(mi.num_uris, sizeof(mi.remote_items[0])); - if (!mi.remote_items) abrt(ENOMEM, "out of memory processing drag source item"); - mi.num_remote_items = mi.num_uris; - } if (!mi.base_dir_for_remote_items) { int fd; mi.base_dir_for_remote_items = mktempdir_in_cache("dnd-drag-", &fd); @@ -2050,10 +2071,10 @@ find_by_handle(DragRemoteItem *parent, int handle, char *path_to_parent, size_t static void subdir_data_for_drag( Window *w, unsigned mime_item_idx, unsigned uri_item_idx, int handle, unsigned entry_num, unsigned item_type, - bool has_more, const uint8_t *payload, size_t payload_sz + bool has_more, const uint8_t *payload, size_t payload_sz, DragRemoteItem **ri ) { if (!mi.remote_items || uri_item_idx >= mi.num_remote_items) abrt(EINVAL, "drag source sub directory item uri list index out of range"); - DragRemoteItem *parent = NULL; + DragRemoteItem *parent = NULL; *ri = NULL; if (mi.currently_open_subdir) { if (mi.currently_open_subdir->type == handle) parent = mi.currently_open_subdir; else { @@ -2080,15 +2101,14 @@ subdir_data_for_drag( } } if (entry_num >= parent->children_sz) abrt(EINVAL, "drag source sub diretory index out of bounds"); - DragRemoteItem *ri = parent->children + entry_num; - if (!ri->started) { - ri->started = true; - ri->type = item_type; - base64_init_stream_decoder(&ri->base64_state); + *ri = parent->children + entry_num; + if (!(*ri)->started) { + (*ri)->started = true; + (*ri)->type = item_type; + base64_init_stream_decoder(&(*ri)->base64_state); } - add_payload(w, ri, has_more, payload, payload_sz, parent->fd_plus_one - 1); + add_payload(w, *ri, has_more, payload, payload_sz, parent->fd_plus_one - 1); } -#undef mi void drag_offer_start_to_child(Window *w, int32_t cell_x, int32_t cell_y, int32_t pixel_x, int32_t pixel_y) { @@ -2099,25 +2119,69 @@ drag_offer_start_to_child(Window *w, int32_t cell_x, int32_t cell_y, int32_t pix w->id, w->drag_source.client_id, &w->drag_source.pending, buf, header_size, NULL, 0, false); } +static void +finish_remote_data_if_all_items_received(Window *w, unsigned mime_item_idx) { + for (size_t i = 0; i < mi.num_remote_items; i++) { + if (mi.remote_items[i].waiting_for_completion && !mi.remote_items[i].completed) return; + } + finish_remote_data(w, mime_item_idx); +} + +static bool +all_children_complete(DragRemoteItem *parent) { + for (size_t i = 0; i < parent->children_sz; i++) { + if (!parent->children[i].completed) return false; + } + return true; +} + void drag_remote_file_data( Window *w, int32_t x, int32_t y, int32_t X, int32_t Y, bool has_more, const uint8_t *payload, size_t payload_sz ) { - size_t item_idx = ds.num_mimes; + size_t mime_item_idx = ds.num_mimes; for (size_t i = 0; i < ds.num_mimes; i++) { if (ds.items[i].requested_remote_files) { - item_idx = i; break; + mime_item_idx = i; break; + } + } + if (mime_item_idx == ds.num_mimes) abrt(EINVAL, "drag source no text/uri-list MIME entry data was requested"); + if (x < 1) abrt(EINVAL, "drag source remote item x index cannot be less than 1"); + const bool all_data_received = !payload_sz && !has_more; + const unsigned uri_item_idx = x - 1; + if (uri_item_idx >= mi.num_remote_items) abrt(EINVAL, "drag source uri list index out of bounds"); + DragRemoteItem *ri; + if (!Y) { + toplevel_data_for_drag(w, mime_item_idx, uri_item_idx, X, has_more, payload, payload_sz); + if (all_data_received) { + ri = mi.remote_items + uri_item_idx; + if (ri->waiting_for_completion && all_children_complete(ri)) { + ri->completed = true; + finish_remote_data_if_all_items_received(w, mime_item_idx); + } + } + } else { + if (y < 1) abrt(EINVAL, "drag source remote item y index cannot be less than 1"); + subdir_data_for_drag(w, mime_item_idx, x - 1, Y, y - 1, X, has_more, payload, payload_sz, &ri); + if (all_data_received && ri && all_children_complete(ri)) { + ri->completed = true; + while (1) { + ri = ri->parent; + if (ri) { + if (all_children_complete(ri)) ri->completed = true; + else break; + } else { + finish_remote_data_if_all_items_received(w, mime_item_idx); + break; + } + } } } - if (item_idx == ds.num_mimes) abrt(EINVAL, "drag source remote file item index out of bounds"); - if (X < 0) abrt(EINVAL, "drag source remote item X cannot be negative"); - if (!x && !y && !Y) { finish_remote_data(w, item_idx); return; } - if (!Y) toplevel_data_for_drag(w, item_idx, x - 1, X, has_more, payload, payload_sz); - else subdir_data_for_drag(w, item_idx, x - 1, Y, y - 1, X, has_more, payload, payload_sz); } #undef img #undef abrt #undef ds +#undef mi // }}} // DnD testing infrastructure {{{ @@ -2316,8 +2380,7 @@ dnd_test_force_drag_dropped(PyObject *self UNUSED, PyObject *args) { static PyObject * dnd_test_request_drag_data(PyObject *self UNUSED, PyObject *args) { // Simulate what drag_get_data does initially: find the MIME item at the - // given index, set requested_remote_files if appropriate, and return the - // escape code that would be sent to the client. + // given index, set requested_remote_files if appropriate. unsigned long long window_id; unsigned idx; if (!PyArg_ParseTuple(args, "KI", &window_id, &idx)) return NULL; @@ -2327,6 +2390,7 @@ dnd_test_request_drag_data(PyObject *self UNUSED, PyObject *args) { PyErr_SetString(PyExc_ValueError, "Invalid state or index"); return NULL; } w->drag_source.items[idx].requested_remote_files = w->drag_source.is_remote_client && w->drag_source.items[idx].is_uri_list; + if (w->drag_source.items[idx].requested_remote_files) request_remote_files(w, idx); Py_RETURN_NONE; } @@ -2424,6 +2488,19 @@ dnd_test_probe_state(PyObject *self UNUSED, PyObject *args) { if (strcmp(q, "drag_thumbnail_size") == 0) { return PyLong_FromSize_t(last_total_image_size); } + if (strcmp(q, "drag_remote_data_complete") == 0) { + for (size_t idx = 0; idx < w->drag_source.num_mimes; idx++) { +#define mi w->drag_source.items[idx] + if (mi.is_uri_list && mi.requested_remote_files) { + for (size_t i = 0; i < mi.num_remote_items; i++) { + if (mi.remote_items[i].waiting_for_completion && !mi.remote_items[i].completed) + return PyUnicode_FromString(mi.remote_items[i].dir_entry_name); + } + } +#undef mi + } + Py_RETURN_NONE; + } Py_RETURN_NONE; } diff --git a/kitty/state.h b/kitty/state.h index 0a9afc2fb..a4e65a49d 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -253,7 +253,8 @@ typedef struct DragRemoteItem { size_t children_sz; char *dir_entry_name; base64_state base64_state; - bool started; + bool started, waiting_for_completion, completed; + struct DragRemoteItem *parent; } DragRemoteItem; typedef struct Window { diff --git a/kitty_tests/dnd.py b/kitty_tests/dnd.py index 089f2a446..9381c4c1c 100644 --- a/kitty_tests/dnd.py +++ b/kitty_tests/dnd.py @@ -18,6 +18,7 @@ from kitty.fast_data_types import ( dnd_test_fake_drop_data, dnd_test_fake_drop_event, dnd_test_force_drag_dropped, + dnd_test_probe_state, dnd_test_request_drag_data, dnd_test_set_mouse_pos, ) @@ -236,14 +237,6 @@ def client_remote_file( return _osc(meta) -def client_remote_file_finish(client_id: int = 0) -> bytes: - """Escape code signaling completion of all remote file data (t=k with no keys).""" - meta = f'{DND_CODE};t=k' - if client_id: - meta += f':i={client_id}' - return _osc(meta) - - # ---- escape-code decoder used by assertions --------------------------------- _OSC_RE = re.compile( @@ -2342,11 +2335,17 @@ class TestDnDProtocol(BaseTest): parse_bytes(screen, client_drag_offer_mimes(operations, mimes, client_id=client_id)) mime_list = mimes.split() uri_idx = mime_list.index('text/uri-list') - b64 = standard_b64encode(uri_list_data).decode().rstrip('=') + b64 = standard_b64encode(uri_list_data).decode() parse_bytes(screen, client_drag_pre_send(uri_idx, b64, client_id=client_id)) cap.consume() dnd_test_force_drag_dropped(cap.window_id) dnd_test_request_drag_data(cap.window_id, uri_idx) + events = self._get_events(cap) + expected = 0 + for line in uri_list_data.decode().splitlines(): + if line.startswith('file://'): + expected += 1 + self.assertEqual(expected, len(events)) def test_remote_drag_single_file(self) -> None: """Transfer a single regular file via t=k.""" @@ -2361,9 +2360,7 @@ class TestDnDProtocol(BaseTest): # End of data for this file parse_bytes(screen, client_remote_file(1, '', item_type=0)) self._assert_no_output(cap) - # Completion signal - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_single_symlink(self) -> None: """Transfer a symlink via t=k with X=1.""" @@ -2378,9 +2375,7 @@ class TestDnDProtocol(BaseTest): # End of data parse_bytes(screen, client_remote_file(1, '', item_type=1)) self._assert_no_output(cap) - # Completion signal - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_single_directory(self) -> None: """Transfer a directory with entries via t=k with X=handle (>1).""" @@ -2417,10 +2412,7 @@ class TestDnDProtocol(BaseTest): parse_bytes(screen, client_remote_file( 1, '', item_type=0, parent_handle=2, entry_num=2)) self._assert_no_output(cap) - - # Completion signal - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_multiple_uris(self) -> None: """Transfer multiple files from a URI list.""" @@ -2437,9 +2429,7 @@ class TestDnDProtocol(BaseTest): parse_bytes(screen, client_remote_file(2, b64, item_type=0)) parse_bytes(screen, client_remote_file(2, '', item_type=0)) self._assert_no_output(cap) - # Completion - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_chunked_file(self) -> None: """File data can be sent in multiple chunks with m=1.""" @@ -2463,9 +2453,7 @@ class TestDnDProtocol(BaseTest): # End of data parse_bytes(screen, client_remote_file(1, '', item_type=0)) self._assert_no_output(cap) - # Completion - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_directory_with_symlink(self) -> None: """Directory can contain symlinks (X=1 type for children).""" @@ -2494,10 +2482,7 @@ class TestDnDProtocol(BaseTest): parse_bytes(screen, client_remote_file( 1, '', item_type=1, parent_handle=2, entry_num=2)) self._assert_no_output(cap) - - # Completion - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_deep_directory_tree_breadth_first(self) -> None: """Transfer a 3-level deep directory tree in breadth-first order. @@ -2567,10 +2552,7 @@ class TestDnDProtocol(BaseTest): 1, b64, item_type=1, parent_handle=4, entry_num=2)) parse_bytes(screen, client_remote_file( 1, '', item_type=1, parent_handle=4, entry_num=2)) - - # Completion - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_deep_directory_tree_depth_first(self) -> None: """Transfer a 3-level deep directory tree in depth-first order. @@ -2640,8 +2622,7 @@ class TestDnDProtocol(BaseTest): parse_bytes(screen, client_remote_file( 1, '', item_type=1, parent_handle=4, entry_num=2)) - # Completion - parse_bytes(screen, client_remote_file_finish()) + self.assert_drag_data_complete(cap) self._assert_no_output(cap) def test_remote_drag_completion_signal(self) -> None: @@ -2652,8 +2633,7 @@ class TestDnDProtocol(BaseTest): b64 = standard_b64encode(b'data').decode() parse_bytes(screen, client_remote_file(1, b64, item_type=0)) parse_bytes(screen, client_remote_file(1, '', item_type=0)) - # Completion - parse_bytes(screen, client_remote_file_finish()) + self.assert_drag_data_complete(cap) self._assert_no_output(cap) def test_remote_drag_invalid_uri_index(self) -> None: @@ -2721,15 +2701,6 @@ class TestDnDProtocol(BaseTest): parse_bytes(screen, client_remote_file(1, big_b64, item_type=0)) self.assert_error(cap) - def test_remote_drag_negative_X_rejected(self) -> None: - """Sending t=k with X < 0 is rejected.""" - uri_list = b'file:///home/user/f.txt\r\n' - with dnd_test_window() as (screen, cap): - self._setup_remote_drag(screen, cap, uri_list) - # Directly construct escape code with negative X - parse_bytes(screen, _osc(f'{DND_CODE};t=k:x=1:X=-1')) - self.assert_error(cap) - def test_remote_drag_without_remote_flag_fails(self) -> None: """t=k fails if the drag is not from a remote client.""" with dnd_test_window() as (screen, cap): @@ -2796,6 +2767,10 @@ class TestDnDProtocol(BaseTest): parse_bytes(screen, client_remote_file(1, b64, item_type=0)) self.assert_error(cap) + def assert_drag_data_complete(self, cap) -> None: + first_incomplete_entry = dnd_test_probe_state(cap.window_id, 'drag_remote_data_complete') + self.assertIsNone(first_incomplete_entry) + def test_remote_drag_three_level_tree_with_verification(self) -> None: """Transfer a 3-level directory tree and verify no errors occur. @@ -2870,9 +2845,7 @@ class TestDnDProtocol(BaseTest): 1, '', item_type=1, parent_handle=30, entry_num=2)) self._assert_no_output(cap) - # Completion - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_process_item_data_basic(self) -> None: """Basic drag_process_item_data: send data for a MIME type after DROPPED state.""" @@ -2949,8 +2922,7 @@ class TestDnDProtocol(BaseTest): parse_bytes(screen, client_remote_file(3, '', item_type=1)) self._assert_no_output(cap) - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_empty_file(self) -> None: """Transfer an empty file (end-of-data immediately after start).""" @@ -2960,8 +2932,7 @@ class TestDnDProtocol(BaseTest): # Start file transfer, then immediately end (no data chunks) parse_bytes(screen, client_remote_file(1, '', item_type=0)) self._assert_no_output(cap) - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_empty_directory(self) -> None: """Transfer a directory with no entries.""" @@ -2972,8 +2943,7 @@ class TestDnDProtocol(BaseTest): b64 = standard_b64encode(b'').decode() parse_bytes(screen, client_remote_file(1, b64, item_type=2)) parse_bytes(screen, client_remote_file(1, '', item_type=2)) - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_uri_list_with_comments(self) -> None: """URI list with comment lines (starting with #) should filter them out.""" @@ -3022,9 +2992,7 @@ class TestDnDProtocol(BaseTest): parse_bytes(screen, client_remote_file( 1, '', item_type=0, parent_handle=2, entry_num=2)) self._assert_no_output(cap) - - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) # ---- DoS limits tests --------------------------------------------------- @@ -3132,9 +3100,6 @@ class TestDnDProtocol(BaseTest): # End of data for this file parse_bytes(screen, client_remote_file(1, '', item_type=0)) self._assert_no_output(cap) - # Completion signal - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) # No crash or leak - cleanup happens in context manager exit # ---- query tests -------------------------------------------------------- From 634f13e65fab66c1c1e12df5c00173c6b357dba3 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 11 May 2026 21:51:23 +0530 Subject: [PATCH 06/25] More work on dnd kitten --- docs/dnd-protocol.rst | 2 +- kittens/dnd/drag.go | 129 ++++++++++++++++++++++------------------ kittens/dnd/main.go | 7 ++- kitty_tests/__init__.py | 2 +- 4 files changed, 79 insertions(+), 61 deletions(-) diff --git a/docs/dnd-protocol.rst b/docs/dnd-protocol.rst index b422b06f4..b3cd0846e 100644 --- a/docs/dnd-protocol.rst +++ b/docs/dnd-protocol.rst @@ -391,7 +391,7 @@ examine the :ref:`machine_id` sent with the enable drag offers from the URI list. To request data for a particular entry, terminals send an escape code of the form:: - OSC _dnd_code ; t=k:x=idx ; base64 encoded file data ST + OSC _dnd_code ; t=k:x=idx ST Here ``idx`` is the one based index into the list of entries in the ``text/uri-list`` MIME type. Then the client can respond with the data diff --git a/kittens/dnd/drag.go b/kittens/dnd/drag.go index b14d69439..83db7ba22 100644 --- a/kittens/dnd/drag.go +++ b/kittens/dnd/drag.go @@ -22,11 +22,10 @@ import ( var _ = fmt.Print type data_request struct { - drag_source *drag_source - send_remote_data bool - index int - write_id loop.IdType - base64 streaming_base64.StreamingBase64Encoder + drag_source *drag_source + index int + write_id loop.IdType + base64 streaming_base64.StreamingBase64Encoder } type remote_data_item struct { @@ -51,7 +50,7 @@ type drag_status struct { current_remote_file *remote_data_item dir_handle_counter int remote_item_write_id loop.IdType - remote_data_was_sent bool + remote_data_requests []int } func find_drag_image(drag_sources map[string]*drag_source) image.Image { @@ -66,12 +65,10 @@ func find_drag_image(drag_sources map[string]*drag_source) image.Image { } } var uri_list []string - if ds := drag_sources["text/uri-list"]; ds != nil && len(ds.data) > 0 { - if q, err := parse_uri_list(string(ds.data)); err == nil { - for _, path := range q { - if path != "" { - uri_list = append(uri_list, path) - } + if ds := drag_sources["text/uri-list"]; ds != nil { + for _, e := range ds.uri_list { + if e.path != "" { + uri_list = append(uri_list, e.path) } } } @@ -102,12 +99,10 @@ func (dnd *dnd) set_drag_image_text() (err error) { } } if icon == "" { - if ds := dnd.drag_sources["text/uri-list"]; ds != nil && len(ds.data) > 0 { - if q, err := parse_uri_list(string(ds.data)); err == nil { - for _, path := range q { - if path != "" && from_path(path) { - break - } + if ds := dnd.drag_sources["text/uri-list"]; ds != nil { + for _, e := range ds.uri_list { + if e.path != "" && from_path(e.path) { + break } } } @@ -219,7 +214,7 @@ func (dnd *dnd) reset_drag() { dnd.drag_status = drag_status{} } -func (dnd *dnd) on_drag_event(x, y, operation, Y int) (err error) { +func (dnd *dnd) on_drag_event(x, y, operation int) (err error) { switch x { case 1: dnd.drag_status.accepted_mime = y @@ -230,21 +225,22 @@ func (dnd *dnd) on_drag_event(x, y, operation, Y int) (err error) { case 4: was_dropped := dnd.drag_status.dropped was_move := dnd.drag_status.accepted_operation == 2 - was_remote := dnd.drag_status.remote_data_was_sent dnd.reset_drag() if was_dropped && dnd.has_exit_on("drag-finish") { dnd.lp.Quit(0) } - if was_dropped && was_move && was_remote { + if was_dropped && was_move { if ds := dnd.drag_sources["text/uri-list"]; ds != nil { for _, item := range ds.uri_list { - if item.metadata.IsDir() { - err = os.RemoveAll(item.path) - } else { - err = os.Remove(item.path) - } - if err != nil { - return err + if item.was_sent { + if item.metadata.IsDir() { + err = os.RemoveAll(item.path) + } else { + err = os.Remove(item.path) + } + if err != nil { + return err + } } } } @@ -256,7 +252,7 @@ func (dnd *dnd) on_drag_event(x, y, operation, Y int) (err error) { } } case 5: - if err = dnd.handle_data_request(y, Y == 1); err != nil { + if err = dnd.handle_data_request(y); err != nil { return err } } @@ -272,28 +268,24 @@ func (dnd *dnd) finish_drag(errname string) { dnd.reset_drag() } -func (dnd *dnd) handle_data_request(idx int, send_remote_data bool) (err error) { +func (dnd *dnd) handle_data_request(idx int) (err error) { if idx < 0 || idx >= len(dnd.drag_status.offered_mimes) { dnd.finish_drag("EINVAL") return fmt.Errorf("terminal asked for drag data from MIME list with out of bounds index: %d", idx) } mime := dnd.drag_status.offered_mimes[idx] ds := dnd.drag_sources[mime] - send_remote_data = send_remote_data && mime == "text/uri-list" && len(ds.uri_list) > 0 for _, dr := range dnd.drag_status.data_requests { if dr.index == idx { dnd.finish_drag("EINVAL") return fmt.Errorf("terminal sent a duplicate drag data request") } } - dr := &data_request{drag_source: ds, send_remote_data: send_remote_data, index: idx} + dr := &data_request{drag_source: ds, index: idx} if ds.path == "" { dnd.lp.QueueDnDData(DC{Type: 'e', Y: idx, Payload: utils.UnsafeStringToBytes(base64.RawStdEncoding.EncodeToString(ds.data))}) dnd.lp.QueueDnDData(DC{Type: 'e', Y: idx}) // EOF - if !dr.send_remote_data { - return - } - return dnd.start_remote_data_send(ds) + return } else { if ds.file != nil { ds.file.Close() @@ -356,9 +348,7 @@ func (dnd *dnd) on_data_request_finished(i int) (err error) { dr.drag_source.file = nil } dnd.drag_status.data_requests = slices.Delete(dnd.drag_status.data_requests, i, i+1) - if dr.send_remote_data { - err = dnd.start_remote_data_send(dr.drag_source) - } else if len(dnd.drag_status.data_requests) > 0 { + if len(dnd.drag_status.data_requests) > 0 { err = dnd.send_data_for_data_request(0) } return @@ -386,6 +376,9 @@ func (dnd *dnd) send_remote_dir(path string, idx_in_uri_list, parent_dir_handle, dnd.finish_drag("EIO") return nil, err } + for dnd.drag_status.dir_handle_counter < 2 { + dnd.drag_status.dir_handle_counter++ + } handle := dnd.drag_status.dir_handle_counter dnd.drag_status.dir_handle_counter++ names := make([]string, 0, len(entries)) @@ -447,7 +440,11 @@ func (dnd *dnd) send_next_file_chunk() (err error) { func (dnd *dnd) next_remote_item() (err error) { if len(dnd.drag_status.remote_items) < 1 { - dnd.lp.QueueDnDData(DC{Type: 'k'}) // inform terminal remote data is finished + // current remote data request finished + dnd.drag_status.remote_data_requests = dnd.drag_status.remote_data_requests[1:] + if len(dnd.drag_status.remote_data_requests) > 0 { + return dnd.send_next_remote_data_request() + } if len(dnd.drag_status.data_requests) > 0 { err = dnd.send_data_for_data_request(0) } @@ -478,26 +475,44 @@ func (dnd *dnd) next_remote_item() (err error) { return } -func (dnd *dnd) start_remote_data_send(ds *drag_source) (err error) { - dnd.drag_status.dir_handle_counter = 2 - dnd.drag_status.remote_item_write_id = 0 - dnd.drag_status.remote_data_was_sent = true +func (dnd *dnd) on_drag_remote_data_request(idx int) (err error) { + ds := dnd.drag_sources["text/uri_list"] + if ds == nil || len(ds.uri_list) < 1 { + dnd.finish_drag("EINVAL") + return fmt.Errorf("terminal asked for drag data from URI list but no list present") + } + if idx < 0 || idx >= len(ds.uri_list) { + dnd.finish_drag("EINVAL") + return fmt.Errorf("terminal asked for drag data from URI list with out of bounds index: %d", idx) + } + ds.uri_list[idx].was_sent = true + dnd.drag_status.remote_data_requests = append(dnd.drag_status.remote_data_requests, idx) + if len(dnd.drag_status.remote_data_requests) == 1 { + err = dnd.send_next_remote_data_request() + } + return +} + +func (dnd *dnd) send_next_remote_data_request() (err error) { + if len(dnd.drag_status.remote_data_requests) == 0 { + return nil + } + i := dnd.drag_status.remote_data_requests[0] + x := dnd.drag_sources["text/uri-list"].uri_list[i] items := []*remote_data_item{} - for i, x := range ds.uri_list { - if x.metadata.IsDir() { - if children, err := dnd.send_remote_dir(x.path, i, 0, i); err != nil { - return err - } else { - items = append(items, children...) - } - } else if x.metadata.Mode().Type()&os.ModeSymlink != 0 { - if err = dnd.send_remote_symlink(x.path, i, 0, i); err != nil { - return err - } + if x.metadata.IsDir() { + if children, err := dnd.send_remote_dir(x.path, i, 0, i); err != nil { + return err } else { - f := remote_data_item{idx_in_parent: i, idx_in_uri_list: i, metadata: x.metadata, path: x.path} - dnd.drag_status.remote_items = append(dnd.drag_status.remote_items, &f) + items = append(items, children...) } + } else if x.metadata.Mode().Type()&os.ModeSymlink != 0 { + if err = dnd.send_remote_symlink(x.path, i, 0, i); err != nil { + return err + } + } else { + f := remote_data_item{idx_in_parent: i, idx_in_uri_list: i, metadata: x.metadata, path: x.path} + dnd.drag_status.remote_items = append(dnd.drag_status.remote_items, &f) } dnd.drag_status.remote_items = append(dnd.drag_status.remote_items, items...) if dnd.drag_status.remote_item_write_id == 0 { diff --git a/kittens/dnd/main.go b/kittens/dnd/main.go index 067883a01..4707da2cd 100644 --- a/kittens/dnd/main.go +++ b/kittens/dnd/main.go @@ -31,6 +31,7 @@ type uri_list_item struct { path, uri, human_name string file *os.File metadata os.FileInfo + was_sent bool } type drag_source struct { @@ -105,7 +106,7 @@ func (dnd *dnd) send_test_response(payload string) { } func (dnd *dnd) has_exit_on(event string) bool { - for _, e := range strings.Split(dnd.opts.ExitOn, ",") { + for e := range strings.SplitSeq(dnd.opts.ExitOn, ",") { if strings.TrimSpace(e) == event { return true } @@ -276,7 +277,9 @@ func (dnd *dnd) run_loop() (err error) { case 'E': return dnd.on_drag_error(cmd) case 'e': - return dnd.on_drag_event(cmd.X, cmd.Y, cmd.Operation, cmd.Yp) + return dnd.on_drag_event(cmd.X, cmd.Y, cmd.Operation) + case 'k': + return dnd.on_drag_remote_data_request(cmd.X - 1) } return nil } diff --git a/kitty_tests/__init__.py b/kitty_tests/__init__.py index 759792d22..8c2c4a602 100644 --- a/kitty_tests/__init__.py +++ b/kitty_tests/__init__.py @@ -426,7 +426,7 @@ class PTY: if isinstance(data, str): data = data.encode('utf-8') if self.log_data_flow: - print('t -> c:', data) + print('t -> c:', bytes(data)) self.write_buf += data if flush: self.process_input_from_child(0) From 02cfa89bae7c9f5505b92ac72089c9ebe8a5c7f4 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 11 May 2026 21:59:45 +0530 Subject: [PATCH 07/25] ... --- kittens/dnd/drag.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kittens/dnd/drag.go b/kittens/dnd/drag.go index 83db7ba22..2772e6ade 100644 --- a/kittens/dnd/drag.go +++ b/kittens/dnd/drag.go @@ -476,7 +476,7 @@ func (dnd *dnd) next_remote_item() (err error) { } func (dnd *dnd) on_drag_remote_data_request(idx int) (err error) { - ds := dnd.drag_sources["text/uri_list"] + ds := dnd.drag_sources["text/uri-list"] if ds == nil || len(ds.uri_list) < 1 { dnd.finish_drag("EINVAL") return fmt.Errorf("terminal asked for drag data from URI list but no list present") From f4e9824e1864ebdcca12a09ec249e6f127380937 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 11 May 2026 22:06:32 +0530 Subject: [PATCH 08/25] Fix off by one in if condition --- kitty/dnd.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kitty/dnd.c b/kitty/dnd.c index ae06b8d69..43332acf9 100644 --- a/kitty/dnd.c +++ b/kitty/dnd.c @@ -1458,7 +1458,7 @@ parse_uri_list(Window *w, char *data, const ssize_t sz, size_t *num_uris_out) { // First pass: count non-comment, non-empty lines size_t count = 0; char *p = data; - while (p - data <= sz) { + while (p - data < sz) { char *eol = p + strcspn(p, "\r\n"); char saved = *eol; *eol = '\0'; char *end = eol; From 654cce687e8c6c43454d194150366066dce52db1 Mon Sep 17 00:00:00 2001 From: nuck Date: Sun, 10 May 2026 12:45:30 -0400 Subject: [PATCH 09/25] Honor background image overrides for single instance windows --- kitty/boss.py | 10 +++++++++- kitty/shaders.c | 7 ++++--- kitty/state.c | 2 ++ kitty/state.h | 2 ++ 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/kitty/boss.py b/kitty/boss.py index d8dc3e2bb..57c415e99 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -977,8 +977,16 @@ class Boss: override_title=args.title or None, window_state=wstate, x=pos_x, y=pos_y) if session.focus_os_window: focused_os_window = os_window_id - if opts.background_opacity != get_options().background_opacity: + global_opts = get_options() + if opts.background_opacity != global_opts.background_opacity: self._set_os_window_background_opacity(os_window_id, opts.background_opacity) + if opts.background_image != global_opts.background_image: + self.set_background_image( + opts.background_image[0] if opts.background_image else None, (os_window_id,), False, + layout=opts.background_image_layout, + linear_interpolation=opts.background_image_linear, + tint=opts.background_tint, + tint_gaps=opts.background_tint_gaps) if n := data.get('notify_on_os_window_death'): self.os_window_death_actions[os_window_id] = partial(self.notify_on_os_window_death, n) if focused_os_window > 0: diff --git a/kitty/shaders.c b/kitty/shaders.c index 3b82ca272..8d9913642 100644 --- a/kitty/shaders.c +++ b/kitty/shaders.c @@ -1537,14 +1537,15 @@ static void draw_bg_image(OSWindow *os_window, Tab *tab) { BackgroundImage *bg = background_image_for_os_window(os_window); if (!bg) return; + BackgroundImageLayout layout = os_window->background_image.has_layout ? os_window->background_image.layout : OPT(background_image_layout); BackgroundImageRenderSettings s = { .os_window.width = os_window->viewport_width, .os_window.height = os_window->viewport_height, - .instance_id = bg->id, .layout=OPT(background_image_layout), + .instance_id = bg->id, .layout=layout, .linear=OPT(background_image_linear), .bgcolor=OPT(background), .opacity=effective_os_window_alpha(os_window), }; GLfloat iwidth = bg->width, iheight = bg->height; GLfloat vwidth = s.os_window.width, vheight = s.os_window.height; - if (CENTER_SCALED == OPT(background_image_layout)) { + if (CENTER_SCALED == layout) { GLfloat ifrac = iwidth / iheight; if (ifrac > (vwidth / vheight)) { iheight = vheight; @@ -1556,7 +1557,7 @@ draw_bg_image(OSWindow *os_window, Tab *tab) { } GLfloat tiled = 0.f;; GLfloat left = -1.0, top = 1.0, right = 1.0, bottom = -1.0; - switch (OPT(background_image_layout)) { + switch (layout) { case TILING: case MIRRORED: case CLAMPED: tiled = 1.f; break; case SCALED: diff --git a/kitty/state.c b/kitty/state.c index a610834b6..63f7cffd9 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -1440,6 +1440,8 @@ pyset_background_image(PyObject *self UNUSED, PyObject *args, PyObject *kw) { if (bgimage) { if (!configured) { // configured means we use the zero index global image os_window->background_image.override = bgimage; + os_window->background_image.layout = layout; + os_window->background_image.has_layout = true; bgimage->refcnt++; } } else if (is_increment) { diff --git a/kitty/state.h b/kitty/state.h index 0a9afc2fb..1675ca393 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -419,7 +419,9 @@ typedef struct OSWindow { struct { size_t global_bg_images_idx; BackgroundImage *override; + BackgroundImageLayout layout; bool no_image; + bool has_layout; } background_image; struct { uint32_t framebuffer_id, attached_texture_generation; From 4e3a3ba6ab712536b3b1db890d13c6a1a4ef6359 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 12 May 2026 09:36:30 +0530 Subject: [PATCH 10/25] Fix infinite wait in test --- kitty_tests/dnd_kitten.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/kitty_tests/dnd_kitten.py b/kitty_tests/dnd_kitten.py index c8fcf4f65..be0e783f6 100644 --- a/kitty_tests/dnd_kitten.py +++ b/kitty_tests/dnd_kitten.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # License: GPLv3 Copyright: 2026, Kovid Goyal -import errno +import errno, time import fnmatch import itertools import os @@ -384,10 +384,11 @@ class TestDnDKitten(BaseTest): self.exit_kitten() self.img_drag_data = None - def read_drag_data(self, mime): + def read_drag_data(self, mime, timeout=10): # self.pty.log_data_flow = True ans = b'' - while True: + st = time.monotonic() + while time.monotonic() - st < timeout: try: chunk = dnd_test_drag_get_data(self.capture.window_id, mime) if not chunk: @@ -395,7 +396,7 @@ class TestDnDKitten(BaseTest): ans += chunk except OSError as err: if err.errno == errno.EAGAIN: - self.pty.process_input_from_child() + self.pty.process_input_from_child(timeout=st + timeout - time.monotonic()) continue chunk = ans = b'' raise From 12bdf972e00566d67ba8705281106cc3dfd97e3a Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 12 May 2026 09:37:16 +0530 Subject: [PATCH 11/25] ... --- kitty_tests/dnd_kitten.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kitty_tests/dnd_kitten.py b/kitty_tests/dnd_kitten.py index be0e783f6..f504cdabc 100644 --- a/kitty_tests/dnd_kitten.py +++ b/kitty_tests/dnd_kitten.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # License: GPLv3 Copyright: 2026, Kovid Goyal -import errno, time +import errno import fnmatch import itertools import os @@ -9,6 +9,7 @@ import random import shutil import stat import tempfile +import time import uuid from base64 import standard_b64encode from functools import partial From 0a69b89a80d237431edc5c9577864415ec805817 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 12 May 2026 12:23:57 +0530 Subject: [PATCH 12/25] More work on dnd kitten --- kitty/dnd.c | 13 +++++++++++-- kitty_tests/dnd_kitten.py | 17 +++++++++-------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/kitty/dnd.c b/kitty/dnd.c index 43332acf9..45152f6fa 100644 --- a/kitty/dnd.c +++ b/kitty/dnd.c @@ -1643,6 +1643,12 @@ is_file_url(const char *url) { return url != NULL && strlen(url) > sizeof("file://")-1 && memcmp(url, "file://", sizeof("file://")-1) == 0; } +const char* +my_basename(const char *path) { + const char *base = strrchr(path, '/'); // Change to '\\' for Windows + return base ? base + 1 : path; +} + static bool request_remote_files(Window *w, size_t i) { #define mi ds.items[i] @@ -1656,6 +1662,7 @@ request_remote_files(Window *w, size_t i) { queue_payload_to_child( w->id, w->drag_source.client_id, &w->drag_source.pending, buf, header_sz, NULL, 0, false); mi.remote_items[k].waiting_for_completion = true; + mi.remote_items[k].dir_entry_name = strdup(my_basename(mi.uri_list[k])); } } return true; @@ -2122,7 +2129,9 @@ drag_offer_start_to_child(Window *w, int32_t cell_x, int32_t cell_y, int32_t pix static void finish_remote_data_if_all_items_received(Window *w, unsigned mime_item_idx) { for (size_t i = 0; i < mi.num_remote_items; i++) { - if (mi.remote_items[i].waiting_for_completion && !mi.remote_items[i].completed) return; + if (mi.remote_items[i].waiting_for_completion && !mi.remote_items[i].completed) { + return; + } } finish_remote_data(w, mime_item_idx); } @@ -2494,7 +2503,7 @@ dnd_test_probe_state(PyObject *self UNUSED, PyObject *args) { if (mi.is_uri_list && mi.requested_remote_files) { for (size_t i = 0; i < mi.num_remote_items; i++) { if (mi.remote_items[i].waiting_for_completion && !mi.remote_items[i].completed) - return PyUnicode_FromString(mi.remote_items[i].dir_entry_name); + return PyUnicode_FromString(mi.remote_items[i].dir_entry_name ? mi.remote_items[i].dir_entry_name : ""); } } #undef mi diff --git a/kitty_tests/dnd_kitten.py b/kitty_tests/dnd_kitten.py index f504cdabc..dae659ddd 100644 --- a/kitty_tests/dnd_kitten.py +++ b/kitty_tests/dnd_kitten.py @@ -61,9 +61,9 @@ def create_fs(base, include_toplevel_working_symlink=True): w(4096 * 3 + 113, 'some-image.png') w(0, 'd1', 'f1') w(0, 'd1', 'f2') - w(0, 'd1', 'sd', 'f1') - w(0, 'd1', 'sd', 'ssd', 'f1') - os.symlink('../moose', join('d1', 'sd', 'ssd', 's1')) + w(0, 'd1', 'sd', 'f11') + w(0, 'd1', 'sd', 'ssd', 'f111') + os.symlink('../moose', join('d1', 'sd', 'ssd', 's11')) class TestDnDKitten(BaseTest): @@ -388,20 +388,21 @@ class TestDnDKitten(BaseTest): def read_drag_data(self, mime, timeout=10): # self.pty.log_data_flow = True ans = b'' - st = time.monotonic() - while time.monotonic() - st < timeout: + end_time = time.monotonic() + timeout + while time.monotonic() <= end_time: try: chunk = dnd_test_drag_get_data(self.capture.window_id, mime) if not chunk: - break + return ans ans += chunk except OSError as err: if err.errno == errno.EAGAIN: - self.pty.process_input_from_child(timeout=st + timeout - time.monotonic()) + self.pty.process_input_from_child(timeout=end_time - time.monotonic()) continue chunk = ans = b'' raise - return ans + chunk = ans = b'' + raise TimeoutError(f'timed out waiting for data from drag_get_data for {mime}') def dnd_kitten_drag(self, remote_client, img_drop_path): # self.pty.log_data_flow = True From 2bda4896981365e2e688eb0efe2e0791fbdcfbec Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 12 May 2026 12:39:34 +0530 Subject: [PATCH 13/25] Clear remote_item_write_id on completion Needed as send next remote item checks it is zero before sending --- kittens/dnd/drag.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/kittens/dnd/drag.go b/kittens/dnd/drag.go index 2772e6ade..ecafb257a 100644 --- a/kittens/dnd/drag.go +++ b/kittens/dnd/drag.go @@ -332,11 +332,8 @@ func (dnd *dnd) on_send_done(id loop.IdType) (err error) { } } if id == dnd.drag_status.remote_item_write_id { - if dnd.drag_status.current_remote_file != nil { - err = dnd.send_next_file_chunk() - } else { - err = dnd.next_remote_item() - } + dnd.drag_status.remote_item_write_id = 0 + err = dnd.send_next_file_chunk() } return } From b9261c4e26d677af9d0dba2ac430472c16476950 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 12 May 2026 12:52:04 +0530 Subject: [PATCH 14/25] Fix failing test Ensure we never write empty payloads for symlinks/dir listings --- kittens/dnd/drag.go | 8 ++++++-- kitty/dnd.c | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/kittens/dnd/drag.go b/kittens/dnd/drag.go index ecafb257a..e180b61ab 100644 --- a/kittens/dnd/drag.go +++ b/kittens/dnd/drag.go @@ -392,7 +392,9 @@ func (dnd *dnd) send_remote_dir(path string, idx_in_uri_list, parent_dir_handle, names = append(names, entry.Name()) } payload := utils.UnsafeStringToBytes(strings.Join(names, "\x00")) - dnd.send_remote_item_payload(parent_dir_handle, idx, idx_in_uri_list, handle, payload) + if len(payload) > 0 { + dnd.send_remote_item_payload(parent_dir_handle, idx, idx_in_uri_list, handle, payload) + } dnd.drag_status.remote_item_write_id = dnd.send_remote_item_payload(parent_dir_handle, idx, idx_in_uri_list, handle, nil) return } @@ -403,7 +405,9 @@ func (dnd *dnd) send_remote_symlink(path string, idx_in_uri_list, parent_dir_han dnd.finish_drag("EIO") return err } - dnd.send_remote_item_payload(parent_dir_handle, idx, idx_in_uri_list, 1, utils.UnsafeStringToBytes(target)) + if len(target) > 0 { + dnd.send_remote_item_payload(parent_dir_handle, idx, idx_in_uri_list, 1, utils.UnsafeStringToBytes(target)) + } dnd.drag_status.remote_item_write_id = dnd.send_remote_item_payload(parent_dir_handle, idx, idx_in_uri_list, 1, nil) return } diff --git a/kitty/dnd.c b/kitty/dnd.c index 45152f6fa..ea0ad9eb0 100644 --- a/kitty/dnd.c +++ b/kitty/dnd.c @@ -2154,7 +2154,7 @@ drag_remote_file_data( mime_item_idx = i; break; } } - if (mime_item_idx == ds.num_mimes) abrt(EINVAL, "drag source no text/uri-list MIME entry data was requested"); + if (mime_item_idx == ds.num_mimes) abrt(EINVAL, "drag source no remote data was requested"); if (x < 1) abrt(EINVAL, "drag source remote item x index cannot be less than 1"); const bool all_data_received = !payload_sz && !has_more; const unsigned uri_item_idx = x - 1; From 824b46507c1692829a4bd43d147a6aeb7ebc3f37 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 12 May 2026 13:20:35 +0530 Subject: [PATCH 15/25] Keep text/uri-list as private MIME for kitty to kitty DnD --- glfw/cocoa_window.m | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index f6eba0865..866b0996b 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -4351,12 +4351,21 @@ add_uri_list_drag_items(_GLFWwindow *window, NSMutableArray* dr static int add_drag_items(_GLFWwindow *window, NSMutableArray* dragItems, GLFWDragSourceItem *mime_item, const GLFWimage *thumbnail) { + // URI list items get added directly to the clipboard for use by native apps since macOS does not support + // the text/uri-list MIME type, however this type remains as a private + // kitty type to enable kitty to kitty DnD for remote clients. + bool is_uri_list = false; if (strcmp(mime_item->mime_type, "text/uri-list") == 0 && mime_item->optional_data && mime_item->data_size) { - return add_uri_list_drag_items(window, dragItems, mime_item->optional_data, mime_item->data_size, mime_item->is_remote_client, thumbnail); + is_uri_list = true; + int err = add_uri_list_drag_items(window, dragItems, mime_item->optional_data, mime_item->data_size, mime_item->is_remote_client, thumbnail); + if (err != 0) return err; } NSString* utiString = mime_to_uti(mime_item->mime_type); id w; - if (mime_item->optional_data) { + // remote client URI list must be set a file promise so that the kitty drag + // source code receives a request for it and can then fetch the remote + // files. + if (mime_item->optional_data && !(is_uri_list && mime_item->is_remote_client)) { NSPasteboardItem *pbItem = [[[NSPasteboardItem alloc] init] autorelease]; NSData *data = [NSData dataWithBytes:mime_item->optional_data length:mime_item->data_size]; [pbItem setData:data forType:utiString]; From c0661024d893ffe623181a960e16fbf642df66ca Mon Sep 17 00:00:00 2001 From: Xuyiyang23333 Date: Wed, 13 May 2026 00:37:55 +0800 Subject: [PATCH 16/25] Fix silent failure when pager (less) is not installed When kitten --help is run in a terminal and less is not available, ShowHelpInPager silently discards the error from pager.Run(), resulting in no output and a zero exit code. Fall back to writing help text directly to stdout when the pager fails, matching the behavior of the Python equivalent in kitty/cli.py which catches FileNotFoundError and prints the text as a fallback. Signed-off-by: Xuyiyang23333 --- tools/cli/help.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/cli/help.go b/tools/cli/help.go index 4db7614ca..29a38ef8e 100644 --- a/tools/cli/help.go +++ b/tools/cli/help.go @@ -131,7 +131,9 @@ func ShowHelpInPager(text string) { pager.Stdin = strings.NewReader(text) pager.Stdout = os.Stdout pager.Stderr = os.Stderr - _ = pager.Run() + if err := pager.Run(); err != nil { + os.Stdout.WriteString(text) + } } func getDeterministicTimestamp() time.Time { From b28712bfae741672ebf315b43201d50e221652ce Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 13 May 2026 07:17:06 +0530 Subject: [PATCH 17/25] More work on the DnD kitten --- glfw/cocoa_window.m | 2 +- glfw/glfw3.h | 1 + kitty/dnd.c | 200 +++++++++++++++++++++++++++++++------------ kitty/glfw-wrapper.h | 1 + kitty/glfw.c | 8 +- kitty/state.h | 7 +- 6 files changed, 160 insertions(+), 59 deletions(-) diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index 866b0996b..a3c890922 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -4333,7 +4333,7 @@ add_uri_list_drag_items(_GLFWwindow *window, NSMutableArray* dr NSString *extension = [url pathExtension]; UTType *type = [UTType typeWithFilenameExtension:extension]; if (!type) type = UTTypeItem; - snprintf(buf, sizeof(buf), "text/uri-list:%d", count); + snprintf(buf, sizeof(buf), "kitty-internal/uri-list-item-%d", count); GLFWFilePromiseProviderDelegate* delegate = [[[GLFWFilePromiseProviderDelegate alloc] initWithWindow:window mimeType:buf instanceId:_glfw.drag.instance_id] autorelease]; NSFilePromiseProvider *provider = [[[NSFilePromiseProvider alloc] diff --git a/glfw/glfw3.h b/glfw/glfw3.h index 69935488c..4e89399a5 100644 --- a/glfw/glfw3.h +++ b/glfw/glfw3.h @@ -1828,6 +1828,7 @@ typedef struct GLFWDragSourceItem { const char *optional_data; size_t data_size; bool is_remote_client; + int type; // used for file promises type of entry 0 = regular, 1 = symlink, 2 = directory } GLFWDragSourceItem; typedef struct GLFWDragEvent { diff --git a/kitty/dnd.c b/kitty/dnd.c index ea0ad9eb0..2ebb0e35f 100644 --- a/kitty/dnd.c +++ b/kitty/dnd.c @@ -33,6 +33,8 @@ static size_t REMOTE_DRAG_LIMIT = DEFAULT_REMOTE_DRAG_LIMIT; static PyObject *g_dnd_test_write_func = NULL; static const unsigned file_permissions = 0644; static const unsigned dir_permissions = 0755; +#define startwith_literal(x, literal) (x != NULL && strlen(x) >= sizeof(literal)-1 && memcmp(x, literal, sizeof(literal)-1) == 0) + // Utils {{{ // In test mode, this callable is invoked instead of schedule_write_to_child_if_possible. @@ -1268,11 +1270,6 @@ drag_free_offer(Window *w) { free(ds.items[i].remote_items); ds.items[i].remote_items = NULL; ds.items[i].num_remote_items = 0; } - if (ds.items[i].base_dir_fd_plus_one) { - rmtree_best_effort(".", ds.items[i].base_dir_fd_plus_one - 1); - ds.items[i].base_dir_fd_plus_one = 0; - } - free(ds.items[i].base_dir_for_remote_items); ds.items[i].base_dir_for_remote_items = NULL; } free(ds.items); ds.items = NULL; @@ -1288,6 +1285,19 @@ drag_free_offer(Window *w) { ds.state = DRAG_SOURCE_NONE; ds.pre_sent_total_sz = 0; ds.images_sent_total_sz = 0; + if (ds.base_dir_fd_plus_one) { + rmtree_best_effort(".", ds.base_dir_fd_plus_one - 1); + ds.base_dir_fd_plus_one = 0; + } + free(ds.base_dir_for_remote_items); ds.base_dir_for_remote_items = NULL; + if (ds.file_promises) { + for (size_t i = 0; i < ds.file_promises_count; i++) { + drag_free_remote_item(&ds.file_promises[i].ri); + } + free(ds.file_promises); + ds.file_promises = NULL; + } + ds.file_promises_count = 0; ds.file_promises_capacity = 0; } static void @@ -1639,40 +1649,94 @@ drag_free_data(Window *w, const char *mime_type, const char* data, size_t sz) { } static bool -is_file_url(const char *url) { - return url != NULL && strlen(url) > sizeof("file://")-1 && memcmp(url, "file://", sizeof("file://")-1) == 0; +is_file_url(const char *url) { return startwith_literal(url, "file://"); } + +static void +request_remote_file(Window *w, DragRemoteItem *ri, const char *url, size_t idx_in_uri_list) { + char buf[128]; + int header_sz = snprintf(buf, sizeof(buf), "\x1b]%d;t=k:x=%zu", DND_CODE, idx_in_uri_list + 1); + ri->dir_entry_name = sanitized_filename_from_url(url); + if (ri->dir_entry_name) { + queue_payload_to_child(w->id, w->drag_source.client_id, &w->drag_source.pending, buf, header_sz, NULL, 0, false); + ri->waiting_for_completion = true; + } } -const char* -my_basename(const char *path) { - const char *base = strrchr(path, '/'); // Change to '\\' for Windows - return base ? base + 1 : path; +static int +notify_drag_data_received(Window *w, size_t uri_item_idx, const char *basename, int type) { + char mime_type[128], path[4096]; + snprintf(mime_type, sizeof(mime_type), "kitty-internal/uri-list-item-%zu", uri_item_idx); + int sz = snprintf(path, sizeof(path), "%s/%zu/%s", ds.base_dir_for_remote_items, uri_item_idx, basename); + return notify_drag_data_ready(global_state.drag_source.from_os_window, mime_type, path, sz, type); +} + +static const char* +request_file_promise(Window *w, size_t idx_in_uri_list, const char *url, int *err_code) { + for (size_t i = 0; i < w->drag_source.file_promises_count; i++) { + if (w->drag_source.file_promises[i].uri_item_idx == idx_in_uri_list) { + const DragRemoteItem *ri = &w->drag_source.file_promises[i].ri; + if (ri->completed) { + notify_drag_data_received(w, idx_in_uri_list, ri->dir_entry_name, ri->type); + *err_code = 0; + } + *err_code = EAGAIN; + return NULL; + } + } + if (w->drag_source.file_promises_count + 1 >= w->drag_source.file_promises_capacity) { + size_t cap = MAX(w->drag_source.file_promises_count + 1, MAX(16u, 2u * w->drag_source.file_promises_capacity)); + void *p = realloc(w->drag_source.file_promises, sizeof(w->drag_source.file_promises[0]) * cap); + if (!p) { *err_code = ENOMEM; return NULL; } + w->drag_source.file_promises = p; + w->drag_source.file_promises_capacity = cap; + } + char *fname = sanitized_filename_from_url(url); + if (!fname) { *err_code = EINVAL; return NULL; } + w->drag_source.file_promises[w->drag_source.file_promises_count].uri_item_idx = idx_in_uri_list; + w->drag_source.file_promises[w->drag_source.file_promises_count++].ri = (DragRemoteItem){.dir_entry_name = fname}; + + *err_code = EAGAIN; + return NULL; } static bool request_remote_files(Window *w, size_t i) { #define mi ds.items[i] - char buf[128]; mi.remote_items = calloc(mi.num_uris, sizeof(mi.remote_items[0])); if (!mi.remote_items) return false; mi.num_remote_items = mi.num_uris; for (size_t k = 0; k < mi.num_remote_items; k++) { if (is_file_url(mi.uri_list[k])) { - int header_sz = snprintf(buf, sizeof(buf), "\x1b]%d;t=k:x=%zu", DND_CODE, k + 1); - queue_payload_to_child( - w->id, w->drag_source.client_id, &w->drag_source.pending, buf, header_sz, NULL, 0, false); - mi.remote_items[k].waiting_for_completion = true; - mi.remote_items[k].dir_entry_name = strdup(my_basename(mi.uri_list[k])); + request_remote_file(w, mi.remote_items + k, mi.uri_list[k], k); } } return true; #undef mi } +const char* +drag_get_file_promise_data(Window *w, size_t idx_in_uri_list, size_t *sz, int *err_code) { + *err_code = ENOENT; *sz = 0; + for (size_t i = 0; i < ds.num_mimes; i++) { + if (strcmp(ds.items[i].mime_type, "text/uri-list") == 0) { + if ((unsigned)idx_in_uri_list >= ds.items[i].num_uris) break; + const char *url = ds.items[i].uri_list[idx_in_uri_list]; + if (!is_file_url(url)) break; + return request_file_promise(w, idx_in_uri_list, url, err_code); + } + } + return NULL; +} + const char* drag_get_data(Window *w, const char *mime_type, size_t *sz, int *err_code) { *err_code = ENOENT; *sz = 0; if (!ds.items || ds.state < DRAG_SOURCE_STARTED) return NULL; + if (startwith_literal(mime_type, "kitty-internal/uri-list-item-")) { + // request for a single file:// URL from text/uri-list used by macOS backend file promise providers + int idx_in_uri_list = atoi(mime_type + sizeof("kitty-internal/uri-list-item-")-1); + return drag_get_file_promise_data(w, idx_in_uri_list, sz, err_code); + } for (size_t i = 0; i < ds.num_mimes; i++) { if (strcmp(ds.items[i].mime_type, mime_type) == 0) { if (ds.items[i].fd_plus_one < 0) { @@ -1769,6 +1833,12 @@ open_item_tmpfile(void) { return fd; } +static int +notify_drag_data_ready_to_read(const char *mime_type) { + return notify_drag_data_ready(global_state.drag_source.from_os_window, mime_type, NULL, 0, 0); +} + + void drag_process_item_data(Window *w, size_t idx, int has_more, const uint8_t *payload, size_t payload_sz) { if ((ds.state < DRAG_SOURCE_STARTED) || idx >= ds.num_mimes || !ds.items) { @@ -1785,7 +1855,7 @@ drag_process_item_data(Window *w, size_t idx, int has_more, const uint8_t *paylo ds.items[idx].fd_plus_one = -err; ds.items[idx].data_decode_initialized = false; if (!dnd_is_test_mode()) { - int ret = notify_drag_data_ready(global_state.drag_source.from_os_window, ds.items[idx].mime_type); + int ret = notify_drag_data_ready_to_read(ds.items[idx].mime_type); if (ret) cancel_drag(w, ret, "could not notify OS that drag source item data is available"); } return; @@ -1797,7 +1867,7 @@ drag_process_item_data(Window *w, size_t idx, int has_more, const uint8_t *paylo if (ds.items[idx].fd_plus_one > 0) { if (!ds.items[idx].requested_remote_files) { if (!dnd_is_test_mode()) { - int ret = notify_drag_data_ready(global_state.drag_source.from_os_window, ds.items[idx].mime_type); + int ret = notify_drag_data_ready_to_read(ds.items[idx].mime_type); if (ret) cancel_drag(w, ret, "could not notify OS that drag source item data is available"); } } @@ -1838,7 +1908,7 @@ drag_process_item_data(Window *w, size_t idx, int has_more, const uint8_t *paylo // Notify as soon as any data is available if (!ds.items[idx].requested_remote_files) { if (!dnd_is_test_mode()) { - int ret = notify_drag_data_ready(global_state.drag_source.from_os_window, ds.items[idx].mime_type); + int ret = notify_drag_data_ready_to_read(ds.items[idx].mime_type); if (ret) cancel_drag(w, ret, "could not notify OS that drag source item data is available"); } } @@ -1881,7 +1951,7 @@ finish_remote_data(Window *w, size_t item_idx) { // fields so drag_get_data returns the full new content starting from the beginning. ds.items[item_idx].data_capacity = new_size; ds.items[item_idx].data_size = 0; - int ret = dnd_is_test_mode() ? 0 : notify_drag_data_ready(global_state.drag_source.from_os_window, ds.items[item_idx].mime_type); + int ret = dnd_is_test_mode() ? 0 : notify_drag_data_ready_to_read(ds.items[item_idx].mime_type); if (ret) abrt(ret, "could not notify OS that drag source item data is available"); } @@ -2024,20 +2094,25 @@ add_payload(Window *w, DragRemoteItem *ri, bool has_more, const uint8_t *payload } +static int +ensure_base_dir_for_drag(Window *w) { + if (!ds.base_dir_for_remote_items) { + int fd; + ds.base_dir_for_remote_items = mktempdir_in_cache("dnd-drag-", &fd); + if (!ds.base_dir_for_remote_items) return errno; + ds.base_dir_fd_plus_one = fd + 1; + detect_tempdir_case_sensitivity(ds.base_dir_for_remote_items); + } + return 0; +} + static void toplevel_data_for_drag( - Window *w, unsigned mime_item_idx, unsigned uri_item_idx, unsigned item_type, + Window *w, DragRemoteItem *ri, unsigned mime_item_idx, unsigned uri_item_idx, unsigned item_type, bool has_more, const uint8_t *payload, size_t payload_sz ) { - if (!mi.base_dir_for_remote_items) { - int fd; - mi.base_dir_for_remote_items = mktempdir_in_cache("dnd-drag-", &fd); - if (!mi.base_dir_for_remote_items) abrt(errno, "failed to create temporary directory for drag source items"); - mi.base_dir_fd_plus_one = fd + 1; - detect_tempdir_case_sensitivity(mi.base_dir_for_remote_items); - } - if (uri_item_idx >= mi.num_remote_items) abrt(EINVAL, "out of bounds uri list item index for drag source"); - DragRemoteItem *ri = mi.remote_items + uri_item_idx; + int errcode; + if ((errcode = ensure_base_dir_for_drag(w)) != 0) abrt(errcode, "failed to create temporary directory for drag source items"); if (!ri->started) { ri->started = true; ri->type = item_type; @@ -2049,12 +2124,14 @@ toplevel_data_for_drag( ri->dir_entry_name = fname; char path[32]; snprintf(path, sizeof(path), "%u", uri_item_idx); - if (mkdirat(mi.base_dir_fd_plus_one - 1, path, dir_permissions) != 0 && errno != EEXIST) abrt(errno, "failed to create directory for drag source item"); - int fd = safe_openat(mi.base_dir_fd_plus_one - 1, path, O_RDONLY | O_DIRECTORY, 0); + if (mkdirat(ds.base_dir_fd_plus_one - 1, path, dir_permissions) != 0 && errno != EEXIST) abrt(errno, "failed to create directory for drag source item"); + int fd = safe_openat(ds.base_dir_fd_plus_one - 1, path, O_RDONLY | O_DIRECTORY, 0); if (fd < 0) abrt(errno, "failed to create directory for drag source item"); ri->top_level_parent_dir_fd_plus_one = fd + 1; - free(mi.uri_list[uri_item_idx]); - mi.uri_list[uri_item_idx] = as_file_url(mi.base_dir_for_remote_items, path, ri->dir_entry_name); + if (!ds.file_promises) { + free(mi.uri_list[uri_item_idx]); + mi.uri_list[uri_item_idx] = as_file_url(ds.base_dir_for_remote_items, path, ri->dir_entry_name); + } } add_payload(w, ri, has_more, payload, payload_sz, ri->top_level_parent_dir_fd_plus_one - 1); } @@ -2097,7 +2174,7 @@ subdir_data_for_drag( DragRemoteItem *root = mi.remote_items + uri_item_idx; if (!root->dir_entry_name) abrt(EINVAL, "drag source sub directory parent dir does not exist"); size_t pos = snprintf(path, PATH_MAX, "%s/%u/%s", - mi.base_dir_for_remote_items, uri_item_idx, root->dir_entry_name); + ds.base_dir_for_remote_items, uri_item_idx, root->dir_entry_name); parent = find_by_handle(root, handle, path, &pos); if (!parent) abrt(EINVAL, "drag source sub directory parent dir handle does not exist"); mi.currently_open_subdir = parent; @@ -2144,43 +2221,60 @@ all_children_complete(DragRemoteItem *parent) { return true; } +static void +finish_file_promise(Window *w, size_t uri_item_idx, DragRemoteItem *ri) { + notify_drag_data_received(w, uri_item_idx, ri->dir_entry_name, ri->type); +} + void drag_remote_file_data( Window *w, int32_t x, int32_t y, int32_t X, int32_t Y, bool has_more, const uint8_t *payload, size_t payload_sz ) { - size_t mime_item_idx = ds.num_mimes; + if (x < 1) abrt(EINVAL, "drag source remote item x index cannot be less than 1"); + size_t mime_item_idx = ds.num_mimes + 1; for (size_t i = 0; i < ds.num_mimes; i++) { - if (ds.items[i].requested_remote_files) { + if ((ds.file_promises && ds.items[i].is_uri_list) || (!ds.file_promises && ds.items[i].is_uri_list)) { mime_item_idx = i; break; } } - if (mime_item_idx == ds.num_mimes) abrt(EINVAL, "drag source no remote data was requested"); - if (x < 1) abrt(EINVAL, "drag source remote item x index cannot be less than 1"); + if (mime_item_idx == ds.num_mimes + 1) abrt(EINVAL, ds.file_promises ? "drag source file promise no uri list present" : "drag source no remote data was requested"); const bool all_data_received = !payload_sz && !has_more; - const unsigned uri_item_idx = x - 1; - if (uri_item_idx >= mi.num_remote_items) abrt(EINVAL, "drag source uri list index out of bounds"); DragRemoteItem *ri; - if (!Y) { - toplevel_data_for_drag(w, mime_item_idx, uri_item_idx, X, has_more, payload, payload_sz); - if (all_data_received) { - ri = mi.remote_items + uri_item_idx; - if (ri->waiting_for_completion && all_children_complete(ri)) { - ri->completed = true; - finish_remote_data_if_all_items_received(w, mime_item_idx); + const unsigned uri_item_idx = x - 1; + if (ds.file_promises) { + size_t promise_item_idx = ds.file_promises_count + 1; + for (size_t i = 0; i < ds.file_promises_count; i++) { + if (ds.file_promises[i].uri_item_idx == uri_item_idx) { + promise_item_idx = i; + break; } } + if (promise_item_idx == ds.file_promises_count + 1) abrt(EINVAL, "drag source file promise uri list index out of bounds"); + ri = &ds.file_promises[promise_item_idx].ri; + } else { + if (uri_item_idx >= mi.num_remote_items) abrt(EINVAL, "drag source uri list index out of bounds"); + ri = mi.remote_items + uri_item_idx; + } + if (!Y) { + toplevel_data_for_drag(w, ri, mime_item_idx, uri_item_idx, X, has_more, payload, payload_sz); + if (all_data_received && all_children_complete(ri)) { + ri->completed = true; + if (ds.file_promises) finish_file_promise(w, uri_item_idx, ri); + else if (ri->waiting_for_completion) finish_remote_data_if_all_items_received(w, mime_item_idx); + } } else { if (y < 1) abrt(EINVAL, "drag source remote item y index cannot be less than 1"); - subdir_data_for_drag(w, mime_item_idx, x - 1, Y, y - 1, X, has_more, payload, payload_sz, &ri); + subdir_data_for_drag(w, mime_item_idx, uri_item_idx, Y, y - 1, X, has_more, payload, payload_sz, &ri); if (all_data_received && ri && all_children_complete(ri)) { ri->completed = true; while (1) { - ri = ri->parent; - if (ri) { + if (ri->parent) { + ri = ri->parent; if (all_children_complete(ri)) ri->completed = true; else break; } else { - finish_remote_data_if_all_items_received(w, mime_item_idx); + if (ds.file_promises) finish_file_promise(w, uri_item_idx, ri); + else finish_remote_data_if_all_items_received(w, mime_item_idx); break; } } diff --git a/kitty/glfw-wrapper.h b/kitty/glfw-wrapper.h index 6198a6714..e5cefc6fc 100644 --- a/kitty/glfw-wrapper.h +++ b/kitty/glfw-wrapper.h @@ -1556,6 +1556,7 @@ typedef struct GLFWDragSourceItem { const char *optional_data; size_t data_size; bool is_remote_client; + int type; // used for file promises type of entry 0 = regular, 1 = symlink, 2 = directory } GLFWDragSourceItem; typedef struct GLFWDragEvent { diff --git a/kitty/glfw.c b/kitty/glfw.c index 047a14256..9e4a396ff 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -998,10 +998,12 @@ drag_source_callback(GLFWwindow *window UNUSED, GLFWDragEvent *ev) { #undef ds int -notify_drag_data_ready(id_type os_window_id, const char *mime_type) { +notify_drag_data_ready(id_type os_window_id, const char *mime_type, const char *data, size_t data_sz, int type) { OSWindow *w = os_window_for_id(os_window_id); - GLFWDragSourceItem item = {.mime_type = mime_type}; - if (w && w->handle) return glfwStartDrag(w->handle, &item, 1, NULL, -1, false); + if (w && w->handle) { + GLFWDragSourceItem item = {.mime_type = mime_type, .optional_data = data, .data_size = data_sz, .type = type}; + return glfwStartDrag(w->handle, &item, 1, NULL, -1, false); + } return ENOENT; } diff --git a/kitty/state.h b/kitty/state.h index a4e65a49d..1610bb613 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -329,7 +329,6 @@ typedef struct Window { char** uri_list; size_t num_uris; DragRemoteItem *remote_items; size_t num_remote_items; DragRemoteItem *currently_open_subdir; - char *base_dir_for_remote_items; int base_dir_fd_plus_one; } *items; struct { int width, height, fmt, opacity; uint8_t *data; size_t sz, capacity; bool started; base64_state base64_state; @@ -340,6 +339,10 @@ typedef struct Window { DragSourceState state; PendingData pending; uint32_t client_id; + char *base_dir_for_remote_items; + int base_dir_fd_plus_one; + struct { size_t uri_item_idx; DragRemoteItem ri; } *file_promises; + size_t file_promises_count, file_promises_capacity; } drag_source; } Window; @@ -640,5 +643,5 @@ void request_drop_data(OSWindow *w, id_type wid, const char* mime); void cancel_current_drag_source(void); bool change_drag_image(int idx); int start_window_drag(Window *w, bool in_test_mode); -int notify_drag_data_ready(id_type os_window_id, const char *mime_type); +int notify_drag_data_ready(id_type os_window_id, const char *mime_type, const char *data, size_t data_sz, int type); BackgroundImage* background_image_for_os_window(OSWindow *w); From c909809bb48e413adc482f98a2ce94cbe3fc6f30 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 13 May 2026 08:25:14 +0530 Subject: [PATCH 18/25] More work on DnD kitten --- glfw/cocoa_window.m | 29 +++++++++++++++++++++++++++-- glfw/input.c | 2 +- glfw/internal.h | 2 +- glfw/null_window.c | 3 ++- glfw/wl_window.c | 2 +- glfw/x11_window.c | 2 +- kitty/dnd.c | 2 +- 7 files changed, 34 insertions(+), 8 deletions(-) diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index a3c890922..09ba085f1 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -878,6 +878,7 @@ static void _glfwUpdateNotchCover(_GLFWwindow*); - (instancetype)initWithWindow:(_GLFWwindow*)initWindow mimeType:(const char*)mime instanceId:(GLFWid)iid; - (void)request_drag_data; +- (void)promised_data_ready:(const char*)data sz:(size_t)sz type:(int)type; - (void)end_transfer:(int)errorCode; - (void)end_transfer_with_error:(NSError*)err; - (bool)is_mimetype:(const char*)mime_type; @@ -4460,6 +4461,24 @@ _glfwPlatformStartDrag(_GLFWwindow* window, const GLFWimage* thumbnail) {@autore - (bool)is_mimetype:(const char*)q { return strcmp(q, mimeType) == 0; } +- (void)promised_data_ready:(const char*)path sz:(size_t)sz type:(int)type { + if (file_handle) [file_handle release]; + file_handle = nil; + // TODO: erase file at file_url + switch (type) { + case 0: + // Create a hard link to path at file_url + break; + case 1: + // Create a symlink to the same destination as the symlink at path + default: + // copy the directory at path to file_url recursively using hard + // links for files. + break; + } + [self end_transfer:0]; +} + - (void)request_drag_data { if (instanceId != _glfw.drag.instance_id) { [self end_transfer:EINVAL]; return; } _GLFWwindow *window = _glfwWindowForId(_glfw.drag.window_id); @@ -4506,6 +4525,8 @@ _glfwPlatformStartDrag(_GLFWwindow* window, const GLFWimage* thumbnail) {@autore - (void)dealloc { free(mimeType); mimeType = NULL; + if (file_handle) [file_handle release]; + file_handle = nil; if (file_url) [file_url release]; file_url = nil; [self end_transfer:EINVAL]; @@ -4602,11 +4623,15 @@ _glfwPlatformChangeDragImage(const GLFWimage *thumbnail) {@autoreleasepool{ return 0; }} + int -_glfwPlatformDragDataReady(const char *mime_type) { +_glfwPlatformDragDataReady(const char *mime_type, const char *data, size_t sz, int type) { if (!file_promise_providers) return 0; for (GLFWFilePromiseProviderDelegate *d in file_promise_providers) { - if ([d is_mimetype:mime_type]) [d request_drag_data]; + if ([d is_mimetype:mime_type]) { + if (type == -1) [d request_drag_data]; + else [d promised_data_ready:data sz:sz type:type]; + } } return 0; } diff --git a/glfw/input.c b/glfw/input.c index 314c58299..f370129e4 100644 --- a/glfw/input.c +++ b/glfw/input.c @@ -1190,7 +1190,7 @@ glfwStartDrag(GLFWwindow* handle, const GLFWDragSourceItem *items, size_t item_c _GLFWwindow* window = (_GLFWwindow*) handle; assert(window != NULL); _GLFW_REQUIRE_INIT_OR_RETURN(EINVAL); - if (operations == -1) return _glfwPlatformDragDataReady(items[0].mime_type); + if (operations == -1) return _glfwPlatformDragDataReady(items[0].mime_type, items[0].optional_data, items[0].data_size, items[0].type); if (operations == -2) return _glfwPlatformChangeDragImage(thumbnail); if (operations == -3) { _glfwPlatformCancelDrag(window); return 0; } _glfwFreeDragSourceData(); diff --git a/glfw/internal.h b/glfw/internal.h index 535d50054..9d3c19183 100644 --- a/glfw/internal.h +++ b/glfw/internal.h @@ -840,7 +840,7 @@ void _glfwPlatformCancelDrag(_GLFWwindow* window); void _glfwFreeDragSourceData(void); void _glfwPlatformFreeDragSourceData(void); void _glfwInputDragSourceRequest(_GLFWwindow* window, GLFWDragEvent *ev); -int _glfwPlatformDragDataReady(const char *mime_type); +int _glfwPlatformDragDataReady(const char *mime_type, const char *data, size_t sz, int type); int _glfwPlatformChangeDragImage(const GLFWimage *thumbnail); diff --git a/glfw/null_window.c b/glfw/null_window.c index 1c3c90b00..fcba784a6 100644 --- a/glfw/null_window.c +++ b/glfw/null_window.c @@ -547,7 +547,8 @@ _glfwPlatformStartDrag(_GLFWwindow* window, const GLFWimage* thumbnail) { return ENOTSUP; } void _glfwPlatformFreeDragSourceData(void) {} -int _glfwPlatformDragDataReady(const char *mime_type) { (void) mime_type; return 0; } +int +_glfwPlatformDragDataReady(const char *mime_type UNUSED, const char *data UNUSED, size_t sz UNUSED, int type UNUSED) { return 0; } int _glfwPlatformChangeDragImage(const GLFWimage *thumbnail) { (void)thumbnail; return 0; } const char** _glfwPlatformGetDropMimeTypes(GLFWDropData* drop UNUSED, int* count) diff --git a/glfw/wl_window.c b/glfw/wl_window.c index 4d2fa7150..9d0dbeb4c 100644 --- a/glfw/wl_window.c +++ b/glfw/wl_window.c @@ -3343,7 +3343,7 @@ _glfwPlatformChangeDragImage(const GLFWimage *thumbnail) { } int -_glfwPlatformDragDataReady(const char *mime_type) { +_glfwPlatformDragDataReady(const char *mime_type, const char *data UNUSED, size_t sz UNUSED, int type UNUSED) { for (size_t i = 0; i < _glfw.wl.drag.count; i++) { if (strcmp(dr.mime_type, mime_type) == 0) { if (!dr.watch_id) dr.watch_id = add_drag_watch(dr.fd); diff --git a/glfw/x11_window.c b/glfw/x11_window.c index 718f24bcc..ecc94ae2e 100644 --- a/glfw/x11_window.c +++ b/glfw/x11_window.c @@ -4614,7 +4614,7 @@ _glfwPlatformChangeDragImage(const GLFWimage *thumbnail) { } int -_glfwPlatformDragDataReady(const char *mime_type) { +_glfwPlatformDragDataReady(const char *mime_type, const char *data UNUSED, size_t sz UNUSED, int type UNUSED) { // Find the pending request for this MIME type for (size_t i = 0; i < _glfw.x11.drag.pending_count; i++) { if (_glfw.x11.drag.pending_requests[i].inflight && diff --git a/kitty/dnd.c b/kitty/dnd.c index 2ebb0e35f..15d2536f4 100644 --- a/kitty/dnd.c +++ b/kitty/dnd.c @@ -1835,7 +1835,7 @@ open_item_tmpfile(void) { static int notify_drag_data_ready_to_read(const char *mime_type) { - return notify_drag_data_ready(global_state.drag_source.from_os_window, mime_type, NULL, 0, 0); + return notify_drag_data_ready(global_state.drag_source.from_os_window, mime_type, NULL, 0, -1); } From 217a01cfdf9bc9b747b592f87444dc896f383044 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 03:03:47 +0000 Subject: [PATCH 19/25] Implement promised_data_ready() in glfw/cocoa_window.m Agent-Logs-Url: https://github.com/kovidgoyal/kitty/sessions/38f75cd7-b61b-42b4-9d28-9a40459a9536 Co-authored-by: kovidgoyal <1308621+kovidgoyal@users.noreply.github.com> --- glfw/cocoa_window.m | 86 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 5 deletions(-) diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index 09ba085f1..9e59ab861 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -4462,19 +4462,95 @@ _glfwPlatformStartDrag(_GLFWwindow* window, const GLFWimage* thumbnail) {@autore - (bool)is_mimetype:(const char*)q { return strcmp(q, mimeType) == 0; } - (void)promised_data_ready:(const char*)path sz:(size_t)sz type:(int)type { + (void)sz; if (file_handle) [file_handle release]; file_handle = nil; - // TODO: erase file at file_url + NSFileManager *fileManager = [NSFileManager defaultManager]; + // Erase the empty placeholder file created by writePromiseToURL: + [fileManager removeItemAtURL:file_url error:nil]; + NSError *error = nil; + NSURL *srcURL = [NSURL fileURLWithPath:@(path)]; switch (type) { case 0: // Create a hard link to path at file_url + if (![fileManager linkItemAtURL:srcURL toURL:file_url error:&error]) { + [self end_transfer_with_error:error]; + return; + } break; - case 1: + case 1: { // Create a symlink to the same destination as the symlink at path - default: - // copy the directory at path to file_url recursively using hard - // links for files. + NSString *linkDest = [fileManager destinationOfSymbolicLinkAtPath:@(path) error:&error]; + if (!linkDest) { + [self end_transfer_with_error:error]; + return; + } + if (![fileManager createSymbolicLinkAtPath:file_url.path withDestinationPath:linkDest error:&error]) { + [self end_transfer_with_error:error]; + return; + } break; + } + default: { + // Copy the directory at path to file_url recursively using hard links for files + if (![fileManager createDirectoryAtURL:file_url withIntermediateDirectories:YES attributes:nil error:&error]) { + [self end_transfer_with_error:error]; + return; + } + NSString *srcPath = srcURL.path; + NSString *dstPath = file_url.path; + __block NSError *enumError = nil; + NSDirectoryEnumerator *enumerator = [fileManager + enumeratorAtURL:srcURL + includingPropertiesForKeys:@[NSURLIsDirectoryKey, NSURLIsSymbolicLinkKey] + options:0 + errorHandler:^BOOL(NSURL *url, NSError *err) { + (void)url; + enumError = err; + return NO; + }]; + for (NSURL *itemURL in enumerator) { + NSString *itemPath = itemURL.path; + if (itemPath.length <= srcPath.length) continue; + NSString *relativePath = [itemPath substringFromIndex:srcPath.length + 1]; + NSURL *destURL = [NSURL fileURLWithPath:[dstPath stringByAppendingPathComponent:relativePath]]; + NSNumber *isSymlink = nil, *isDirectory = nil; + if (![itemURL getResourceValue:&isSymlink forKey:NSURLIsSymbolicLinkKey error:&error]) { + [self end_transfer_with_error:error]; + return; + } + if (![itemURL getResourceValue:&isDirectory forKey:NSURLIsDirectoryKey error:&error]) { + [self end_transfer_with_error:error]; + return; + } + if ([isSymlink boolValue]) { + NSString *linkDest = [fileManager destinationOfSymbolicLinkAtPath:itemPath error:&error]; + if (!linkDest) { + [self end_transfer_with_error:error]; + return; + } + if (![fileManager createSymbolicLinkAtPath:destURL.path withDestinationPath:linkDest error:&error]) { + [self end_transfer_with_error:error]; + return; + } + } else if ([isDirectory boolValue]) { + if (![fileManager createDirectoryAtURL:destURL withIntermediateDirectories:YES attributes:nil error:&error]) { + [self end_transfer_with_error:error]; + return; + } + } else { + if (![fileManager linkItemAtURL:itemURL toURL:destURL error:&error]) { + [self end_transfer_with_error:error]; + return; + } + } + } + if (enumError) { + [self end_transfer_with_error:enumError]; + return; + } + break; + } } [self end_transfer:0]; } From c2a7225657f208000f1a261de1b6c6e7543b5fcb Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 13 May 2026 10:59:44 +0530 Subject: [PATCH 20/25] Ensure drag finish timer doesnt fire will waiting for remote item data --- glfw/cocoa_window.m | 6 +++++- kitty/dnd.c | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index 9e59ab861..fce4b72fd 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -4462,7 +4462,11 @@ _glfwPlatformStartDrag(_GLFWwindow* window, const GLFWimage* thumbnail) {@autore - (bool)is_mimetype:(const char*)q { return strcmp(q, mimeType) == 0; } - (void)promised_data_ready:(const char*)path sz:(size_t)sz type:(int)type { - (void)sz; + if (drag_finish_timer) { + [drag_finish_timer invalidate]; + drag_finish_timer = nil; + } + if (path == NULL) return; // progress update if (file_handle) [file_handle release]; file_handle = nil; NSFileManager *fileManager = [NSFileManager defaultManager]; diff --git a/kitty/dnd.c b/kitty/dnd.c index 15d2536f4..28d237228 100644 --- a/kitty/dnd.c +++ b/kitty/dnd.c @@ -1666,6 +1666,7 @@ static int notify_drag_data_received(Window *w, size_t uri_item_idx, const char *basename, int type) { char mime_type[128], path[4096]; snprintf(mime_type, sizeof(mime_type), "kitty-internal/uri-list-item-%zu", uri_item_idx); + if (!basename || !basename[0]) notify_drag_data_ready(global_state.drag_source.from_os_window, mime_type, NULL, 0, type); int sz = snprintf(path, sizeof(path), "%s/%zu/%s", ds.base_dir_for_remote_items, uri_item_idx, basename); return notify_drag_data_ready(global_state.drag_source.from_os_window, mime_type, path, sz, type); } @@ -2251,6 +2252,7 @@ drag_remote_file_data( } if (promise_item_idx == ds.file_promises_count + 1) abrt(EINVAL, "drag source file promise uri list index out of bounds"); ri = &ds.file_promises[promise_item_idx].ri; + notify_drag_data_received(w, uri_item_idx, "", ri->type); } else { if (uri_item_idx >= mi.num_remote_items) abrt(EINVAL, "drag source uri list index out of bounds"); ri = mi.remote_items + uri_item_idx; From 8b85bc34403e2ab562cf97cb8fbc252b29e9569a Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 13 May 2026 11:51:18 +0530 Subject: [PATCH 21/25] Cleanup previous PR --- docs/changelog.rst | 2 ++ kitty/fast_data_types.pyi | 1 + 2 files changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index beb5c4873..1191c3ed8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -276,6 +276,8 @@ Detailed list of changes - ssh kitten: Sanitize user controlled data in error messages that might leak to shell (:cve:`2026-42850`) +- Linux: Respect the fontconfig matrix setting commonly used for fake slant with fonts that do not have italic variants (:pull:`9990`) + 0.46.2 [2026-03-21] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index 54b53959f..1d70c5255 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -423,6 +423,7 @@ class FontConfigPattern(TypedDict): scalable: bool outline: bool color: bool + matrix: tuple[float, float, float, float] variable: bool named_instance: bool From 8b17088b5893c9f86c28c4b640b22c8c04f4b5e5 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 13 May 2026 12:35:10 +0530 Subject: [PATCH 22/25] dnd fix remote drag source premature removal of remote file data Instead the remote data is removed from the cache on: 1) new drag start 2) window destruction 3) application exit --- kitty/dnd.c | 26 ++++++++++++++++---------- kitty/dnd.h | 2 +- kitty/screen.c | 2 +- kitty/state.c | 2 +- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/kitty/dnd.c b/kitty/dnd.c index 28d237228..edd14bb58 100644 --- a/kitty/dnd.c +++ b/kitty/dnd.c @@ -1254,8 +1254,17 @@ drag_free_remote_item(DragRemoteItem *x) { zero_at_ptr(x); } +static void +delete_basedir_for_remote_items(Window *w) { + if (ds.base_dir_fd_plus_one) { + rmtree_best_effort(".", ds.base_dir_fd_plus_one - 1); + ds.base_dir_fd_plus_one = 0; + } + free(ds.base_dir_for_remote_items); ds.base_dir_for_remote_items = NULL; +} + void -drag_free_offer(Window *w) { +drag_free_offer(Window *w, bool remove_remote_items) { free(ds.mimes_buf); ds.mimes_buf = NULL; ds.bufsz = 0; if (ds.items) { for (size_t i=0; i < ds.num_mimes; i++) { @@ -1285,11 +1294,6 @@ drag_free_offer(Window *w) { ds.state = DRAG_SOURCE_NONE; ds.pre_sent_total_sz = 0; ds.images_sent_total_sz = 0; - if (ds.base_dir_fd_plus_one) { - rmtree_best_effort(".", ds.base_dir_fd_plus_one - 1); - ds.base_dir_fd_plus_one = 0; - } - free(ds.base_dir_for_remote_items); ds.base_dir_for_remote_items = NULL; if (ds.file_promises) { for (size_t i = 0; i < ds.file_promises_count; i++) { drag_free_remote_item(&ds.file_promises[i].ri); @@ -1298,6 +1302,7 @@ drag_free_offer(Window *w) { ds.file_promises = NULL; } ds.file_promises_count = 0; ds.file_promises_capacity = 0; + if (remove_remote_items) delete_basedir_for_remote_items(w); } static void @@ -1315,7 +1320,7 @@ static void cancel_drag(Window *w, int error_code, const char *details) { if (error_code) drag_send_error(w, error_code, details); if (global_state.drag_source.is_active && global_state.drag_source.from_window == w->id) cancel_current_drag_source(); - drag_free_offer(w); + if (error_code) drag_free_offer(w, true); } #define abrt(code, details) { cancel_drag(w, code, details); return; } @@ -1328,7 +1333,7 @@ drag_start_offerring(Window *w, const char *client_machine_id, size_t sz) { void drag_stop_offerring(Window *w) { - drag_free_offer(w); + drag_free_offer(w, true); ds.can_offer = false; ds.is_remote_client = false; } @@ -1515,6 +1520,7 @@ parse_uri_list(Window *w, char *data, const ssize_t sz, size_t *num_uris_out) { void drag_start(Window *w) { if (ds.state != DRAG_SOURCE_BEING_BUILT) abrt(EINVAL, "cannot start drag as drag source is not being built"); + delete_basedir_for_remote_items(w); size_t total_size = 0; for (size_t idx = 0; idx < arraysz(ds.images); idx++) { if (img.sz) { @@ -1638,7 +1644,7 @@ drag_notify(Window *w, DragNotifyType type) { sz += snprintf(buf + sz, sizeof(buf) - sz, ":y=%d", global_state.drag_source.was_canceled ? 1 : 0); break; } queue_payload_to_child(w->id, w->drag_source.client_id, &w->drag_source.pending, buf, sz, NULL, 0, false); - if (type == DRAG_NOTIFY_FINISHED) drag_free_offer(w); + if (type == DRAG_NOTIFY_FINISHED) drag_free_offer(w, false); } int @@ -2304,7 +2310,7 @@ static void destroy_fake_window_contents(Window *w) { // Free window resources without touching GPU objects (none allocated for fake windows). drop_free_data(w); - drag_free_offer(w); + drag_free_offer(w, false); free(w->pending_clicks.clicks); zero_at_ptr(&w->pending_clicks); free(w->buffered_keys.key_data); zero_at_ptr(&w->buffered_keys); Py_CLEAR(w->render_data.screen); diff --git a/kitty/dnd.h b/kitty/dnd.h index d2a172c21..86833d549 100644 --- a/kitty/dnd.h +++ b/kitty/dnd.h @@ -24,7 +24,7 @@ void drop_dispatch_data(Window *w, const char *mime_type, const char *data, ssiz void drop_finish(Window *w); typedef enum { DRAG_NOTIFY_ACCEPTED, DRAG_NOTIFY_ACTION_CHANGED, DRAG_NOTIFY_DROPPED, DRAG_NOTIFY_FINISHED } DragNotifyType; -void drag_free_offer(Window *w); +void drag_free_offer(Window *w, bool remove_remote_items); void drag_add_mimes(Window *w, int allowed_operations, uint32_t client_id, const char *data, size_t sz, bool has_more); void drag_add_pre_sent_data(Window *w, unsigned idx, const uint8_t *payload, size_t sz); void drag_add_image(Window *w, unsigned idx_, int fmt, int width, int height, int opacity, const uint8_t *payload, size_t sz); diff --git a/kitty/screen.c b/kitty/screen.c index b13d9cb04..bcc5579d9 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -1573,7 +1573,7 @@ screen_handle_dnd_command(Screen *self, const DnDCommand *cmd_, const uint8_t *p } break; case 'E': { if (cmd->cell_y == -1) { - drag_free_offer(w); + drag_free_offer(w, true); if (global_state.drag_source.is_active && global_state.drag_source.from_window == w->id) { cancel_current_drag_source(); } diff --git a/kitty/state.c b/kitty/state.c index 63f7cffd9..d4a122e47 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -404,7 +404,7 @@ update_os_window_title(OSWindow *os_window) { static void destroy_window(Window *w) { drop_free_data(w); - drag_free_offer(w); + drag_free_offer(w, true); free(w->pending_clicks.clicks); zero_at_ptr(&w->pending_clicks); free(w->buffered_keys.key_data); zero_at_ptr(&w->buffered_keys); Py_CLEAR(w->render_data.screen); Py_CLEAR(w->title); From 56f276580f615d0663d32c79f592832366740791 Mon Sep 17 00:00:00 2001 From: gogongxt Date: Wed, 13 May 2026 17:09:42 +0800 Subject: [PATCH 23/25] Fix hints kitten breaking multi-byte UTF-8 characters when overlaying hint labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In highlight_mark(), mark_text was sliced using byte-based indexing (mark_text[:len(hint)] and mark_text[len(hint):]). Since len(hint) equals the rune count (hint is always ASCII), but len(mark_text) is a byte count, this could slice in the middle of a multi-byte UTF-8 sequence (e.g. CJK characters), producing an invalid byte sequence rendered as the Unicode replacement character (�). Fix by converting mark_text to []rune first, then slicing at rune boundaries. The hint is ASCII so len(hint) == rune count, requiring no conversion on the hint side. Co-Authored-By: Claude Opus 4.7 --- kittens/hints/main.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/kittens/hints/main.go b/kittens/hints/main.go index 8d4142f8b..e8249c12c 100644 --- a/kittens/hints/main.go +++ b/kittens/hints/main.go @@ -200,10 +200,12 @@ func main(_ *cli.Command, o *Options, args []string) (rc int, err error) { if hint == "" { hint = " " } - if len(mark_text) <= len(hint) { + hint_runes := len(hint) + runes := []rune(mark_text) + if len(runes) <= hint_runes { mark_text = "" } else { - replaced_text := mark_text[:len(hint)] + replaced_text := string(runes[:hint_runes]) replaced_text = strings.ReplaceAll(replaced_text, "\r", "\n") if strings.Contains(replaced_text, "\n") { buf := strings.Builder{} @@ -224,7 +226,7 @@ func main(_ *cli.Command, o *Options, args []string) (rc int, err error) { } hint = buf.String() } - mark_text = mark_text[len(hint):] + mark_text = string(runes[hint_runes:]) } ans := hint_style(hint) + text_style(mark_text) return fmt.Sprintf("\x1b]8;;mark:%d\a%s\x1b]8;;\a", m.Index, ans) From a28abf15738d2df0a665ced1e811e2e76b35ac94 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 13 May 2026 15:03:17 +0530 Subject: [PATCH 24/25] Ignore inapplicable CVE --- .github/workflows/ci.py | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.py b/.github/workflows/ci.py index 357266214..a936c908d 100644 --- a/.github/workflows/ci.py +++ b/.github/workflows/ci.py @@ -232,6 +232,7 @@ IGNORED_DEPENDENCY_CVES = [ 'CVE-2026-4224', 'CVE-2026-4519', 'CVE-2026-1502', + 'CVE-2026-7210', # DoS in unused XML parser # github.com/nwaples/rardecode/v2 'CVE-2025-11579', # rardecode is version 2.2.1, not vulnerable 'CVE-2026-2673', # openssl fix not released From a771b1d7e511d6be8790e79e55a60388ed19d224 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 14 May 2026 06:13:53 +0530 Subject: [PATCH 25/25] Expand ~ when processing args for dnd kitten --- kittens/dnd/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/kittens/dnd/main.go b/kittens/dnd/main.go index 4707da2cd..241a50575 100644 --- a/kittens/dnd/main.go +++ b/kittens/dnd/main.go @@ -381,6 +381,7 @@ func dnd_main(cmd *cli.Command, opts *Options, args []string) (rc int, err error } var uri_list []uri_list_item for _, arg := range args { + arg = utils.Expanduser(arg) st, err := os.Lstat(arg) if err != nil { return 1, err