From 0195f0f5f6ffab3261fed2a26bee4796df3f4332 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 7 Nov 2025 09:36:57 +0530 Subject: [PATCH 01/53] auto fixes to github actions by zizmor --- .github/dependabot.yml | 4 ++++ .github/workflows/codeql-analysis.yml | 1 + 2 files changed, 5 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d9a92048adc..5f74b1389cf 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,6 +13,8 @@ updates: all-go-deps: patterns: - "*" # group all non-security update PRs + cooldown: + default-days: 7 - package-ecosystem: "github-actions" directory: "/" schedule: @@ -21,3 +23,5 @@ updates: actions: patterns: - "*" + cooldown: + default-days: 7 \ No newline at end of file diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 889636eea80..4eab20d795c 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -44,6 +44,7 @@ jobs: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. fetch-depth: 2 + persist-credentials: false - name: Install Go if: matrix.language == 'c' || matrix.language == 'go' From f8c78909faa2bb9dffa76ed8092ce0d3a71047b4 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 7 Nov 2025 11:33:13 +0530 Subject: [PATCH 02/53] Use an explicit APNG identifier for apng files with ImageMagick Required as per Imagemgick docs: https://imagemagick.org/script/formats.php --- kittens/icat/magick.go | 8 ++++---- tools/utils/images/loading.go | 28 +++++++++++++++++++++------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/kittens/icat/magick.go b/kittens/icat/magick.go index 7b5047cb9ec..a738ab54637 100644 --- a/kittens/icat/magick.go +++ b/kittens/icat/magick.go @@ -12,9 +12,9 @@ import ( var _ = fmt.Print -func Render(path string, ro *images.RenderOptions, frames []images.IdentifyRecord) (ans []*image_frame, err error) { +func Render(path, original_file_path string, ro *images.RenderOptions, frames []images.IdentifyRecord) (ans []*image_frame, err error) { ro.TempfilenameTemplate = shm_template - image_frames, filenames, err := images.RenderWithMagick(path, ro, frames) + image_frames, filenames, err := images.RenderWithMagick(path, original_file_path, ro, frames) if err == nil { ans = make([]*image_frame, len(image_frames)) for i, x := range image_frames { @@ -41,7 +41,7 @@ func render_image_with_magick(imgd *image_data, src *opened_input) (err error) { if err != nil { return err } - frames, err := images.IdentifyWithMagick(src.FileSystemName()) + frames, err := images.IdentifyWithMagick(src.FileSystemName(), imgd.source_name) if err != nil { return err } @@ -56,7 +56,7 @@ func render_image_with_magick(imgd *image_data, src *opened_input) (err error) { if scale_image(imgd) { ro.ResizeTo.X, ro.ResizeTo.Y = imgd.canvas_width, imgd.canvas_height } - imgd.frames, err = Render(src.FileSystemName(), &ro, frames) + imgd.frames, err = Render(src.FileSystemName(), imgd.source_name, &ro, frames) if err != nil { return err } diff --git a/tools/utils/images/loading.go b/tools/utils/images/loading.go index b8fb9f0d9a0..337bfb3073c 100644 --- a/tools/utils/images/loading.go +++ b/tools/utils/images/loading.go @@ -302,9 +302,13 @@ func check_resize(frame *ImageFrame, filename string) error { } expected_size := bytes_per_pixel * frame.Width * frame.Height if sz < expected_size { + if bytes_per_pixel == 4 && sz == 3*frame.Width*frame.Height { + frame.Is_opaque = true + return nil + } missing := expected_size - sz if missing%(bytes_per_pixel*frame.Width) != 0 { - return fmt.Errorf("ImageMagick failed to resize correctly. It generated %d < %d of data (w=%d h=%d bpp=%d)", sz, expected_size, frame.Width, frame.Height, bytes_per_pixel) + return fmt.Errorf("ImageMagick failed to resize correctly. It generated %d < %d of data (w=%d h=%d bpp=%d frame-number: %d)", sz, expected_size, frame.Width, frame.Height, bytes_per_pixel, frame.Number) } frame.Height -= missing / (bytes_per_pixel * frame.Width) } @@ -439,11 +443,16 @@ func parse_identify_record(ans *IdentifyRecord, raw *IdentifyOutput) (err error) return } -func IdentifyWithMagick(path string) (ans []IdentifyRecord, err error) { +func IdentifyWithMagick(path, original_file_path string) (ans []IdentifyRecord, err error) { cmd := []string{"identify"} q := `{"fmt":"%m","canvas":"%g","transparency":"%A","gap":"%T","index":"%p","size":"%wx%h",` + `"dpi":"%xx%y","dispose":"%D","orientation":"%[EXIF:Orientation]"},` - cmd = append(cmd, "-format", q, "--", path) + ext := filepath.Ext(original_file_path) + ipath := path + if strings.ToLower(ext) == ".apng" { + ipath = "APNG:" + path + } + cmd = append(cmd, "-format", q, "--", ipath) output, err := RunMagick(path, cmd) if err != nil { return nil, fmt.Errorf("Failed to identify image at path: %s with error: %w", path, err) @@ -476,10 +485,15 @@ type RenderOptions struct { TempfilenameTemplate string } -func RenderWithMagick(path string, ro *RenderOptions, frames []IdentifyRecord) (ans []*ImageFrame, fmap map[int]string, err error) { +func RenderWithMagick(path, original_file_path string, ro *RenderOptions, frames []IdentifyRecord) (ans []*ImageFrame, fmap map[int]string, err error) { cmd := []string{"convert"} ans = make([]*ImageFrame, 0, len(frames)) fmap = make(map[int]string, len(frames)) + ext := filepath.Ext(original_file_path) + ipath := path + if strings.ToLower(ext) == ".apng" { + ipath = "APNG:" + path + } defer func() { if err != nil { @@ -500,7 +514,7 @@ func RenderWithMagick(path string, ro *RenderOptions, frames []IdentifyRecord) ( if ro.Flop { cmd = append(cmd, "-flop") } - cpath := path + cpath := ipath if ro.OnlyFirstFrame { cpath += "[0]" } @@ -621,11 +635,11 @@ func RenderWithMagick(path string, ro *RenderOptions, frames []IdentifyRecord) ( } func OpenImageFromPathWithMagick(path string) (ans *ImageData, err error) { - identify_records, err := IdentifyWithMagick(path) + identify_records, err := IdentifyWithMagick(path, path) if err != nil { return nil, fmt.Errorf("Failed to identify image at %#v with error: %w", path, err) } - frames, filenames, err := RenderWithMagick(path, &RenderOptions{}, identify_records) + frames, filenames, err := RenderWithMagick(path, path, &RenderOptions{}, identify_records) if err != nil { return nil, fmt.Errorf("Failed to render image at %#v with error: %w", path, err) } From 375aeae68998113ec9dee879c388a05d59199081 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 7 Nov 2025 14:44:26 +0530 Subject: [PATCH 03/53] Clean up imagemagick disposal processing --- tools/utils/images/loading.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tools/utils/images/loading.go b/tools/utils/images/loading.go index 337bfb3073c..9714d7aa1a1 100644 --- a/tools/utils/images/loading.go +++ b/tools/utils/images/loading.go @@ -420,9 +420,7 @@ func parse_identify_record(ans *IdentifyRecord, raw *IdentifyOutput) (err error) } ans.Needs_blend = q == "blend" switch strings.ToLower(raw.Dispose) { - case "undefined": - ans.Disposal = 0 - case "none": + case "none", "undefined": ans.Disposal = gif.DisposalNone case "background": ans.Disposal = gif.DisposalBackground @@ -626,10 +624,16 @@ func RenderWithMagick(path, original_file_path string, ro *RenderOptions, frames return } slices.SortFunc(ans, func(a, b *ImageFrame) int { return a.Number - b.Number }) - anchor_frame := uint(1) + prev_disposal := gif.DisposalBackground + prev_compose_onto := 0 for i, frame := range ans { - af, co := gifmeta.SetGIFFrameDisposal(uint(frame.Number), anchor_frame, byte(frames[i].Disposal)) - anchor_frame, frame.Compose_onto = af, int(co) + switch prev_disposal { + case gif.DisposalNone: + frame.Compose_onto = frame.Number - 1 + case gif.DisposalPrevious: + frame.Compose_onto = prev_compose_onto + } + prev_disposal, prev_compose_onto = frames[i].Disposal, frame.Compose_onto } return } From b6c3841beb113b0cd8ae771c1a787759aa36d235 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 7 Nov 2025 15:06:08 +0530 Subject: [PATCH 04/53] ... --- kitty/launch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kitty/launch.py b/kitty/launch.py index 948fcb05eed..233fedf5491 100644 --- a/kitty/launch.py +++ b/kitty/launch.py @@ -134,7 +134,7 @@ def options_spec() -> str: --cwd completion=type:directory kwds:current,oldest,last_reported,root The working directory for the newly launched child. Use the special value -:code:`current` to use the working directory of the :option:`source window ` +:code:`current` to use the working directory of the :option:`source window `. The special value :code:`last_reported` uses the last working directory reported by the shell (needs :ref:`shell_integration` to work). The special value :code:`oldest` works like :code:`current` but uses the working directory of the From 08784b1758945ac71f335f1949c742b847722887 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 7 Nov 2025 17:30:13 +0530 Subject: [PATCH 05/53] Bump imaging version --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 7dd4009cb63..ce6ce1ae101 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/google/uuid v1.6.0 github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1 github.com/kovidgoyal/go-parallel v1.1.1 - github.com/kovidgoyal/imaging v1.8.2 + github.com/kovidgoyal/imaging v1.8.3 github.com/seancfoley/ipaddress-go v1.7.1 github.com/shirou/gopsutil/v4 v4.25.10 github.com/zeebo/xxh3 v1.0.2 diff --git a/go.sum b/go.sum index d73f4743a60..b052e74860e 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,8 @@ github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1 h1:rMY/hWfcVzBm6BL github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1/go.mod h1:RbNG3Q1g6GUy1/WzWVx+S24m7VKyvl57vV+cr2hpt50= github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811uiUXyI7ok= github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw= -github.com/kovidgoyal/imaging v1.8.2 h1:AYBaYSAI7W6p2CfQnAqVEB6AeiOVMJ0uUEuhuM+zuWo= -github.com/kovidgoyal/imaging v1.8.2/go.mod h1:fHutWjAiIZ3t7+YRVzuPkICFFzHTTkxgx2jSeQXKjE0= +github.com/kovidgoyal/imaging v1.8.3 h1:o6t2imMeuvBdr/jteBkFuaUMqZZJLyyQ/9JjkuI5urI= +github.com/kovidgoyal/imaging v1.8.3/go.mod h1:fHutWjAiIZ3t7+YRVzuPkICFFzHTTkxgx2jSeQXKjE0= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a h1:N9zuLhTvBSRt0gWSiJswwQ2HqDmtX/ZCDJURnKUt1Ik= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= From e20b2c7ebcca32f9f0ab407fe0590e6b38ce8066 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 7 Nov 2025 23:55:29 +0530 Subject: [PATCH 06/53] Bump version of imaging for latest fixes --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ce6ce1ae101..71fa1131c9c 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/google/uuid v1.6.0 github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1 github.com/kovidgoyal/go-parallel v1.1.1 - github.com/kovidgoyal/imaging v1.8.3 + github.com/kovidgoyal/imaging v1.8.4 github.com/seancfoley/ipaddress-go v1.7.1 github.com/shirou/gopsutil/v4 v4.25.10 github.com/zeebo/xxh3 v1.0.2 diff --git a/go.sum b/go.sum index b052e74860e..718f02ecf6a 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,8 @@ github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1 h1:rMY/hWfcVzBm6BL github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1/go.mod h1:RbNG3Q1g6GUy1/WzWVx+S24m7VKyvl57vV+cr2hpt50= github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811uiUXyI7ok= github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw= -github.com/kovidgoyal/imaging v1.8.3 h1:o6t2imMeuvBdr/jteBkFuaUMqZZJLyyQ/9JjkuI5urI= -github.com/kovidgoyal/imaging v1.8.3/go.mod h1:fHutWjAiIZ3t7+YRVzuPkICFFzHTTkxgx2jSeQXKjE0= +github.com/kovidgoyal/imaging v1.8.4 h1:gCL5Ogc14NL6F2zOn9/T1eoRewUuxSFgLOoihLjcma4= +github.com/kovidgoyal/imaging v1.8.4/go.mod h1:fHutWjAiIZ3t7+YRVzuPkICFFzHTTkxgx2jSeQXKjE0= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a h1:N9zuLhTvBSRt0gWSiJswwQ2HqDmtX/ZCDJURnKUt1Ik= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= From 09741e204e716b52b9063532db191fdae99d5039 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 8 Nov 2025 00:03:44 +0530 Subject: [PATCH 07/53] Graphics: Fix overwrite composition mode for animation frames not being honored --- docs/changelog.rst | 2 ++ kitty/graphics.c | 2 +- kitty/graphics.h | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 295f4811afd..4a279ea15c4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -146,6 +146,8 @@ Detailed list of changes - icat kitten: Add support for APNG, netPBM, ICC color profiles and CCIP metadata to the builtin engine +- Graphics: Fix overwrite composition mode for animation frames not being honored + 0.44.0 [2025-11-03] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/kitty/graphics.c b/kitty/graphics.c index c1b8c5c7085..c06cfe69527 100644 --- a/kitty/graphics.c +++ b/kitty/graphics.c @@ -1590,7 +1590,7 @@ handle_animation_frame_load_command(GraphicsManager *self, GraphicsCommand *g, I .x = g->x_offset, .y = g->y_offset, .is_4byte_aligned = load_data->is_4byte_aligned, .is_opaque = load_data->is_opaque, - .alpha_blend = g->blend_mode != 1 && !load_data->is_opaque, + .alpha_blend = g->compose_mode != 1 && !load_data->is_opaque, .gap = g->gap > 0 ? g->gap : (g->gap < 0) ? 0 : DEFAULT_GAP, .bgcolor = g->bgcolor, }; diff --git a/kitty/graphics.h b/kitty/graphics.h index 50de464b51f..84e5d6d5713 100644 --- a/kitty/graphics.h +++ b/kitty/graphics.h @@ -13,7 +13,7 @@ typedef struct { uint32_t format, more, id, image_number, data_sz, data_offset, placement_id, quiet, parent_id, parent_placement_id; uint32_t width, height, x_offset, y_offset; union { uint32_t cursor_movement, compose_mode; }; - union { uint32_t cell_x_offset, blend_mode; }; + union { uint32_t cell_x_offset; }; union { uint32_t cell_y_offset, bgcolor; }; union { uint32_t data_width, animation_state; }; union { uint32_t data_height, loop_count; }; From 9ca03420bad84caddcfe2f13950b606e1112e105 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 8 Nov 2025 00:04:44 +0530 Subject: [PATCH 08/53] Update changelog --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 4a279ea15c4..b5609c2607f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -144,7 +144,7 @@ Detailed list of changes - ssh kitten: Fix a bug where automatic login was not working (:iss:`9187`) -- icat kitten: Add support for APNG, netPBM, ICC color profiles and CCIP metadata to the builtin engine +- icat kitten: Add support for animated PNG and animated WebP, netPBM images, ICC color profiles and CCIP metadata to the builtin engine - Graphics: Fix overwrite composition mode for animation frames not being honored From aa7d38d5b16eb9b5728c4df50b4364f49f165a65 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 8 Nov 2025 00:51:15 +0530 Subject: [PATCH 09/53] Cleanup some code --- gen/config.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/gen/config.py b/gen/config.py index b7defd61309..cb07f2f58b9 100755 --- a/gen/config.py +++ b/gen/config.py @@ -45,11 +45,15 @@ def main(args: list[str]=sys.argv) -> None: all_colors = [] for opt in definition.iter_all_options(): if callable(opt.parser_func): - if opt.parser_func.__name__ in ('to_color_or_none', 'cursor_text_color'): - nullable_colors.append(opt.name) - all_colors.append(opt.name) - elif opt.parser_func.__name__ in ('to_color', 'titlebar_color', 'macos_titlebar_color', 'scrollbar_color'): - all_colors.append(opt.name) + match opt.parser_func.__name__: + case 'to_color': + all_colors.append(opt.name) + case 'macos_titlebar_color' | 'titlebar_color' | 'scrollbar_color': + all_colors.append(opt.name) + case 'to_color_or_none' | 'cursor_text_color': + nullable_colors.append(opt.name) + all_colors.append(opt.name) + patch_color_list('tools/cmd/at/set_colors.go', nullable_colors, 'NULLABLE') patch_color_list('tools/themes/collection.go', all_colors, 'ALL') nc = ',\n '.join(f'{x!r}' for x in nullable_colors) From 6c81547e24c5e7333eff1cc0bb0c13c8a980c04b Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 8 Nov 2025 00:55:10 +0530 Subject: [PATCH 10/53] Output the set of special colors separately --- gen/config.py | 10 ++++++++-- kitty/options/types.py | 7 +++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/gen/config.py b/gen/config.py index cb07f2f58b9..d41e9c84d22 100755 --- a/gen/config.py +++ b/gen/config.py @@ -43,6 +43,7 @@ def main(args: list[str]=sys.argv) -> None: from kitty.options.definition import definition nullable_colors = [] all_colors = [] + special_colors = [] for opt in definition.iter_all_options(): if callable(opt.parser_func): match opt.parser_func.__name__: @@ -50,14 +51,19 @@ def main(args: list[str]=sys.argv) -> None: all_colors.append(opt.name) case 'macos_titlebar_color' | 'titlebar_color' | 'scrollbar_color': all_colors.append(opt.name) + special_colors.append(opt.name) case 'to_color_or_none' | 'cursor_text_color': - nullable_colors.append(opt.name) all_colors.append(opt.name) + nullable_colors.append(opt.name) patch_color_list('tools/cmd/at/set_colors.go', nullable_colors, 'NULLABLE') patch_color_list('tools/themes/collection.go', all_colors, 'ALL') nc = ',\n '.join(f'{x!r}' for x in nullable_colors) - write_output('kitty', definition, f'\nnullable_colors = frozenset({{\n {nc}\n}})\n') + sc = ',\n '.join(f'{x!r}' for x in special_colors) + write_output('kitty', definition, + f'\nnullable_colors = frozenset({{\n {nc}\n}})\n' + f'\nspecial_colors = frozenset({{\n {sc}\n}})\n' + ) if __name__ == '__main__': diff --git a/kitty/options/types.py b/kitty/options/types.py index 61ca32c37eb..cd9d8b8b6d4 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -1095,5 +1095,12 @@ def __setattr__(self, key: str, val: typing.Any) -> typing.Any: 'selection_background' }) +special_colors = frozenset({ + 'scrollbar_handle_color', + 'scrollbar_track_color', + 'wayland_titlebar_color', + 'macos_titlebar_color' +}) + secret_options = ('remote_control_password', 'file_transfer_confirmation_bypass') \ No newline at end of file From 8b2d92d58dfa37377fa66a652d635d2b5cb97171 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 8 Nov 2025 01:09:57 +0530 Subject: [PATCH 11/53] Automatic color scheme switching: Fix title bar and scroll bar colors not being updated Fixes #9167 --- docs/changelog.rst | 5 +++-- kitty/colors.py | 17 ++++++++++++++--- kitty/state.c | 1 + 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index b5609c2607f..11a58e6d572 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -148,6 +148,9 @@ Detailed list of changes - Graphics: Fix overwrite composition mode for animation frames not being honored +- Automatic color scheme switching: Fix title bar and scroll bar colors not being updated (:iss:`9167`) + + 0.44.0 [2025-11-03] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -197,8 +200,6 @@ Detailed list of changes - Wayland: Fix scrolling using some mouse wheels that produce "VALUE120" based scroll events too fast on some compositors (:pull:`9128`) -- Automatic color scheme switching: Fix title bar color not being updated (:iss:`9167`) - - Add support for Unicode 17 - Fix a regression in 0.43.0 that caused :opt:`tab_bar_margin_width` to be diff --git a/kitty/colors.py b/kitty/colors.py index eec04cce6e8..e7e955dd029 100644 --- a/kitty/colors.py +++ b/kitty/colors.py @@ -10,7 +10,7 @@ from .config import parse_config from .constants import config_dir from .fast_data_types import Color, get_boss, get_options, glfw_get_system_color_theme, patch_color_profiles, patch_global_colors, set_os_window_chrome -from .options.types import Options, nullable_colors +from .options.types import Options, nullable_colors, special_colors from .rgb import color_from_int from .typing_compat import WindowType @@ -52,6 +52,8 @@ def get_default_colors(self) -> ColorsSpec: defval = getattr(defaults, name) if isinstance(defval, Color): ans[name] = int(defval) + for name in special_colors: + ans[name] = getattr(defaults, name) self.default_colors = ans self.default_background_image_options: BackgroundImageOptions = { k: getattr(defaults, k) for k in BackgroundImageOptions.__optional_keys__} # type: ignore @@ -190,8 +192,9 @@ def apply_theme(self, new_value: ColorSchemes, notify_on_bg_change: bool = True) def parse_colors(args: Iterable[str | Iterable[str]], background_image_options: BackgroundImageOptions | None = None) -> Colors: - colors: dict[str, Color | None] = {} + colors: dict[str, Color | None | int] = {} nullable_color_map: dict[str, int | None] = {} + special_color_map: dict[str, int] = {} transparent_background_colors = () for spec in args: if isinstance(spec, str): @@ -213,8 +216,13 @@ def parse_colors(args: Iterable[str | Iterable[str]], background_image_options: if q is not False: val = int(q) if isinstance(q, Color) else None nullable_color_map[k] = val + for k in special_colors: + sq = colors.pop(k, None) + if isinstance(sq, int): + special_color_map[k] = sq ans: dict[str, int | None] = {k: int(v) for k, v in colors.items() if isinstance(v, Color)} ans.update(nullable_color_map) + ans.update(special_color_map) return ans, transparent_background_colors @@ -228,7 +236,10 @@ def patch_options_with_color_spec( if k in nullable_colors: setattr(opts, k, None) else: - setattr(opts, k, color_from_int(v)) + if k in special_colors: + setattr(opts, k, v) + else: + setattr(opts, k, color_from_int(v)) opts.transparent_background_colors = transparent_background_colors if background_image_options is not None: for k, bv in background_image_options.items(): diff --git a/kitty/state.c b/kitty/state.c index a8d330be21a..cdace646f1b 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -1202,6 +1202,7 @@ PYWRAP1(patch_global_colors) { } P(active_border_color); P(inactive_border_color); P(bell_border_color); P(tab_bar_background); P(tab_bar_margin_color); P(macos_titlebar_color); P(wayland_titlebar_color); + P(scrollbar_handle_color); P(scrollbar_track_color); if (configured) { P(background); P(url_color); } From 2eddb6ab1975b2c50c66b2116c9195c3aa085c19 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 8 Nov 2025 08:48:34 +0530 Subject: [PATCH 12/53] Send an OTP for paste events --- docs/clipboard.rst | 6 ++++++ kitty/clipboard.py | 28 +++++++++++++++++++++++----- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/docs/clipboard.rst b/docs/clipboard.rst index 379f293d63c..0932e6c981b 100644 --- a/docs/clipboard.rst +++ b/docs/clipboard.rst @@ -180,6 +180,12 @@ free to request whatever MIME data it wants from the list of types. The mode can be enabled using the standard DECSET or DECRST control sequences. ``CSI ? 5522 h`` to enable the mode. ``CSI ? 5522 l`` to disable the mode. +The terminal *should* send a one time password with the list of mime +types, as the ``pw`` key (base64 encoded). The application can then use this +password to request data from the clipboard without needing a permission +prompt. The human name *should* be set to ``Paste event`` (base64 encoded) when +the application uses this one time password. + Detecting support for this protocol ----------------------------------------- diff --git a/kitty/clipboard.py b/kitty/clipboard.py index b145f85900d..e0f73c3d10c 100644 --- a/kitty/clipboard.py +++ b/kitty/clipboard.py @@ -214,8 +214,12 @@ class ReadRequest(NamedTuple): protocol_type: ProtocolType = ProtocolType.osc_52 human_name: str = '' password: str = '' + otp_for_response: str = '' def encode_response(self, status: str = 'DATA', mime: str = '', payload: bytes | memoryview = b'') -> bytes: + from base64 import standard_b64encode + def encode_b64(s: str) -> str: + return standard_b64encode(s.encode()).decode() ans = f'{self.protocol_type.value};type=read:status={status}' if status == 'OK' and self.is_primary_selection: ans += ':loc=primary' @@ -223,10 +227,11 @@ def encode_response(self, status: str = 'DATA', mime: str = '', payload: bytes | ans += f':id={self.id}' if mime: ans += f':mime={encode_mime(mime)}' + if self.otp_for_response: + ans += f':pw={encode_b64(self.otp_for_response)}' a = ans.encode('ascii') if payload: - import base64 - a += b';' + base64.standard_b64encode(payload) + a += b';' + standard_b64encode(payload) return a @@ -329,9 +334,13 @@ def data_for(self, mime: str = 'text/plain', offset: int = 0, size: int = -1) -> class GrantedPermission: - def __init__(self, read: bool = False, write: bool = False): + one_time: bool = False + write_ban: bool = False + read_ban: bool = False + + def __init__(self, read: bool = False, write: bool = False, one_time: bool = False): self.read, self.write = read, write - self.write_ban = self.read_ban = False + self.one_time = one_time class ClipboardRequestManager: @@ -521,7 +530,12 @@ def cb(q: str) -> None: default='d', window=window, title=_('A program wants to access the clipboard')) def password_is_allowed_already(self, password: str, for_write: bool = False) -> bool: - return (q := self.granted_passwords.get(password)) is not None and (q.write if for_write else q.read) + q = self.granted_passwords.get(password) + if q is not None: + if q.one_time: + self.granted_passwords.pop(password, None) + return q.write if for_write else q.read + return False def fulfill_legacy_write_request(self, wr: WriteRequest, allowed: bool = True) -> None: cp = get_boss().primary_selection if wr.is_primary_selection else get_boss().clipboard @@ -543,7 +557,11 @@ def handle_read_request(self, rr: ReadRequest) -> None: self.fulfill_read_request(rr, allowed=allowed) def send_paste_event(self, is_primary_selection: bool) -> None: + from kitty.short_uuid import uuid4 + pw = uuid4() + self.granted_passwords[pw] = GrantedPermission(read=True, one_time=True) rr = ReadRequest(is_primary_selection=is_primary_selection, mime_types=(TARGETS_MIME,), protocol_type=ProtocolType.osc_5522) + rr = rr._replace(otp_for_response=pw) self.fulfill_read_request(rr) def fulfill_read_request(self, rr: ReadRequest, allowed: bool = True) -> None: From 57f7c8f65e1bf130f2a2fb9c07a3a2ade2546a25 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 8 Nov 2025 10:27:56 +0530 Subject: [PATCH 13/53] Bump version of imaging for a few more fixes --- go.mod | 2 +- go.sum | 4 ++-- kittens/icat/native.go | 2 +- tools/utils/images/loading.go | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 71fa1131c9c..1b0be8d82b9 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/google/uuid v1.6.0 github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1 github.com/kovidgoyal/go-parallel v1.1.1 - github.com/kovidgoyal/imaging v1.8.4 + github.com/kovidgoyal/imaging v1.8.5 github.com/seancfoley/ipaddress-go v1.7.1 github.com/shirou/gopsutil/v4 v4.25.10 github.com/zeebo/xxh3 v1.0.2 diff --git a/go.sum b/go.sum index 718f02ecf6a..1bab6a0a527 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,8 @@ github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1 h1:rMY/hWfcVzBm6BL github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1/go.mod h1:RbNG3Q1g6GUy1/WzWVx+S24m7VKyvl57vV+cr2hpt50= github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811uiUXyI7ok= github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw= -github.com/kovidgoyal/imaging v1.8.4 h1:gCL5Ogc14NL6F2zOn9/T1eoRewUuxSFgLOoihLjcma4= -github.com/kovidgoyal/imaging v1.8.4/go.mod h1:fHutWjAiIZ3t7+YRVzuPkICFFzHTTkxgx2jSeQXKjE0= +github.com/kovidgoyal/imaging v1.8.5 h1:GZrBlpRbuSbpomWhERCl5ZmQq5Q6nMR8gnHL9R3uomM= +github.com/kovidgoyal/imaging v1.8.5/go.mod h1:fHutWjAiIZ3t7+YRVzuPkICFFzHTTkxgx2jSeQXKjE0= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a h1:N9zuLhTvBSRt0gWSiJswwQ2HqDmtX/ZCDJURnKUt1Ik= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/kittens/icat/native.go b/kittens/icat/native.go index 4ff59722782..a9457fe294b 100644 --- a/kittens/icat/native.go +++ b/kittens/icat/native.go @@ -114,7 +114,7 @@ var _ = debugprintln func add_frames(ctx *images.Context, imgd *image_data, gf *imaging.Image) { for _, f := range gf.Frames { - frame := add_frame(ctx, imgd, f.Image, f.X, f.Y) + frame := add_frame(ctx, imgd, f.Image, f.TopLeft.X, f.TopLeft.Y) frame.number, frame.compose_onto = int(f.Number), int(f.ComposeOnto) frame.replace = f.Replace frame.delay_ms = int(f.Delay.Milliseconds()) diff --git a/tools/utils/images/loading.go b/tools/utils/images/loading.go index 9714d7aa1a1..d57a39d53f6 100644 --- a/tools/utils/images/loading.go +++ b/tools/utils/images/loading.go @@ -269,7 +269,7 @@ func OpenNativeImageFromReader(f io.ReadSeeker) (ans *ImageData, err error) { for _, f := range ic.Frames { fr := ImageFrame{ - Img: f.Image, Left: f.X, Top: f.Y, Width: f.Image.Bounds().Dx(), Height: f.Image.Bounds().Dy(), + Img: f.Image, Left: f.TopLeft.X, Top: f.TopLeft.Y, Width: f.Image.Bounds().Dx(), Height: f.Image.Bounds().Dy(), Compose_onto: int(f.ComposeOnto), Number: int(f.Number), Delay_ms: int32(f.Delay.Milliseconds()), Replace: f.Replace, Is_opaque: imaging.IsOpaque(f.Image), } From a814ab4c2e915da54389db1a0334a3fcd89becd3 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 8 Nov 2025 11:51:11 +0530 Subject: [PATCH 14/53] icat: Allow controlling how images are fit Fixes #9201 --- docs/changelog.rst | 8 ++++++-- kittens/icat/main.go | 32 ++++++++++++++++++++++++++++++-- kittens/icat/main.py | 13 ++++++++++--- kittens/icat/native.go | 31 ++++++++++++++++++++++++++++--- kittens/icat/process_images.go | 19 +++++++++++++++++-- kittens/icat/scaling_test.go | 29 +++++++++++++++++++++++++++++ 6 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 kittens/icat/scaling_test.go diff --git a/docs/changelog.rst b/docs/changelog.rst index 11a58e6d572..e36b4b56b7f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -139,13 +139,17 @@ Detailed list of changes - Add support for the `paste events protocol `__ (:iss:`9183`) +- icat kitten: Add support for animated PNG and animated WebP, netPBM images, ICC color profiles and CCIP metadata to the builtin engine + +- icat kitten: Add a new flag :option:`kitty +kitten icat --fit` to control how images are scaled to fit the screen (:iss:`9201`) + +- icat kitten: The :option:`kitty +kitten icat --scale-up` flag now takes effect when not using :option:`kitty +kitten icat --place` as well + - Add a mappable action :ac:`copy_last_command_output` to copy the output of the last command to the clipboard (:pull:`9185`) - ssh kitten: Fix a bug where automatic login was not working (:iss:`9187`) -- icat kitten: Add support for animated PNG and animated WebP, netPBM images, ICC color profiles and CCIP metadata to the builtin engine - - Graphics: Fix overwrite composition mode for animation frames not being honored - Automatic color scheme switching: Fix title bar and scroll bar colors not being updated (:iss:`9167`) diff --git a/kittens/icat/main.go b/kittens/icat/main.go index 64d8bef526b..6b84c070e98 100644 --- a/kittens/icat/main.go +++ b/kittens/icat/main.go @@ -42,6 +42,15 @@ const ( supported ) +type fit_t int + +const ( + fit_none fit_t = iota + fit_width + fit_height + fit_both +) + var transfer_by_file, transfer_by_memory transfer_mode var files_channel chan input_arg @@ -49,6 +58,7 @@ var output_channel chan *image_data var num_of_items int var keep_going *atomic.Bool var screen_size *unix.Winsize +var fit_mode fit_t func send_output(imgd *image_data) { output_channel <- imgd @@ -87,6 +97,22 @@ func parse_z_index() (err error) { return } +func parse_fit() (err error) { + switch strings.ToLower(opts.Fit) { + case "width": + fit_mode = fit_width + case "height": + fit_mode = fit_height + case "none", "neither": + fit_mode = fit_none + case "both": + fit_mode = fit_both + default: + return fmt.Errorf("unknown fit specification: %#v", opts.Fit) + } + return nil +} + func parse_place() (err error) { if opts.Place == "" { return nil @@ -130,8 +156,10 @@ func print_error(format string, args ...any) { func main(cmd *cli.Command, o *Options, args []string) (rc int, err error) { opts = o - err = parse_place() - if err != nil { + if err = parse_place(); err != nil { + return 1, err + } + if err = parse_fit(); err != nil { return 1, err } err = parse_z_index() diff --git a/kittens/icat/main.py b/kittens/icat/main.py index b3ad4e88518..c571a500af2 100644 --- a/kittens/icat/main.py +++ b/kittens/icat/main.py @@ -22,9 +22,16 @@ --scale-up type=bool-set -When used in combination with :option:`--place` it will cause images that are -smaller than the specified area to be scaled up to use as much of the specified -area as possible. +Cause images that are smaller than the specified area to be scaled up to use as much +of the specified area as possible. The specified area depends on either the :option:`--place` +or the :option:`--fit` options. + + +--fit +choices=width,height,both,none +default=width +When not using :option:`--place`, control how the image is scaled relative to the screen. +You can have it fit in the screen width or height or both or neither. --background diff --git a/kittens/icat/native.go b/kittens/icat/native.go index a9457fe294b..e387f15c1d8 100644 --- a/kittens/icat/native.go +++ b/kittens/icat/native.go @@ -91,12 +91,37 @@ func add_frame(ctx *images.Context, imgd *image_data, img image.Image, left, top return &f } +func scale_up(width, height, maxWidth, maxHeight int) (newWidth, newHeight int) { + if width == 0 || height == 0 { + return 0, 0 + } + + // Calculate the ratio to scale the width and the ratio to scale the height. + // We use floating-point division for precision. + widthRatio := float64(maxWidth) / float64(width) + heightRatio := float64(maxHeight) / float64(height) + + // To preserve the aspect ratio and fit within the limits, we must use the + // smaller of the two scaling ratios. + var ratio float64 + if widthRatio < heightRatio { + ratio = widthRatio + } else { + ratio = heightRatio + } + + // Calculate the new dimensions and convert them back to uints. + newWidth = int(float64(width) * ratio) + newHeight = int(float64(height) * ratio) + + return newWidth, newHeight +} + func scale_image(imgd *image_data) bool { if imgd.needs_scaling { width, height := imgd.canvas_width, imgd.canvas_height - if imgd.canvas_width < imgd.available_width && opts.ScaleUp && place != nil { - r := float64(imgd.available_width) / float64(imgd.canvas_width) - imgd.canvas_width, imgd.canvas_height = imgd.available_width, int(r*float64(imgd.canvas_height)) + if opts.ScaleUp && (imgd.canvas_width < imgd.available_width || imgd.canvas_height < imgd.available_height) && (imgd.available_height != inf || imgd.available_width != inf) { + imgd.canvas_width, imgd.canvas_height = scale_up(imgd.canvas_width, imgd.canvas_height, imgd.available_width, imgd.available_height) } neww, newh := images.FitImage(imgd.canvas_width, imgd.canvas_height, imgd.available_width, imgd.available_height) imgd.needs_scaling = false diff --git a/kittens/icat/process_images.go b/kittens/icat/process_images.go index c9957f279f9..4f5834bc1aa 100644 --- a/kittens/icat/process_images.go +++ b/kittens/icat/process_images.go @@ -8,6 +8,7 @@ import ( "image" "io" "io/fs" + "math" "net/http" "net/url" "os" @@ -196,15 +197,29 @@ type image_data struct { source_name string } +const inf = math.MaxInt + func set_basic_metadata(imgd *image_data) { if imgd.frames == nil { imgd.frames = make([]*image_frame, 0, 32) } - imgd.available_width = int(screen_size.Xpixel) - imgd.available_height = 10 * imgd.canvas_height if place != nil { imgd.available_width = place.width * int(screen_size.Xpixel) / int(screen_size.Col) imgd.available_height = place.height * int(screen_size.Ypixel) / int(screen_size.Row) + } else { + switch fit_mode { + case fit_none: + imgd.available_width, imgd.available_height = inf, inf + case fit_both: + imgd.available_width = int(screen_size.Xpixel) + imgd.available_height = int(screen_size.Ypixel) + case fit_width: + imgd.available_width = int(screen_size.Xpixel) + imgd.available_height = inf + case fit_height: + imgd.available_width = inf + imgd.available_height = int(screen_size.Ypixel) + } } imgd.needs_scaling = imgd.canvas_width > imgd.available_width || imgd.canvas_height > imgd.available_height || opts.ScaleUp imgd.needs_conversion = imgd.needs_scaling || remove_alpha != nil || flip || flop || imgd.format_uppercase != "PNG" diff --git a/kittens/icat/scaling_test.go b/kittens/icat/scaling_test.go new file mode 100644 index 00000000000..82b974b7169 --- /dev/null +++ b/kittens/icat/scaling_test.go @@ -0,0 +1,29 @@ +package icat + +import ( + "fmt" + "image" + "testing" +) + +var _ = fmt.Print + +func TestScaling(t *testing.T) { + for _, tc := range []struct { + w, h, pw, ph, ew, eh int + }{ + {1000, 50, 800, 600, 800, 40}, + {1000, 50, 800000, 600, 12000, 600}, + {100, 50, 800, 600, 800, 400}, + {1920, 1080, 800, 600, 800, 450}, + {300, 900, 800, 600, 200, 600}, + {400, 300, 800, 600, 800, 600}, + } { + aw, ah := scale_up(tc.w, tc.h, tc.pw, tc.ph) + actual := image.Pt(aw, ah) + expected := image.Pt(tc.ew, tc.eh) + if actual != expected { + t.Fatalf("want: %v got: %v", expected, actual) + } + } +} From 426167d78d56b0d9e2e9c4c88cb0ee86e0e19ad8 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 8 Nov 2025 11:55:45 +0530 Subject: [PATCH 15/53] ... --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e36b4b56b7f..b42a55432ea 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -139,7 +139,7 @@ Detailed list of changes - Add support for the `paste events protocol `__ (:iss:`9183`) -- icat kitten: Add support for animated PNG and animated WebP, netPBM images, ICC color profiles and CCIP metadata to the builtin engine +- icat kitten: Add support for animated PNG and animated WebP, netPBM images, ICC color profiles and CCIP color space metadata to the builtin engine - icat kitten: Add a new flag :option:`kitty +kitten icat --fit` to control how images are scaled to fit the screen (:iss:`9201`) From 87f4c5ccecab0039c17b44b23f2c34dc55034053 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 8 Nov 2025 12:21:28 +0530 Subject: [PATCH 16/53] Remove no longer needed code --- kittens/icat/magick.go | 4 ++-- kittens/icat/native.go | 13 ++++--------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/kittens/icat/magick.go b/kittens/icat/magick.go index a738ab54637..2fcf10eed5a 100644 --- a/kittens/icat/magick.go +++ b/kittens/icat/magick.go @@ -12,7 +12,7 @@ import ( var _ = fmt.Print -func Render(path, original_file_path string, ro *images.RenderOptions, frames []images.IdentifyRecord) (ans []*image_frame, err error) { +func render(path, original_file_path string, ro *images.RenderOptions, frames []images.IdentifyRecord) (ans []*image_frame, err error) { ro.TempfilenameTemplate = shm_template image_frames, filenames, err := images.RenderWithMagick(path, original_file_path, ro, frames) if err == nil { @@ -56,7 +56,7 @@ func render_image_with_magick(imgd *image_data, src *opened_input) (err error) { if scale_image(imgd) { ro.ResizeTo.X, ro.ResizeTo.Y = imgd.canvas_width, imgd.canvas_height } - imgd.frames, err = Render(src.FileSystemName(), imgd.source_name, &ro, frames) + imgd.frames, err = render(src.FileSystemName(), imgd.source_name, &ro, frames) if err != nil { return err } diff --git a/kittens/icat/native.go b/kittens/icat/native.go index e387f15c1d8..3c711db138d 100644 --- a/kittens/icat/native.go +++ b/kittens/icat/native.go @@ -32,13 +32,7 @@ func resize_frame(imgd *image_data, img image.Image) (image.Image, image.Rectang const shm_template = "kitty-icat-*" func add_frame(ctx *images.Context, imgd *image_data, img image.Image, left, top int) *image_frame { - is_opaque := false - if imgd.format_uppercase == "JPEG" { - // special cased because EXIF orientation could have already changed this image to an NRGBA making IsOpaque() very slow - is_opaque = true - } else { - is_opaque = imaging.IsOpaque(img) - } + is_opaque := imaging.IsOpaque(img) b := img.Bounds() if imgd.scaled_frac.x != 0 { img, b = resize_frame(imgd, img) @@ -163,10 +157,11 @@ func render_image_with_go(imgd *image_data, src *opened_input) (err error) { if imgs == nil { return fmt.Errorf("unknown image format") } + imgd.format_uppercase = imgs.Metadata.Format.String() // Loading could auto orient and therefore change width/height, so // re-calculate - imgd.canvas_width = int(imgs.Metadata.PixelWidth) - imgd.canvas_height = int(imgs.Metadata.PixelHeight) + b := imgs.Bounds() + imgd.canvas_width, imgd.canvas_height = b.Dx(), b.Dy() set_basic_metadata(imgd) scale_image(imgd) add_frames(&ctx, imgd, imgs) From 6d4e6438f7abc8ab5bea2392091105d9c8b7cd98 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 8 Nov 2025 16:46:44 +0530 Subject: [PATCH 17/53] Clarify behavior of keyboard protocol for pure text events --- docs/keyboard-protocol.rst | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/keyboard-protocol.rst b/docs/keyboard-protocol.rst index d06e2c94a36..f252aae2a43 100644 --- a/docs/keyboard-protocol.rst +++ b/docs/keyboard-protocol.rst @@ -244,17 +244,19 @@ The terminal can optionally send the text associated with key events as a sequence of Unicode code points. This behavior is opt-in by the :ref:`progressive enhancement ` mechanism described below. Some examples:: - shift+a -> CSI 97 ; 2 ; 65 u # The text 'A' is reported as 65 - option+a -> CSI 97 ; ; 229 u # The text 'å' is reported as 229 + shift+a -> CSI 97 ; 2 ; 65 u # The text 'A' is reported as 65 + alt+a -> CSI 0 ; ; 229 u # The text 'å' is reported as 229 If multiple code points are present, they must be separated by colons. If no known key is associated with the text the key number ``0`` must be used. The associated text must not contain control codes (control codes are code points below U+0020 and codepoints in the C0 and C1 blocks). In the above example, the -:kbd:`option` modifier is consumed by macOS itself to produce the text å -and therefore not reported in the keyboard protocol. On some platforms -composition keys might produce no key information at all, in which case the key -number ``0`` must be used. +:kbd:`alt` modifier is consumed by the OS itself to produce the text å and not +sent to the terminal emulator, which gets only a "text input" event and no +information about modifiers, thus the event gets encoded with no modifiers. +The exact behavior in these situations depends on the OS, keyboard layout, IME +system in use and so on. In general, if the terminal emulator receives no key +information, the key number 0 must be used to indicate a pure "text event". Non-Unicode keys From 1c8e8e9530bc0d11e7ab1a3d730384cb23c91afe Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 10 Nov 2025 11:34:56 +0530 Subject: [PATCH 18/53] Switch over to the new imaging backend for icat Greatly simplifies a whole bunch of code. The new backend takes care of falling back to ImageMagick efficiently itself. --- go.mod | 4 +- go.sum | 8 +- kittens/icat/magick.go | 64 ---- kittens/icat/native.go | 169 --------- kittens/icat/process_images.go | 250 +++++++------- kittens/icat/transmit.go | 101 ++---- tools/utils/images/convert.go | 11 +- tools/utils/images/loading.go | 499 +-------------------------- tools/utils/images/serialize_test.go | 2 +- tools/utils/images/transforms.go | 58 ---- tools/utils/shm/shm_fs.go | 4 +- 11 files changed, 181 insertions(+), 989 deletions(-) delete mode 100644 kittens/icat/magick.go delete mode 100644 kittens/icat/native.go delete mode 100644 tools/utils/images/transforms.go diff --git a/go.mod b/go.mod index 1b0be8d82b9..d5494d8a1dc 100644 --- a/go.mod +++ b/go.mod @@ -13,13 +13,13 @@ require ( github.com/google/uuid v1.6.0 github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1 github.com/kovidgoyal/go-parallel v1.1.1 - github.com/kovidgoyal/imaging v1.8.5 + github.com/kovidgoyal/imaging v1.8.8 github.com/seancfoley/ipaddress-go v1.7.1 github.com/shirou/gopsutil/v4 v4.25.10 github.com/zeebo/xxh3 v1.0.2 golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b golang.org/x/image v0.32.0 - golang.org/x/sys v0.37.0 + golang.org/x/sys v0.38.0 golang.org/x/text v0.30.0 howett.net/plist v1.0.1 ) diff --git a/go.sum b/go.sum index 1bab6a0a527..01257683fe9 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,8 @@ github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1 h1:rMY/hWfcVzBm6BL github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1/go.mod h1:RbNG3Q1g6GUy1/WzWVx+S24m7VKyvl57vV+cr2hpt50= github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811uiUXyI7ok= github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw= -github.com/kovidgoyal/imaging v1.8.5 h1:GZrBlpRbuSbpomWhERCl5ZmQq5Q6nMR8gnHL9R3uomM= -github.com/kovidgoyal/imaging v1.8.5/go.mod h1:fHutWjAiIZ3t7+YRVzuPkICFFzHTTkxgx2jSeQXKjE0= +github.com/kovidgoyal/imaging v1.8.8 h1:PohlAOYuokFtmt6sjhgA90YAUKhuuL3i0dhd5gepp4g= +github.com/kovidgoyal/imaging v1.8.8/go.mod h1:GAbZkbyB86PSfosof5EnS2o6N15yUk9Vy2r61EWy1Wg= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a h1:N9zuLhTvBSRt0gWSiJswwQ2HqDmtX/ZCDJURnKUt1Ik= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -67,8 +67,8 @@ golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc 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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/kittens/icat/magick.go b/kittens/icat/magick.go deleted file mode 100644 index 2fcf10eed5a..00000000000 --- a/kittens/icat/magick.go +++ /dev/null @@ -1,64 +0,0 @@ -// License: GPLv3 Copyright: 2023, Kovid Goyal, - -package icat - -import ( - "fmt" - - "github.com/kovidgoyal/go-parallel" - "github.com/kovidgoyal/kitty/tools/tui/graphics" - "github.com/kovidgoyal/kitty/tools/utils/images" -) - -var _ = fmt.Print - -func render(path, original_file_path string, ro *images.RenderOptions, frames []images.IdentifyRecord) (ans []*image_frame, err error) { - ro.TempfilenameTemplate = shm_template - image_frames, filenames, err := images.RenderWithMagick(path, original_file_path, ro, frames) - if err == nil { - ans = make([]*image_frame, len(image_frames)) - for i, x := range image_frames { - ans[i] = &image_frame{ - filename: filenames[x.Number], filename_is_temporary: true, - number: x.Number, width: x.Width, height: x.Height, left: x.Left, top: x.Top, - transmission_format: graphics.GRT_format_rgba, delay_ms: int(x.Delay_ms), compose_onto: x.Compose_onto, - } - if x.Is_opaque { - ans[i].transmission_format = graphics.GRT_format_rgb - } - } - } - return ans, err -} - -func render_image_with_magick(imgd *image_data, src *opened_input) (err error) { - defer func() { - if r := recover(); r != nil { - err = parallel.Format_stacktrace_on_panic(r, 1) - } - }() - err = src.PutOnFilesystem() - if err != nil { - return err - } - frames, err := images.IdentifyWithMagick(src.FileSystemName(), imgd.source_name) - if err != nil { - return err - } - imgd.format_uppercase = frames[0].Fmt_uppercase - imgd.canvas_width, imgd.canvas_height = frames[0].Canvas.Width, frames[0].Canvas.Height - set_basic_metadata(imgd) - if !imgd.needs_conversion { - make_output_from_input(imgd, src) - return nil - } - ro := images.RenderOptions{RemoveAlpha: remove_alpha, Flip: flip, Flop: flop} - if scale_image(imgd) { - ro.ResizeTo.X, ro.ResizeTo.Y = imgd.canvas_width, imgd.canvas_height - } - imgd.frames, err = render(src.FileSystemName(), imgd.source_name, &ro, frames) - if err != nil { - return err - } - return nil -} diff --git a/kittens/icat/native.go b/kittens/icat/native.go deleted file mode 100644 index 3c711db138d..00000000000 --- a/kittens/icat/native.go +++ /dev/null @@ -1,169 +0,0 @@ -// License: GPLv3 Copyright: 2023, Kovid Goyal, - -package icat - -import ( - "fmt" - "image" - - "github.com/kovidgoyal/go-parallel" - "github.com/kovidgoyal/imaging/nrgb" - "github.com/kovidgoyal/kitty/tools/tty" - "github.com/kovidgoyal/kitty/tools/tui/graphics" - "github.com/kovidgoyal/kitty/tools/utils/images" - "github.com/kovidgoyal/kitty/tools/utils/shm" - - "github.com/kovidgoyal/imaging" -) - -var _ = fmt.Print - -func resize_frame(imgd *image_data, img image.Image) (image.Image, image.Rectangle) { - b := img.Bounds() - left, top, width, height := b.Min.X, b.Min.Y, b.Dx(), b.Dy() - new_width := int(imgd.scaled_frac.x * float64(width)) - new_height := int(imgd.scaled_frac.y * float64(height)) - img = imaging.Resize(img, new_width, new_height, imaging.Lanczos) - newleft := int(imgd.scaled_frac.x * float64(left)) - newtop := int(imgd.scaled_frac.y * float64(top)) - return img, image.Rect(newleft, newtop, newleft+new_width, newtop+new_height) -} - -const shm_template = "kitty-icat-*" - -func add_frame(ctx *images.Context, imgd *image_data, img image.Image, left, top int) *image_frame { - is_opaque := imaging.IsOpaque(img) - b := img.Bounds() - if imgd.scaled_frac.x != 0 { - img, b = resize_frame(imgd, img) - } - f := image_frame{width: b.Dx(), height: b.Dy(), number: len(imgd.frames) + 1, left: left, top: top} - dest_rect := image.Rect(0, 0, f.width, f.height) - var final_img image.Image - bytes_per_pixel := 4 - - if is_opaque || remove_alpha != nil { - var rgb *imaging.NRGB - bytes_per_pixel = 3 - m, err := shm.CreateTemp(shm_template, uint64(f.width*f.height*bytes_per_pixel)) - if err != nil { - rgb = nrgb.NewNRGB(dest_rect) - } else { - rgb = &imaging.NRGB{Pix: m.Slice(), Stride: bytes_per_pixel * f.width, Rect: dest_rect} - f.shm = m - } - f.transmission_format = graphics.GRT_format_rgb - f.in_memory_bytes = rgb.Pix - final_img = rgb - } else { - var rgba *image.NRGBA - m, err := shm.CreateTemp(shm_template, uint64(f.width*f.height*bytes_per_pixel)) - if err != nil { - rgba = image.NewNRGBA(dest_rect) - } else { - rgba = &image.NRGBA{Pix: m.Slice(), Stride: bytes_per_pixel * f.width, Rect: dest_rect} - f.shm = m - } - f.transmission_format = graphics.GRT_format_rgba - f.in_memory_bytes = rgba.Pix - final_img = rgba - } - ctx.PasteCenter(final_img, img, remove_alpha) - imgd.frames = append(imgd.frames, &f) - if flip { - ctx.FlipPixelsV(bytes_per_pixel, f.width, f.height, f.in_memory_bytes) - if f.height < imgd.canvas_height { - f.top = (2*imgd.canvas_height - f.height - f.top) % imgd.canvas_height - } - } - if flop { - ctx.FlipPixelsH(bytes_per_pixel, f.width, f.height, f.in_memory_bytes) - if f.width < imgd.canvas_width { - f.left = (2*imgd.canvas_width - f.width - f.left) % imgd.canvas_width - } - } - return &f -} - -func scale_up(width, height, maxWidth, maxHeight int) (newWidth, newHeight int) { - if width == 0 || height == 0 { - return 0, 0 - } - - // Calculate the ratio to scale the width and the ratio to scale the height. - // We use floating-point division for precision. - widthRatio := float64(maxWidth) / float64(width) - heightRatio := float64(maxHeight) / float64(height) - - // To preserve the aspect ratio and fit within the limits, we must use the - // smaller of the two scaling ratios. - var ratio float64 - if widthRatio < heightRatio { - ratio = widthRatio - } else { - ratio = heightRatio - } - - // Calculate the new dimensions and convert them back to uints. - newWidth = int(float64(width) * ratio) - newHeight = int(float64(height) * ratio) - - return newWidth, newHeight -} - -func scale_image(imgd *image_data) bool { - if imgd.needs_scaling { - width, height := imgd.canvas_width, imgd.canvas_height - if opts.ScaleUp && (imgd.canvas_width < imgd.available_width || imgd.canvas_height < imgd.available_height) && (imgd.available_height != inf || imgd.available_width != inf) { - imgd.canvas_width, imgd.canvas_height = scale_up(imgd.canvas_width, imgd.canvas_height, imgd.available_width, imgd.available_height) - } - neww, newh := images.FitImage(imgd.canvas_width, imgd.canvas_height, imgd.available_width, imgd.available_height) - imgd.needs_scaling = false - imgd.scaled_frac.x = float64(neww) / float64(width) - imgd.scaled_frac.y = float64(newh) / float64(height) - imgd.canvas_width = int(imgd.scaled_frac.x * float64(width)) - imgd.canvas_height = int(imgd.scaled_frac.y * float64(height)) - return true - } - return false -} - -var debugprintln = tty.DebugPrintln -var _ = debugprintln - -func add_frames(ctx *images.Context, imgd *image_data, gf *imaging.Image) { - for _, f := range gf.Frames { - frame := add_frame(ctx, imgd, f.Image, f.TopLeft.X, f.TopLeft.Y) - frame.number, frame.compose_onto = int(f.Number), int(f.ComposeOnto) - frame.replace = f.Replace - frame.delay_ms = int(f.Delay.Milliseconds()) - if frame.delay_ms <= 0 { - frame.delay_ms = -1 // -1 is gapless in graphics protocol - } - } -} - -func render_image_with_go(imgd *image_data, src *opened_input) (err error) { - defer func() { - if r := recover(); r != nil { - err = parallel.Format_stacktrace_on_panic(r, 1) - } - }() - ctx := images.Context{} - imgs, _, err := imaging.DecodeAll(src.file) - if err != nil { - return err - } - if imgs == nil { - return fmt.Errorf("unknown image format") - } - imgd.format_uppercase = imgs.Metadata.Format.String() - // Loading could auto orient and therefore change width/height, so - // re-calculate - b := imgs.Bounds() - imgd.canvas_width, imgd.canvas_height = b.Dx(), b.Dy() - set_basic_metadata(imgd) - scale_image(imgd) - add_frames(&ctx, imgd, imgs) - return nil -} diff --git a/kittens/icat/process_images.go b/kittens/icat/process_images.go index 4f5834bc1aa..f89883e49b3 100644 --- a/kittens/icat/process_images.go +++ b/kittens/icat/process_images.go @@ -15,52 +15,15 @@ import ( "path/filepath" "strings" + "github.com/kovidgoyal/imaging" "github.com/kovidgoyal/kitty/tools/tty" "github.com/kovidgoyal/kitty/tools/tui/graphics" "github.com/kovidgoyal/kitty/tools/utils" "github.com/kovidgoyal/kitty/tools/utils/images" - "github.com/kovidgoyal/kitty/tools/utils/shm" ) var _ = fmt.Print -type BytesBuf struct { - data []byte - pos int64 -} - -func (self *BytesBuf) Seek(offset int64, whence int) (int64, error) { - switch whence { - case io.SeekStart: - self.pos = offset - case io.SeekCurrent: - self.pos += offset - case io.SeekEnd: - self.pos = int64(len(self.data)) + offset - default: - return self.pos, fmt.Errorf("Unknown value for whence: %#v", whence) - } - self.pos = utils.Max(0, utils.Min(self.pos, int64(len(self.data)))) - return self.pos, nil -} - -func (self *BytesBuf) Read(p []byte) (n int, err error) { - nb := utils.Min(int64(len(p)), int64(len(self.data))-self.pos) - if nb == 0 { - err = io.EOF - } else { - n = copy(p, self.data[self.pos:self.pos+nb]) - self.pos += nb - } - return -} - -func (self *BytesBuf) Close() error { - self.data = nil - self.pos = 0 - return nil -} - type input_arg struct { arg string value string @@ -120,54 +83,14 @@ func process_dirs(args ...string) (results []input_arg, err error) { } type opened_input struct { - file io.ReadSeekCloser - name_to_unlink string -} - -func (self *opened_input) Rewind() { - if self.file != nil { - _, _ = self.file.Seek(0, io.SeekStart) - } + file io.Reader + bytes []byte + path string } -func (self *opened_input) Release() { - if self.file != nil { - self.file.Close() - self.file = nil - } - if self.name_to_unlink != "" { - os.Remove(self.name_to_unlink) - self.name_to_unlink = "" - } -} - -func (self *opened_input) PutOnFilesystem() (err error) { - if self.name_to_unlink != "" { - return - } - f, err := images.CreateTempInRAM() - if err != nil { - return fmt.Errorf("Failed to create a temporary file to store input data with error: %w", err) - } - self.Rewind() - _, err = io.Copy(f, self.file) - if err != nil { - f.Close() - return fmt.Errorf("Failed to copy input data to temporary file with error: %w", err) - } - self.Release() - self.file = f - self.name_to_unlink = f.Name() - return -} - -func (self *opened_input) FileSystemName() string { return self.name_to_unlink } - type image_frame struct { filename string - shm shm.MMap in_memory_bytes []byte - filename_is_temporary bool width, height, left, top int transmission_format graphics.GRT_f compose_onto int @@ -180,8 +103,7 @@ type image_data struct { canvas_width, canvas_height int format_uppercase string available_width, available_height int - needs_scaling, needs_conversion bool - scaled_frac struct{ x, y float64 } + needs_scaling bool frames []*image_frame image_number uint32 image_id uint32 @@ -222,7 +144,6 @@ func set_basic_metadata(imgd *image_data) { } } imgd.needs_scaling = imgd.canvas_width > imgd.available_width || imgd.canvas_height > imgd.available_height || opts.ScaleUp - imgd.needs_conversion = imgd.needs_scaling || remove_alpha != nil || flip || flop || imgd.format_uppercase != "PNG" } func report_error(source_name, msg string, err error) { @@ -231,7 +152,6 @@ func report_error(source_name, msg string, err error) { } func make_output_from_input(imgd *image_data, f *opened_input) { - bb, ok := f.file.(*BytesBuf) frame := image_frame{} imgd.frames = append(imgd.frames, &frame) frame.width = imgd.canvas_width @@ -240,17 +160,71 @@ func make_output_from_input(imgd *image_data, f *opened_input) { panic(fmt.Sprintf("Unknown transmission format: %s", imgd.format_uppercase)) } frame.transmission_format = graphics.GRT_format_png - if ok { - frame.in_memory_bytes = bb.data + if f.bytes != nil { + frame.in_memory_bytes = f.bytes + } else if f.path != "" { + frame.filename = f.path } else { - frame.filename = f.file.(*os.File).Name() - if f.name_to_unlink != "" { - frame.filename_is_temporary = true - f.name_to_unlink = "" + var err error + if frame.in_memory_bytes, err = io.ReadAll(f.file); err != nil { + panic(err) } } } +func scale_up(width, height, maxWidth, maxHeight int) (newWidth, newHeight int) { + if width == 0 || height == 0 { + return 0, 0 + } + // Calculate the ratio to scale the width and the ratio to scale the height. + // We use floating-point division for precision. + widthRatio := float64(maxWidth) / float64(width) + heightRatio := float64(maxHeight) / float64(height) + + // To preserve the aspect ratio and fit within the limits, we must use the + // smaller of the two scaling ratios. + var ratio float64 + if widthRatio < heightRatio { + ratio = widthRatio + } else { + ratio = heightRatio + } + + // Calculate the new dimensions and convert them back to uints. + newWidth = int(float64(width) * ratio) + newHeight = int(float64(height) * ratio) + + return newWidth, newHeight +} + +func scale_image(imgd *image_data) bool { + if imgd.needs_scaling { + width, height := imgd.canvas_width, imgd.canvas_height + if opts.ScaleUp && (imgd.canvas_width < imgd.available_width || imgd.canvas_height < imgd.available_height) && (imgd.available_height != inf || imgd.available_width != inf) { + imgd.canvas_width, imgd.canvas_height = scale_up(imgd.canvas_width, imgd.canvas_height, imgd.available_width, imgd.available_height) + } + neww, newh := images.FitImage(imgd.canvas_width, imgd.canvas_height, imgd.available_width, imgd.available_height) + imgd.needs_scaling = false + x := float64(neww) / float64(width) + y := float64(newh) / float64(height) + imgd.canvas_width = int(x * float64(width)) + imgd.canvas_height = int(y * float64(height)) + return true + } + return false +} + +func add_frame(imgd *image_data, img image.Image, left, top int) *image_frame { + const shm_template = "kitty-icat-*" + num_channels, pix := imaging.AsRGBData8(img) + b := img.Bounds() + f := image_frame{width: b.Dx(), height: b.Dy(), number: len(imgd.frames) + 1, left: left, top: top} + f.transmission_format = utils.IfElse(num_channels == 3, graphics.GRT_format_rgb, graphics.GRT_format_rgba) + f.in_memory_bytes = pix + imgd.frames = append(imgd.frames, &f) + return &f +} + func process_arg(arg input_arg) { var f opened_input if arg.is_http_url { @@ -271,14 +245,16 @@ func process_arg(arg input_arg) { report_error(arg.value, "Could not download", err) return } - f.file = &BytesBuf{data: dest.Bytes()} + f.bytes = dest.Bytes() + f.file = bytes.NewReader(f.bytes) } else if arg.value == "" { stdin, err := io.ReadAll(os.Stdin) if err != nil { report_error("", "Could not read from", err) return } - f.file = &BytesBuf{data: stdin} + f.bytes = stdin + f.file = bytes.NewReader(f.bytes) } else { q, err := os.Open(arg.value) if err != nil { @@ -286,56 +262,70 @@ func process_arg(arg input_arg) { return } f.file = q + f.path = q.Name() + defer q.Close() + } + + var img *images.ImageData + var dopts []imaging.DecodeOption + needs_conversion := false + if flip { + dopts = append(dopts, imaging.Transform(imaging.FlipVTransform)) + needs_conversion = true + } + if flop { + dopts = append(dopts, imaging.Transform(imaging.FlipHTransform)) + needs_conversion = true + } + if remove_alpha != nil { + dopts = append(dopts, imaging.Background(*remove_alpha)) + needs_conversion = true + } + switch opts.Engine { + case "native", "builtin": + dopts = append(dopts, imaging.Backends(imaging.GO_IMAGE)) + case "magick": + dopts = append(dopts, imaging.Backends(imaging.MAGICK_IMAGE)) } - defer f.Release() - can_use_go := false - var c image.Config - var format string - var err error imgd := image_data{source_name: arg.value} - if opts.Engine == "auto" || opts.Engine == "builtin" { - c, format, err = image.DecodeConfig(f.file) - f.Rewind() - can_use_go = err == nil + dopts = append(dopts, imaging.ResizeCallback(func(w, h int) (int, int) { + imgd.canvas_width, imgd.canvas_height = w, h + set_basic_metadata(&imgd) + if scale_image(&imgd) { + needs_conversion = true + w, h = imgd.canvas_width, imgd.canvas_height + } + return w, h + })) + var err error + if f.path != "" { + img, err = images.OpenImageFromPath(f.path, dopts...) + } else { + img, f.file, err = images.OpenImageFromReader(f.file, dopts...) + } + if err != nil { + report_error(arg.value, "Could not render image to RGB", err) + return } if !keep_going.Load() { return } - if can_use_go { - imgd.canvas_width = c.Width - imgd.canvas_height = c.Height - imgd.format_uppercase = strings.ToUpper(format) - set_basic_metadata(&imgd) - if !imgd.needs_conversion { - make_output_from_input(&imgd, &f) - send_output(&imgd) - return - } - err = render_image_with_go(&imgd, &f) - if err != nil { - if opts.Engine != "builtin" { - merr := render_image_with_magick(&imgd, &f) - if merr != nil { - report_error(arg.value, "Could not render image to RGB", err) - return - } - err = nil - } - report_error(arg.value, "could not render", err) - return - } + imgd.format_uppercase = img.Format_uppercase + imgd.canvas_width, imgd.canvas_height = img.Width, img.Height + if !needs_conversion && imgd.format_uppercase == "PNG" && len(img.Frames) == 1 { + make_output_from_input(&imgd, &f) } else { - err = render_image_with_magick(&imgd, &f) - if err != nil { - report_error(arg.value, "ImageMagick failed", err) - return + for _, f := range img.Frames { + frame := add_frame(&imgd, f.Img, f.Left, f.Top) + frame.number, frame.compose_onto = int(f.Number), int(f.Compose_onto) + frame.replace = f.Replace + frame.delay_ms = int(f.Delay_ms) } } if !keep_going.Load() { return } send_output(&imgd) - } func run_worker() { diff --git a/kittens/icat/transmit.go b/kittens/icat/transmit.go index a5c4a30038c..c6f92a81f48 100644 --- a/kittens/icat/transmit.go +++ b/kittens/icat/transmit.go @@ -6,7 +6,6 @@ import ( "bytes" "crypto/rand" "encoding/binary" - "errors" "fmt" "github.com/kovidgoyal/kitty" "io" @@ -92,43 +91,32 @@ func transmit_shm(imgd *image_data, frame_num int, frame *image_frame) (err erro return fmt.Errorf("Failed to open image data output file: %s with error: %w", frame.filename, err) } defer f.Close() - data_size, _ = f.Seek(0, io.SeekEnd) - _, _ = f.Seek(0, io.SeekStart) - mmap, err = shm.CreateTemp("icat-*", uint64(data_size)) - if err != nil { + if data_size, err = f.Seek(0, io.SeekEnd); err != nil { + return fmt.Errorf("Failed to seek in image data output file: %s with error: %w", frame.filename, err) + } + if _, err = f.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("Failed to seek in image data output file: %s with error: %w", frame.filename, err) + } + if mmap, err = shm.CreateTemp("icat-*", uint64(data_size)); err != nil { return fmt.Errorf("Failed to create a SHM file for transmission: %w", err) } - dest := mmap.Slice() - for len(dest) > 0 { - n, err := f.Read(dest) - dest = dest[n:] - if err != nil { - if errors.Is(err, io.EOF) { - break - } - _ = mmap.Unlink() - return fmt.Errorf("Failed to read data from image output data file: %w", err) - } + if _, err = io.ReadFull(f, mmap.Slice()); err != nil { + mmap.Close() + mmap.Unlink() + return fmt.Errorf("Failed to read data from image output data file: %w", err) } } else { - if frame.shm == nil { - data_size = int64(len(frame.in_memory_bytes)) - mmap, err = shm.CreateTemp("icat-*", uint64(data_size)) - if err != nil { - return fmt.Errorf("Failed to create a SHM file for transmission: %w", err) - } - copy(mmap.Slice(), frame.in_memory_bytes) - } else { - mmap = frame.shm - frame.shm = nil + data_size = int64(len(frame.in_memory_bytes)) + if mmap, err = shm.CreateTemp("icat-*", uint64(data_size)); err != nil { + return fmt.Errorf("Failed to create a SHM file for transmission: %w", err) } + copy(mmap.Slice(), frame.in_memory_bytes) } + defer mmap.Close() // terminal is responsible for unlink gc := gc_for_image(imgd, frame_num, frame) gc.SetTransmission(graphics.GRT_transmission_sharedmem) gc.SetDataSize(uint64(data_size)) err = gc.WriteWithPayloadTo(os.Stdout, utils.UnsafeStringToBytes(mmap.Name())) - mmap.Close() - return } @@ -137,7 +125,6 @@ func transmit_file(imgd *image_data, frame_num int, frame *image_frame) (err err fname := "" var data_size int if frame.in_memory_bytes == nil { - is_temp = frame.filename_is_temporary fname, err = filepath.Abs(frame.filename) if err != nil { return fmt.Errorf("Failed to convert image data output file: %s to absolute path with error: %w", frame.filename, err) @@ -145,30 +132,21 @@ func transmit_file(imgd *image_data, frame_num int, frame *image_frame) (err err frame.filename = "" // so it isn't deleted in cleanup } else { is_temp = true - if frame.shm != nil && frame.shm.FileSystemName() != "" { - fname = frame.shm.FileSystemName() - frame.shm.Close() - frame.shm = nil - } else { - f, err := images.CreateTempInRAM() - if err != nil { - return fmt.Errorf("Failed to create a temp file for image data transmission: %w", err) - } - data_size = len(frame.in_memory_bytes) - _, err = bytes.NewBuffer(frame.in_memory_bytes).WriteTo(f) - f.Close() - if err != nil { - return fmt.Errorf("Failed to write image data to temp file for transmission: %w", err) - } - fname = f.Name() + f, err := images.CreateTempInRAM() + if err != nil { + return fmt.Errorf("Failed to create a temp file for image data transmission: %w", err) } + data_size = len(frame.in_memory_bytes) + _, err = bytes.NewBuffer(frame.in_memory_bytes).WriteTo(f) + f.Close() + if err != nil { + os.Remove(f.Name()) + return fmt.Errorf("Failed to write image data to temp file for transmission: %w", err) + } + fname = f.Name() } gc := gc_for_image(imgd, frame_num, frame) - if is_temp { - gc.SetTransmission(graphics.GRT_transmission_tempfile) - } else { - gc.SetTransmission(graphics.GRT_transmission_file) - } + gc.SetTransmission(utils.IfElse(is_temp, graphics.GRT_transmission_tempfile, graphics.GRT_transmission_file)) if data_size > 0 { gc.SetDataSize(uint64(data_size)) } @@ -178,14 +156,9 @@ func transmit_file(imgd *image_data, frame_num int, frame *image_frame) (err err func transmit_stream(imgd *image_data, frame_num int, frame *image_frame) (err error) { data := frame.in_memory_bytes if data == nil { - f, err := os.Open(frame.filename) - if err != nil { - return fmt.Errorf("Failed to open image data output file: %s with error: %w", frame.filename, err) - } - data, err = io.ReadAll(f) - f.Close() + data, err = os.ReadFile(frame.filename) if err != nil { - return fmt.Errorf("Failed to read data from image output data file: %w", err) + return fmt.Errorf("Failed to read image data output file: %s with error: %w", frame.filename, err) } } gc := gc_for_image(imgd, frame_num, frame) @@ -282,20 +255,6 @@ func transmit_image(imgd *image_data, no_trailing_newline bool) { if seen_image_ids == nil { seen_image_ids = utils.NewSet[uint32](32) } - defer func() { - for _, frame := range imgd.frames { - if frame.filename_is_temporary && frame.filename != "" { - os.Remove(frame.filename) - frame.filename = "" - } - if frame.shm != nil { - _ = frame.shm.Unlink() - frame.shm.Close() - frame.shm = nil - } - frame.in_memory_bytes = nil - } - }() var f func(*image_data, int, *image_frame) error if opts.TransferMode != "detect" { switch opts.TransferMode { diff --git a/tools/utils/images/convert.go b/tools/utils/images/convert.go index cf24692d1d6..ecfb68e2105 100644 --- a/tools/utils/images/convert.go +++ b/tools/utils/images/convert.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" + "github.com/kovidgoyal/imaging" "github.com/kovidgoyal/kitty/tools/cli" "github.com/kovidgoyal/kitty/tools/utils" ) @@ -51,14 +52,10 @@ func encode_rgba(output io.Writer, img image.Image) (err error) { } func convert_image(input io.ReadSeeker, output io.Writer, format string) (err error) { - image_data, err := OpenNativeImageFromReader(input) + img, err := imaging.Decode(input) if err != nil { return err } - if len(image_data.Frames) == 0 { - return fmt.Errorf("Image has no frames") - } - img := image_data.Frames[0].Img q := strings.ToLower(format) if q == "rgba" { return encode_rgba(output, img) @@ -92,7 +89,7 @@ func images_equal(img, rimg *ImageData) (err error) { } func develop_serialize(input_data io.ReadSeeker) (err error) { - img, err := OpenNativeImageFromReader(input_data) + img, _, err := OpenImageFromReader(input_data) if err != nil { return err } @@ -113,7 +110,7 @@ func develop_resize(spec string, input_data io.ReadSeeker) (err error) { if h, err = strconv.Atoi(hs); err != nil { return } - img, err := OpenNativeImageFromReader(input_data) + img, _, err := OpenImageFromReader(input_data) if err != nil { return err } diff --git a/tools/utils/images/loading.go b/tools/utils/images/loading.go index d57a39d53f6..73afb204a62 100644 --- a/tools/utils/images/loading.go +++ b/tools/utils/images/loading.go @@ -3,24 +3,14 @@ package images import ( - "bytes" - "encoding/json" - "errors" "fmt" "image" - "image/gif" "image/png" "io" "os" - "os/exec" - "path/filepath" - "slices" - "strconv" "strings" - "sync" "github.com/kovidgoyal/imaging/nrgb" - "github.com/kovidgoyal/imaging/prism/meta/gifmeta" "github.com/kovidgoyal/kitty/tools/utils" "github.com/kovidgoyal/kitty/tools/utils/shm" @@ -78,71 +68,16 @@ func (s *ImageFrame) Serialize() SerializableImageFrame { } func (self *ImageFrame) DataAsSHM(pattern string) (ans shm.MMap, err error) { - bytes_per_pixel := 4 - if self.Is_opaque { - bytes_per_pixel = 3 - } - ans, err = shm.CreateTemp(pattern, uint64(self.Width*self.Height*bytes_per_pixel)) - if err != nil { + d := self.Data() + if ans, err = shm.CreateTemp(pattern, uint64(len(d))); err != nil { return nil, err } - switch img := self.Img.(type) { - case *imaging.NRGB: - if bytes_per_pixel == 3 { - copy(ans.Slice(), img.Pix) - return - } - case *image.NRGBA: - if bytes_per_pixel == 4 { - copy(ans.Slice(), img.Pix) - return - } - } - dest_rect := image.Rect(0, 0, self.Width, self.Height) - var final_img image.Image - switch bytes_per_pixel { - case 3: - rgb := &imaging.NRGB{Pix: ans.Slice(), Stride: bytes_per_pixel * self.Width, Rect: dest_rect} - final_img = rgb - case 4: - rgba := &image.NRGBA{Pix: ans.Slice(), Stride: bytes_per_pixel * self.Width, Rect: dest_rect} - final_img = rgba - } - ctx := Context{} - ctx.PasteCenter(final_img, self.Img, nil) + copy(ans.Slice(), d) return - } func (self *ImageFrame) Data() (ans []byte) { - bytes_per_pixel := 4 - if self.Is_opaque { - bytes_per_pixel = 3 - } - switch img := self.Img.(type) { - case *imaging.NRGB: - if bytes_per_pixel == 3 { - return img.Pix - } - case *image.NRGBA: - if bytes_per_pixel == 4 { - return img.Pix - } - } - dest_rect := image.Rect(0, 0, self.Width, self.Height) - var final_img image.Image - switch bytes_per_pixel { - case 3: - rgb := nrgb.NewNRGB(dest_rect) - final_img = rgb - ans = rgb.Pix - case 4: - rgba := image.NewNRGBA(dest_rect) - final_img = rgba - ans = rgba.Pix - } - ctx := Context{} - ctx.PasteCenter(final_img, self.Img, nil) + _, ans = imaging.AsRGBData8(self.Img) return } @@ -257,15 +192,14 @@ func MakeTempDir(template string) (ans string, err error) { return os.MkdirTemp("", template) } -// Native {{{ -func OpenNativeImageFromReader(f io.ReadSeeker) (ans *ImageData, err error) { - ic, _, err := imaging.DecodeAll(f) - if err != nil { - return nil, err +func NewImageData(ic *imaging.Image) (ans *ImageData) { + b := ic.Bounds() + ans = &ImageData{ + Width: b.Dx(), Height: b.Dy(), + } + if ic.Metadata != nil { + ans.Format_uppercase = strings.ToUpper(ic.Metadata.Format.String()) } - _, _ = f.Seek(0, io.SeekStart) - - ans = &ImageData{Width: int(ic.Metadata.PixelWidth), Height: int(ic.Metadata.PixelHeight), Format_uppercase: strings.ToUpper(ic.Metadata.Format.String())} for _, f := range ic.Frames { fr := ImageFrame{ @@ -281,413 +215,18 @@ func OpenNativeImageFromReader(f io.ReadSeeker) (ans *ImageData, err error) { return } -// }}} - -// ImageMagick {{{ -var MagickExe = sync.OnceValue(func() string { - return utils.FindExe("magick") -}) - -func check_resize(frame *ImageFrame, filename string) error { - // ImageMagick sometimes generates RGBA images smaller than the specified - // size. See https://github.com/kovidgoyal/kitty/issues/276 for examples - s, err := os.Stat(filename) - if err != nil { - return err - } - sz := int(s.Size()) - bytes_per_pixel := 4 - if frame.Is_opaque { - bytes_per_pixel = 3 - } - expected_size := bytes_per_pixel * frame.Width * frame.Height - if sz < expected_size { - if bytes_per_pixel == 4 && sz == 3*frame.Width*frame.Height { - frame.Is_opaque = true - return nil - } - missing := expected_size - sz - if missing%(bytes_per_pixel*frame.Width) != 0 { - return fmt.Errorf("ImageMagick failed to resize correctly. It generated %d < %d of data (w=%d h=%d bpp=%d frame-number: %d)", sz, expected_size, frame.Width, frame.Height, bytes_per_pixel, frame.Number) - } - frame.Height -= missing / (bytes_per_pixel * frame.Width) - } - return nil -} - -func RunMagick(path string, cmd []string) ([]byte, error) { - if MagickExe() != "magick" { - cmd = append([]string{MagickExe()}, cmd...) - } - c := exec.Command(cmd[0], cmd[1:]...) - output, err := c.Output() - if err != nil { - var exit_err *exec.ExitError - if errors.As(err, &exit_err) { - return nil, fmt.Errorf("Running the command: %s\nFailed with error:\n%s", strings.Join(cmd, " "), string(exit_err.Stderr)) - } - return nil, fmt.Errorf("Could not find the program: %#v. Is ImageMagick installed and in your PATH?", cmd[0]) - } - return output, nil -} - -type IdentifyOutput struct { - Fmt, Canvas, Transparency, Gap, Index, Size, Dpi, Dispose, Orientation string -} - -type IdentifyRecord struct { - Fmt_uppercase string - Gap int - Canvas struct{ Width, Height, Left, Top int } - Width, Height int - Dpi struct{ X, Y float64 } - Index int - Is_opaque bool - Needs_blend bool - Disposal int - Dimensions_swapped bool -} - -func parse_identify_record(ans *IdentifyRecord, raw *IdentifyOutput) (err error) { - ans.Fmt_uppercase = strings.ToUpper(raw.Fmt) - if raw.Gap != "" { - ans.Gap, err = strconv.Atoi(raw.Gap) - if err != nil { - return fmt.Errorf("Invalid gap value in identify output: %s", raw.Gap) - } - ans.Gap = max(0, ans.Gap) - } - area, pos, found := strings.Cut(raw.Canvas, "+") - ok := false - if found { - w, h, found := strings.Cut(area, "x") - if found { - ans.Canvas.Width, err = strconv.Atoi(w) - if err == nil { - ans.Canvas.Height, err = strconv.Atoi(h) - if err == nil { - x, y, found := strings.Cut(pos, "+") - if found { - ans.Canvas.Left, err = strconv.Atoi(x) - if err == nil { - if ans.Canvas.Top, err = strconv.Atoi(y); err == nil { - ok = true - } - } - } - } - } - } - } - if !ok { - return fmt.Errorf("Invalid canvas value in identify output: %s", raw.Canvas) - } - w, h, found := strings.Cut(raw.Size, "x") - ok = false - if found { - ans.Width, err = strconv.Atoi(w) - if err == nil { - if ans.Height, err = strconv.Atoi(h); err == nil { - ok = true - } - } - } - if !ok { - return fmt.Errorf("Invalid size value in identify output: %s", raw.Size) - } - x, y, found := strings.Cut(raw.Dpi, "x") - ok = false - if found { - ans.Dpi.X, err = strconv.ParseFloat(x, 64) - if err == nil { - if ans.Dpi.Y, err = strconv.ParseFloat(y, 64); err == nil { - ok = true - } - } - } - if !ok { - return fmt.Errorf("Invalid dpi value in identify output: %s", raw.Dpi) - } - ans.Index, err = strconv.Atoi(raw.Index) - if err != nil { - return fmt.Errorf("Invalid index value in identify output: %s", raw.Index) - } - q := strings.ToLower(raw.Transparency) - if q == "blend" || q == "true" { - ans.Is_opaque = false - } else { - ans.Is_opaque = true - } - ans.Needs_blend = q == "blend" - switch strings.ToLower(raw.Dispose) { - case "none", "undefined": - ans.Disposal = gif.DisposalNone - case "background": - ans.Disposal = gif.DisposalBackground - case "previous": - ans.Disposal = gif.DisposalPrevious - default: - return fmt.Errorf("Invalid value for dispose: %s", raw.Dispose) - } - switch raw.Orientation { - case "5", "6", "7", "8": - ans.Dimensions_swapped = true - } - if ans.Dimensions_swapped { - ans.Canvas.Width, ans.Canvas.Height = ans.Canvas.Height, ans.Canvas.Width - ans.Width, ans.Height = ans.Height, ans.Width - } - - return -} - -func IdentifyWithMagick(path, original_file_path string) (ans []IdentifyRecord, err error) { - cmd := []string{"identify"} - q := `{"fmt":"%m","canvas":"%g","transparency":"%A","gap":"%T","index":"%p","size":"%wx%h",` + - `"dpi":"%xx%y","dispose":"%D","orientation":"%[EXIF:Orientation]"},` - ext := filepath.Ext(original_file_path) - ipath := path - if strings.ToLower(ext) == ".apng" { - ipath = "APNG:" + path - } - cmd = append(cmd, "-format", q, "--", ipath) - output, err := RunMagick(path, cmd) - if err != nil { - return nil, fmt.Errorf("Failed to identify image at path: %s with error: %w", path, err) - } - output = bytes.TrimRight(bytes.TrimSpace(output), ",") - raw_json := make([]byte, 0, len(output)+2) - raw_json = append(raw_json, '[') - raw_json = append(raw_json, output...) - raw_json = append(raw_json, ']') - var records []IdentifyOutput - err = json.Unmarshal(raw_json, &records) - if err != nil { - return nil, fmt.Errorf("The ImageMagick identify program returned malformed output for the image at path: %s, with error: %w", path, err) - } - ans = make([]IdentifyRecord, len(records)) - for i, rec := range records { - err = parse_identify_record(&ans[i], &rec) - if err != nil { - return nil, err - } - } - return ans, nil -} - -type RenderOptions struct { - RemoveAlpha *imaging.NRGBColor - Flip, Flop bool - ResizeTo image.Point - OnlyFirstFrame bool - TempfilenameTemplate string -} - -func RenderWithMagick(path, original_file_path string, ro *RenderOptions, frames []IdentifyRecord) (ans []*ImageFrame, fmap map[int]string, err error) { - cmd := []string{"convert"} - ans = make([]*ImageFrame, 0, len(frames)) - fmap = make(map[int]string, len(frames)) - ext := filepath.Ext(original_file_path) - ipath := path - if strings.ToLower(ext) == ".apng" { - ipath = "APNG:" + path - } - - defer func() { - if err != nil { - for _, f := range fmap { - os.Remove(f) - } - } - }() - - if ro.RemoveAlpha != nil { - cmd = append(cmd, "-background", ro.RemoveAlpha.AsSharp(), "-alpha", "remove") - } else { - cmd = append(cmd, "-background", "none") - } - if ro.Flip { - cmd = append(cmd, "-flip") - } - if ro.Flop { - cmd = append(cmd, "-flop") - } - cpath := ipath - if ro.OnlyFirstFrame { - cpath += "[0]" - } - has_multiple_frames := len(frames) > 1 - get_multiple_frames := has_multiple_frames && !ro.OnlyFirstFrame - cmd = append(cmd, "--", cpath, "-auto-orient") - if ro.ResizeTo.X > 0 { - rcmd := []string{"-resize", fmt.Sprintf("%dx%d!", ro.ResizeTo.X, ro.ResizeTo.Y)} - if get_multiple_frames { - cmd = append(cmd, "-coalesce") - cmd = append(cmd, rcmd...) - cmd = append(cmd, "-deconstruct") - } else { - cmd = append(cmd, rcmd...) - } - } - cmd = append(cmd, "-depth", "8", "-set", "filename:f", "%w-%h-%g-%p") - if get_multiple_frames { - cmd = append(cmd, "+adjoin") - } - tdir, err := MakeTempDir(ro.TempfilenameTemplate) - if err != nil { - err = fmt.Errorf("Failed to create temporary directory to hold ImageMagick output with error: %w", err) - return - } - defer os.RemoveAll(tdir) - mode := "rgba" - if frames[0].Is_opaque { - mode = "rgb" - } - cmd = append(cmd, filepath.Join(tdir, "im-%[filename:f]."+mode)) - _, err = RunMagick(path, cmd) - if err != nil { - return - } - entries, err := os.ReadDir(tdir) +func OpenImageFromPath(path string, opts ...imaging.DecodeOption) (ans *ImageData, err error) { + ic, err := imaging.OpenAll(path, opts...) if err != nil { - err = fmt.Errorf("Failed to read temp dir used to store ImageMagick output with error: %w", err) - return - } - base_dir := filepath.Dir(tdir) - gaps := make([]int, len(frames)) - for i, frame := range frames { - gaps[i] = frame.Gap - } - // although ImageMagick *might* be already taking care of this adjustment, - // I dont know for sure, so do it anyway. - min_gap := gifmeta.CalcMinimumGap(gaps) - for _, entry := range entries { - fname := entry.Name() - p, _, _ := strings.Cut(fname, ".") - parts := strings.Split(p, "-") - if len(parts) < 5 { - continue - } - index, cerr := strconv.Atoi(parts[len(parts)-1]) - if cerr != nil || index < 0 || index >= len(frames) { - continue - } - width, cerr := strconv.Atoi(parts[1]) - if cerr != nil { - continue - } - height, cerr := strconv.Atoi(parts[2]) - if cerr != nil { - continue - } - _, pos, found := strings.Cut(parts[3], "+") - if !found { - continue - } - px, py, found := strings.Cut(pos, "+") - if !found { - continue - } - x, cerr := strconv.Atoi(px) - if cerr != nil { - continue - } - y, cerr := strconv.Atoi(py) - if cerr != nil { - continue - } - identify_data := frames[index] - df, cerr := os.CreateTemp(base_dir, TempTemplate+"."+mode) - if cerr != nil { - err = fmt.Errorf("Failed to create a temporary file in %s with error: %w", base_dir, cerr) - return - } - err = os.Rename(filepath.Join(tdir, fname), df.Name()) - if err != nil { - err = fmt.Errorf("Failed to rename a temporary file in %s with error: %w", tdir, err) - return - } - df.Close() - fmap[index+1] = df.Name() - frame := ImageFrame{ - Number: index + 1, Width: width, Height: height, Left: x, Top: y, Is_opaque: identify_data.Is_opaque, - } - frame.Delay_ms = int32(max(min_gap, identify_data.Gap) * 10) - err = check_resize(&frame, df.Name()) - if err != nil { - return - } - ans = append(ans, &frame) - } - if len(ans) < len(frames) { - err = fmt.Errorf("Failed to render %d out of %d frames", len(frames)-len(ans), len(frames)) - return - } - slices.SortFunc(ans, func(a, b *ImageFrame) int { return a.Number - b.Number }) - prev_disposal := gif.DisposalBackground - prev_compose_onto := 0 - for i, frame := range ans { - switch prev_disposal { - case gif.DisposalNone: - frame.Compose_onto = frame.Number - 1 - case gif.DisposalPrevious: - frame.Compose_onto = prev_compose_onto - } - prev_disposal, prev_compose_onto = frames[i].Disposal, frame.Compose_onto + return nil, err } - return + return NewImageData(ic), nil } -func OpenImageFromPathWithMagick(path string) (ans *ImageData, err error) { - identify_records, err := IdentifyWithMagick(path, path) - if err != nil { - return nil, fmt.Errorf("Failed to identify image at %#v with error: %w", path, err) - } - frames, filenames, err := RenderWithMagick(path, path, &RenderOptions{}, identify_records) +func OpenImageFromReader(r io.Reader, opts ...imaging.DecodeOption) (ans *ImageData, s io.Reader, err error) { + ic, s, err := imaging.DecodeAll(r, opts...) if err != nil { - return nil, fmt.Errorf("Failed to render image at %#v with error: %w", path, err) + return nil, nil, err } - defer func() { - for _, f := range filenames { - os.Remove(f) - } - }() - - for _, frame := range frames { - filename := filenames[frame.Number] - data, err := os.ReadFile(filename) - if err != nil { - return nil, fmt.Errorf("Failed to read temp file for image %#v at %#v with error: %w", path, filename, err) - } - dest_rect := image.Rect(0, 0, frame.Width, frame.Height) - if frame.Is_opaque { - frame.Img = &imaging.NRGB{Pix: data, Stride: frame.Width * 3, Rect: dest_rect} - } else { - frame.Img = &image.NRGBA{Pix: data, Stride: frame.Width * 4, Rect: dest_rect} - } - } - ans = &ImageData{ - Width: frames[0].Width, Height: frames[0].Height, Format_uppercase: identify_records[0].Fmt_uppercase, Frames: frames, - } - return ans, nil -} - -// }}} - -func OpenImageFromPath(path string) (ans *ImageData, err error) { - mt := utils.GuessMimeType(path) - if DecodableImageTypes[mt] { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - ans, err = OpenNativeImageFromReader(f) - if err != nil { - return OpenImageFromPathWithMagick(path) - } - } else { - return OpenImageFromPathWithMagick(path) - } - return + return NewImageData(ic), s, nil } diff --git a/tools/utils/images/serialize_test.go b/tools/utils/images/serialize_test.go index 2860088894f..5a278115df6 100644 --- a/tools/utils/images/serialize_test.go +++ b/tools/utils/images/serialize_test.go @@ -12,7 +12,7 @@ import ( var _ = fmt.Print func TestImageSerialize(t *testing.T) { - img, err := OpenNativeImageFromReader(bytes.NewReader(kitty.KittyLogoAsPNGData)) + img, _, err := OpenImageFromReader(bytes.NewReader(kitty.KittyLogoAsPNGData)) if err != nil { t.Fatal(err) } diff --git a/tools/utils/images/transforms.go b/tools/utils/images/transforms.go deleted file mode 100644 index 8bf751414b8..00000000000 --- a/tools/utils/images/transforms.go +++ /dev/null @@ -1,58 +0,0 @@ -// License: GPLv3 Copyright: 2023, Kovid Goyal, - -package images - -import ( - "fmt" -) - -var _ = fmt.Print - -func reverse_row(bytes_per_pixel int, pix []uint8) { - if len(pix) <= bytes_per_pixel { - return - } - i := 0 - j := len(pix) - bytes_per_pixel - for i < j { - pi := pix[i : i+bytes_per_pixel : i+bytes_per_pixel] - pj := pix[j : j+bytes_per_pixel : j+bytes_per_pixel] - for x := range bytes_per_pixel { - pi[x], pj[x] = pj[x], pi[x] - } - i += bytes_per_pixel - j -= bytes_per_pixel - } -} - -func (self *Context) FlipPixelsH(bytes_per_pixel, width, height int, pix []uint8) { - stride := bytes_per_pixel * width - if err := self.SafeParallel(0, height, func(ys <-chan int) { - for y := range ys { - i := y * stride - reverse_row(bytes_per_pixel, pix[i:i+stride]) - } - }); err != nil { - panic(err) - } -} - -func (self *Context) FlipPixelsV(bytes_per_pixel, width, height int, pix []uint8) { - stride := bytes_per_pixel * width - num := height / 2 - if err := self.SafeParallel(0, num, func(ys <-chan int) { - for y := range ys { - upper := y - lower := height - 1 - y - a := upper * stride - b := lower * stride - as := pix[a : a+stride : a+stride] - bs := pix[b : b+stride : b+stride] - for i := range as { - as[i], bs[i] = bs[i], as[i] - } - } - }); err != nil { - panic(err) - } -} diff --git a/tools/utils/shm/shm_fs.go b/tools/utils/shm/shm_fs.go index 73512a89ba6..a0d92abe8fe 100644 --- a/tools/utils/shm/shm_fs.go +++ b/tools/utils/shm/shm_fs.go @@ -33,9 +33,7 @@ func ShmUnlink(name string) error { if runtime.GOOS == "openbsd" { return os.Remove(openbsd_shm_path(name)) } - if strings.HasPrefix(name, "/") { - name = name[1:] - } + name = strings.TrimPrefix(name, "/") return os.Remove(filepath.Join(SHM_DIR, name)) } From d19fc375bac34917b62b92f214502bf4a750ca24 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 10 Nov 2025 12:01:05 +0530 Subject: [PATCH 19/53] Switch to external shm package --- go.mod | 1 + go.sum | 2 + kittens/icat/detect.go | 2 +- kittens/icat/transmit.go | 2 +- kittens/ssh/askpass.go | 2 +- kittens/ssh/main.go | 2 +- kittens/ssh/main_test.go | 2 +- tools/cmd/atexit/main.go | 2 +- tools/cmd/pytest/main.go | 47 ++++- tools/tui/graphics/collection.go | 2 +- tools/utils/images/loading.go | 2 +- tools/utils/shm/fallocate_linux.go | 20 -- tools/utils/shm/fallocate_other.go | 16 -- tools/utils/shm/shm.go | 252 -------------------------- tools/utils/shm/shm_fs.go | 189 ------------------- tools/utils/shm/shm_syscall.go | 200 -------------------- tools/utils/shm/shm_test.go | 61 ------- tools/utils/shm/specific_darwin.go | 7 - tools/utils/shm/specific_dragonfly.go | 12 -- tools/utils/shm/specific_freebsd.go | 7 - tools/utils/shm/specific_linux.go | 11 -- tools/utils/shm/specific_netbsd.go | 11 -- tools/utils/shm/specific_openbsd.go | 11 -- 23 files changed, 56 insertions(+), 807 deletions(-) delete mode 100644 tools/utils/shm/fallocate_linux.go delete mode 100644 tools/utils/shm/fallocate_other.go delete mode 100644 tools/utils/shm/shm.go delete mode 100644 tools/utils/shm/shm_fs.go delete mode 100644 tools/utils/shm/shm_syscall.go delete mode 100644 tools/utils/shm/shm_test.go delete mode 100644 tools/utils/shm/specific_darwin.go delete mode 100644 tools/utils/shm/specific_dragonfly.go delete mode 100644 tools/utils/shm/specific_freebsd.go delete mode 100644 tools/utils/shm/specific_linux.go delete mode 100644 tools/utils/shm/specific_netbsd.go delete mode 100644 tools/utils/shm/specific_openbsd.go diff --git a/go.mod b/go.mod index d5494d8a1dc..b7ff3a14553 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/google/uuid v1.6.0 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 github.com/kovidgoyal/imaging v1.8.8 github.com/seancfoley/ipaddress-go v1.7.1 github.com/shirou/gopsutil/v4 v4.25.10 diff --git a/go.sum b/go.sum index 01257683fe9..1a5d4acb2f6 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,8 @@ github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1 h1:rMY/hWfcVzBm6BL github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1/go.mod h1:RbNG3Q1g6GUy1/WzWVx+S24m7VKyvl57vV+cr2hpt50= github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811uiUXyI7ok= github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw= +github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo= +github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds= github.com/kovidgoyal/imaging v1.8.8 h1:PohlAOYuokFtmt6sjhgA90YAUKhuuL3i0dhd5gepp4g= github.com/kovidgoyal/imaging v1.8.8/go.mod h1:GAbZkbyB86PSfosof5EnS2o6N15yUk9Vy2r61EWy1Wg= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a h1:N9zuLhTvBSRt0gWSiJswwQ2HqDmtX/ZCDJURnKUt1Ik= diff --git a/kittens/icat/detect.go b/kittens/icat/detect.go index 226a600d54e..9d19d5cda8f 100644 --- a/kittens/icat/detect.go +++ b/kittens/icat/detect.go @@ -8,11 +8,11 @@ import ( "os" "time" + "github.com/kovidgoyal/go-shm" "github.com/kovidgoyal/kitty/tools/tui/graphics" "github.com/kovidgoyal/kitty/tools/tui/loop" "github.com/kovidgoyal/kitty/tools/utils" "github.com/kovidgoyal/kitty/tools/utils/images" - "github.com/kovidgoyal/kitty/tools/utils/shm" ) var _ = fmt.Print diff --git a/kittens/icat/transmit.go b/kittens/icat/transmit.go index c6f92a81f48..4da2746f775 100644 --- a/kittens/icat/transmit.go +++ b/kittens/icat/transmit.go @@ -15,12 +15,12 @@ import ( "path/filepath" "strings" + "github.com/kovidgoyal/go-shm" "github.com/kovidgoyal/kitty/tools/tui" "github.com/kovidgoyal/kitty/tools/tui/graphics" "github.com/kovidgoyal/kitty/tools/tui/loop" "github.com/kovidgoyal/kitty/tools/utils" "github.com/kovidgoyal/kitty/tools/utils/images" - "github.com/kovidgoyal/kitty/tools/utils/shm" ) var _ = fmt.Print diff --git a/kittens/ssh/askpass.go b/kittens/ssh/askpass.go index ac28035ec43..742dd9c787e 100644 --- a/kittens/ssh/askpass.go +++ b/kittens/ssh/askpass.go @@ -13,9 +13,9 @@ import ( "strings" "time" + "github.com/kovidgoyal/go-shm" "github.com/kovidgoyal/kitty/tools/cli" "github.com/kovidgoyal/kitty/tools/tty" - "github.com/kovidgoyal/kitty/tools/utils/shm" ) var _ = fmt.Print diff --git a/kittens/ssh/main.go b/kittens/ssh/main.go index 438c4791046..0eb90f4c0a5 100644 --- a/kittens/ssh/main.go +++ b/kittens/ssh/main.go @@ -28,6 +28,7 @@ import ( "syscall" "time" + "github.com/kovidgoyal/go-shm" "github.com/kovidgoyal/kitty/tools/cli" "github.com/kovidgoyal/kitty/tools/themes" "github.com/kovidgoyal/kitty/tools/tty" @@ -37,7 +38,6 @@ import ( "github.com/kovidgoyal/kitty/tools/utils" "github.com/kovidgoyal/kitty/tools/utils/secrets" "github.com/kovidgoyal/kitty/tools/utils/shlex" - "github.com/kovidgoyal/kitty/tools/utils/shm" "golang.org/x/sys/unix" ) diff --git a/kittens/ssh/main_test.go b/kittens/ssh/main_test.go index 0e1a83a294f..21fcee8d29f 100644 --- a/kittens/ssh/main_test.go +++ b/kittens/ssh/main_test.go @@ -6,8 +6,8 @@ import ( "encoding/binary" "encoding/json" "fmt" + "github.com/kovidgoyal/go-shm" "github.com/kovidgoyal/kitty" - "github.com/kovidgoyal/kitty/tools/utils/shm" "io/fs" "os" "os/exec" diff --git a/tools/cmd/atexit/main.go b/tools/cmd/atexit/main.go index 5fd527655fc..eb108ff77cd 100644 --- a/tools/cmd/atexit/main.go +++ b/tools/cmd/atexit/main.go @@ -7,9 +7,9 @@ import ( "os/signal" "strings" + "github.com/kovidgoyal/go-shm" "github.com/kovidgoyal/kitty/tools/cli" "github.com/kovidgoyal/kitty/tools/utils" - "github.com/kovidgoyal/kitty/tools/utils/shm" ) var _ = fmt.Print diff --git a/tools/cmd/pytest/main.go b/tools/cmd/pytest/main.go index 08b4c3e850d..fea1742c15a 100644 --- a/tools/cmd/pytest/main.go +++ b/tools/cmd/pytest/main.go @@ -4,19 +4,62 @@ package pytest import ( "fmt" + "io" + "os" + "github.com/kovidgoyal/go-shm" "github.com/kovidgoyal/kitty/kittens/ssh" "github.com/kovidgoyal/kitty/tools/cli" - "github.com/kovidgoyal/kitty/tools/utils/shm" ) var _ = fmt.Print +func test_integration_with_python(args []string) (rc int, err error) { + switch args[0] { + default: + return 1, fmt.Errorf("Unknown test type: %s", args[0]) + case "read": + data, err := shm.ReadWithSizeAndUnlink(args[1]) + if err != nil { + return 1, err + } + _, err = os.Stdout.Write(data) + if err != nil { + return 1, err + } + case "write": + data, err := io.ReadAll(os.Stdin) + if err != nil { + return 1, err + } + mmap, err := shm.CreateTemp("shmtest-", uint64(len(data)+shm.NUM_BYTES_FOR_SIZE)) + if err != nil { + return 1, err + } + if err = shm.WriteWithSize(mmap, data, 0); err != nil { + return 1, err + } + mmap.Close() + fmt.Println(mmap.Name()) + } + return 0, nil +} + +func shm_entry_point(root *cli.Command) { + root.AddSubCommand(&cli.Command{ + Name: "shm", + OnlyArgsAllowed: true, + Run: func(cmd *cli.Command, args []string) (rc int, err error) { + return test_integration_with_python(args) + }, + }) + +} func EntryPoint(root *cli.Command) { root = root.AddSubCommand(&cli.Command{ Name: "__pytest__", Hidden: true, }) - shm.TestEntryPoint(root) + shm_entry_point(root) ssh.TestEntryPoint(root) } diff --git a/tools/tui/graphics/collection.go b/tools/tui/graphics/collection.go index 0e77af35a49..f1542ea5bd7 100644 --- a/tools/tui/graphics/collection.go +++ b/tools/tui/graphics/collection.go @@ -10,11 +10,11 @@ import ( "sync" "sync/atomic" + "github.com/kovidgoyal/go-shm" "github.com/kovidgoyal/kitty/tools/tui" "github.com/kovidgoyal/kitty/tools/tui/loop" "github.com/kovidgoyal/kitty/tools/utils" "github.com/kovidgoyal/kitty/tools/utils/images" - "github.com/kovidgoyal/kitty/tools/utils/shm" ) var _ = fmt.Print diff --git a/tools/utils/images/loading.go b/tools/utils/images/loading.go index 73afb204a62..0a8f18cddd7 100644 --- a/tools/utils/images/loading.go +++ b/tools/utils/images/loading.go @@ -10,9 +10,9 @@ import ( "os" "strings" + "github.com/kovidgoyal/go-shm" "github.com/kovidgoyal/imaging/nrgb" "github.com/kovidgoyal/kitty/tools/utils" - "github.com/kovidgoyal/kitty/tools/utils/shm" "github.com/kovidgoyal/imaging" ) diff --git a/tools/utils/shm/fallocate_linux.go b/tools/utils/shm/fallocate_linux.go deleted file mode 100644 index 8cd91b68d8e..00000000000 --- a/tools/utils/shm/fallocate_linux.go +++ /dev/null @@ -1,20 +0,0 @@ -// License: GPLv3 Copyright: 2023, Kovid Goyal, - -package shm - -import ( - "errors" - "fmt" - - "golang.org/x/sys/unix" -) - -var _ = fmt.Print - -func Fallocate_simple(fd int, size int64) (err error) { - for { - if err = unix.Fallocate(fd, 0, 0, size); !errors.Is(err, unix.EINTR) { - return - } - } -} diff --git a/tools/utils/shm/fallocate_other.go b/tools/utils/shm/fallocate_other.go deleted file mode 100644 index 3aa7ee5326b..00000000000 --- a/tools/utils/shm/fallocate_other.go +++ /dev/null @@ -1,16 +0,0 @@ -// License: GPLv3 Copyright: 2023, Kovid Goyal, - -//go:build !linux - -package shm - -import ( - "errors" - "fmt" -) - -var _ = fmt.Print - -func Fallocate_simple(fd int, size int64) (err error) { - return errors.ErrUnsupported -} diff --git a/tools/utils/shm/shm.go b/tools/utils/shm/shm.go deleted file mode 100644 index a01f1251c4c..00000000000 --- a/tools/utils/shm/shm.go +++ /dev/null @@ -1,252 +0,0 @@ -// License: GPLv3 Copyright: 2022, Kovid Goyal, - -package shm - -import ( - "encoding/binary" - "errors" - "fmt" - "io" - "io/fs" - "os" - "strings" - - "github.com/kovidgoyal/kitty/tools/cli" - - "golang.org/x/sys/unix" -) - -var _ = fmt.Print -var ErrPatternHasSeparator = errors.New("The specified pattern has file path separators in it") -var ErrPatternTooLong = errors.New("The specified pattern for the SHM name is too long") - -type ErrNotSupported struct { - err error -} - -func (self *ErrNotSupported) Error() string { - return fmt.Sprintf("POSIX shared memory not supported on this platform: with underlying error: %v", self.err) -} - -// prefix_and_suffix splits pattern by the last wildcard "*", if applicable, -// returning prefix as the part before "*" and suffix as the part after "*". -func prefix_and_suffix(pattern string) (prefix, suffix string, err error) { - for i := 0; i < len(pattern); i++ { - if os.IsPathSeparator(pattern[i]) { - return "", "", ErrPatternHasSeparator - } - } - if pos := strings.LastIndexByte(pattern, '*'); pos != -1 { - prefix, suffix = pattern[:pos], pattern[pos+1:] - } else { - prefix = pattern - } - return prefix, suffix, nil -} - -type MMap interface { - Close() error - Unlink() error - Slice() []byte - Name() string - IsFileSystemBacked() bool - FileSystemName() string - Stat() (fs.FileInfo, error) - Flush() error - Seek(offset int64, whence int) (ret int64, err error) - Read(b []byte) (n int, err error) - Write(b []byte) (n int, err error) -} - -type AccessFlags int - -const ( - READ AccessFlags = iota - WRITE - COPY -) - -func mmap(sz int, access AccessFlags, fd int, off int64) ([]byte, error) { - flags := unix.MAP_SHARED - prot := unix.PROT_READ - switch access { - case COPY: - prot |= unix.PROT_WRITE - flags = unix.MAP_PRIVATE - case WRITE: - prot |= unix.PROT_WRITE - } - - b, err := unix.Mmap(fd, off, sz, prot, flags) - if err != nil { - return nil, err - } - return b, nil -} - -func munmap(s []byte) error { - return unix.Munmap(s) -} - -func CreateTemp(pattern string, size uint64) (MMap, error) { - return create_temp(pattern, size) -} - -func truncate_or_unlink(ans *os.File, size uint64, unlink func(string) error) (err error) { - fd := int(ans.Fd()) - sz := int64(size) - if err = Fallocate_simple(fd, sz); err != nil { - if !errors.Is(err, errors.ErrUnsupported) { - return fmt.Errorf("fallocate() failed on fd from shm_open(%s) with size: %d with error: %w", ans.Name(), size, err) - } - for { - if err = unix.Ftruncate(fd, sz); !errors.Is(err, unix.EINTR) { - break - } - } - } - if err != nil { - _ = ans.Close() - _ = unlink(ans.Name()) - return fmt.Errorf("Failed to ftruncate() SHM file %s to size: %d with error: %w", ans.Name(), size, err) - } - return -} - -const NUM_BYTES_FOR_SIZE = 4 - -var ErrRegionTooSmall = errors.New("mmaped region too small") - -func WriteWithSize(self MMap, b []byte, at int) error { - if len(self.Slice()) < at+len(b)+NUM_BYTES_FOR_SIZE { - return ErrRegionTooSmall - } - binary.BigEndian.PutUint32(self.Slice()[at:], uint32(len(b))) - copy(self.Slice()[at+NUM_BYTES_FOR_SIZE:], b) - return nil -} - -func ReadWithSize(self MMap, at int) ([]byte, error) { - s := self.Slice()[at:] - if len(s) < NUM_BYTES_FOR_SIZE { - return nil, ErrRegionTooSmall - } - size := int(binary.BigEndian.Uint32(self.Slice()[at : at+NUM_BYTES_FOR_SIZE])) - s = s[NUM_BYTES_FOR_SIZE:] - if len(s) < size { - return nil, ErrRegionTooSmall - } - return s[:size], nil -} - -func ReadWithSizeAndUnlink(name string, file_callback ...func(fs.FileInfo) error) ([]byte, error) { - mmap, err := Open(name, 0) - if err != nil { - return nil, err - } - if len(file_callback) > 0 { - s, err := mmap.Stat() - if err != nil { - return nil, fmt.Errorf("Failed to stat SHM file with error: %w", err) - } - for _, f := range file_callback { - err = f(s) - if err != nil { - return nil, err - } - } - } - defer func() { - mmap.Close() - _ = mmap.Unlink() - }() - slice, err := ReadWithSize(mmap, 0) - if err != nil { - return nil, err - } - ans := make([]byte, len(slice)) - copy(ans, slice) - return ans, nil -} - -func Read(self MMap, b []byte) (n int, err error) { - pos, err := self.Seek(0, io.SeekCurrent) - if err != nil { - return 0, err - } - if pos < 0 { - pos = 0 - } - s := self.Slice() - sz := int64(len(s)) - if pos >= sz { - return 0, io.EOF - } - n = copy(b, s[pos:]) - _, err = self.Seek(int64(n), io.SeekCurrent) - return -} - -func Write(self MMap, b []byte) (n int, err error) { - if len(b) == 0 { - return 0, nil - } - pos, _ := self.Seek(0, io.SeekCurrent) - if pos < 0 { - pos = 0 - } - s := self.Slice() - if pos >= int64(len(s)) { - return 0, io.ErrShortWrite - } - n = copy(s[pos:], b) - if _, err = self.Seek(int64(n), io.SeekCurrent); err != nil { - return n, err - } - if n < len(b) { - return n, io.ErrShortWrite - } - return n, nil -} - -func test_integration_with_python(args []string) (rc int, err error) { - switch args[0] { - default: - return 1, fmt.Errorf("Unknown test type: %s", args[0]) - case "read": - data, err := ReadWithSizeAndUnlink(args[1]) - if err != nil { - return 1, err - } - _, err = os.Stdout.Write(data) - if err != nil { - return 1, err - } - case "write": - data, err := io.ReadAll(os.Stdin) - if err != nil { - return 1, err - } - mmap, err := CreateTemp("shmtest-", uint64(len(data)+NUM_BYTES_FOR_SIZE)) - if err != nil { - return 1, err - } - if err = WriteWithSize(mmap, data, 0); err != nil { - return 1, err - } - mmap.Close() - fmt.Println(mmap.Name()) - } - return 0, nil -} - -func TestEntryPoint(root *cli.Command) { - root.AddSubCommand(&cli.Command{ - Name: "shm", - OnlyArgsAllowed: true, - Run: func(cmd *cli.Command, args []string) (rc int, err error) { - return test_integration_with_python(args) - }, - }) - -} diff --git a/tools/utils/shm/shm_fs.go b/tools/utils/shm/shm_fs.go deleted file mode 100644 index a0d92abe8fe..00000000000 --- a/tools/utils/shm/shm_fs.go +++ /dev/null @@ -1,189 +0,0 @@ -// License: GPLv3 Copyright: 2022, Kovid Goyal, -//go:build linux || netbsd || openbsd || dragonfly - -package shm - -import ( - "crypto/sha256" - "errors" - "fmt" - "io" - "io/fs" - "os" - "path/filepath" - "runtime" - "strings" - - "github.com/kovidgoyal/kitty/tools/utils" - - "golang.org/x/sys/unix" -) - -var _ = fmt.Print - -type file_based_mmap struct { - f *os.File - pos int64 - region []byte - unlinked bool - special_name string -} - -func ShmUnlink(name string) error { - if runtime.GOOS == "openbsd" { - return os.Remove(openbsd_shm_path(name)) - } - name = strings.TrimPrefix(name, "/") - return os.Remove(filepath.Join(SHM_DIR, name)) -} - -func file_mmap(f *os.File, size uint64, access AccessFlags, truncate bool, special_name string) (MMap, error) { - if truncate { - err := truncate_or_unlink(f, size, os.Remove) - if err != nil { - return nil, err - } - } - region, err := mmap(int(size), access, int(f.Fd()), 0) - if err != nil { - f.Close() - os.Remove(f.Name()) - return nil, err - } - return &file_based_mmap{f: f, region: region, special_name: special_name}, nil -} - -func (self *file_based_mmap) Seek(offset int64, whence int) (ret int64, err error) { - switch whence { - case io.SeekStart: - self.pos = offset - case io.SeekEnd: - self.pos = int64(len(self.region)) + offset - case io.SeekCurrent: - self.pos += offset - } - return self.pos, nil -} - -func (self *file_based_mmap) Read(b []byte) (n int, err error) { - return Read(self, b) -} - -func (self *file_based_mmap) Write(b []byte) (n int, err error) { - return Write(self, b) -} - -func (self *file_based_mmap) Stat() (fs.FileInfo, error) { - return self.f.Stat() -} - -func (self *file_based_mmap) Name() string { - if self.special_name != "" { - return self.special_name - } - return filepath.Base(self.f.Name()) -} - -func (self *file_based_mmap) Flush() error { - return unix.Msync(self.region, unix.MS_SYNC) -} - -func (self *file_based_mmap) FileSystemName() string { - return self.f.Name() -} - -func (self *file_based_mmap) Slice() []byte { - return self.region -} - -func (self *file_based_mmap) Close() (err error) { - if self.region != nil { - self.f.Close() - err = munmap(self.region) - self.region = nil - } - return err -} - -func (self *file_based_mmap) Unlink() (err error) { - if self.unlinked { - return nil - } - self.unlinked = true - return os.Remove(self.f.Name()) -} - -func (self *file_based_mmap) IsFileSystemBacked() bool { return true } - -func openbsd_shm_path(name string) string { - hash := sha256.Sum256(utils.UnsafeStringToBytes(name)) - return filepath.Join(SHM_DIR, utils.UnsafeBytesToString(hash[:])+".shm") -} - -func file_path_from_name(name string) string { - // See https://github.com/openbsd/src/blob/master/lib/libc/gen/shm_open.c - if runtime.GOOS == "openbsd" { - return openbsd_shm_path(name) - } - return filepath.Join(SHM_DIR, name) -} - -func create_temp(pattern string, size uint64) (ans MMap, err error) { - special_name := "" - var prefix, suffix string - prefix, suffix, err = prefix_and_suffix(pattern) - if err != nil { - return - } - var f *os.File - try := 0 - for { - name := prefix + utils.RandomFilename() + suffix - path := file_path_from_name(name) - f, err = os.OpenFile(path, os.O_EXCL|os.O_CREATE|os.O_RDWR, 0600) - if err != nil { - if errors.Is(err, fs.ErrExist) { - try += 1 - if try > 10000 { - return nil, &os.PathError{Op: "createtemp", Path: prefix + "*" + suffix, Err: fs.ErrExist} - } - continue - } - if errors.Is(err, fs.ErrNotExist) { - return nil, &ErrNotSupported{err: err} - } - return - } - break - } - return file_mmap(f, size, WRITE, true, special_name) -} - -func open(name string) (*os.File, error) { - ans, err := os.OpenFile(file_path_from_name(name), os.O_RDONLY, 0) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - if _, serr := os.Stat(SHM_DIR); serr != nil && errors.Is(serr, fs.ErrNotExist) { - return nil, &ErrNotSupported{err: serr} - } - } - return nil, err - } - return ans, nil -} - -func Open(name string, size uint64) (MMap, error) { - ans, err := open(name) - if err != nil { - return nil, err - } - if size == 0 { - s, err := ans.Stat() - if err != nil { - ans.Close() - return nil, fmt.Errorf("Failed to stat SHM file with error: %w", err) - } - size = uint64(s.Size()) - } - return file_mmap(ans, size, READ, false, name) -} diff --git a/tools/utils/shm/shm_syscall.go b/tools/utils/shm/shm_syscall.go deleted file mode 100644 index ea0e2447901..00000000000 --- a/tools/utils/shm/shm_syscall.go +++ /dev/null @@ -1,200 +0,0 @@ -// License: GPLv3 Copyright: 2022, Kovid Goyal, -//go:build darwin || freebsd - -package shm - -import ( - "errors" - "fmt" - "io" - "io/fs" - "os" - "strings" - "unsafe" - - "github.com/kovidgoyal/kitty/tools/utils" - - "golang.org/x/sys/unix" -) - -var _ = fmt.Print - -// ByteSliceFromString makes a zero terminated byte slice from the string -func ByteSliceFromString(s string) []byte { - a := make([]byte, len(s)+1) - copy(a, s) - return a -} - -func BytePtrFromString(s string) *byte { - a := ByteSliceFromString(s) - return &a[0] -} - -func shm_unlink(name string) (err error) { - bname := BytePtrFromString(name) - for { - _, _, errno := unix.Syscall(unix.SYS_SHM_UNLINK, uintptr(unsafe.Pointer(bname)), 0, 0) - if errno != unix.EINTR { - if errno != 0 { - if errno == unix.ENOENT { - err = fs.ErrNotExist - } else { - err = fmt.Errorf("shm_unlink() failed with error: %w", errno) - } - } - break - } - } - return -} - -func ShmUnlink(name string) error { - return shm_unlink(name) -} - -func shm_open(name string, flags, perm int) (ans *os.File, err error) { - bname := BytePtrFromString(name) - var fd uintptr - var errno unix.Errno - for { - fd, _, errno = unix.Syscall(unix.SYS_SHM_OPEN, uintptr(unsafe.Pointer(bname)), uintptr(flags), uintptr(perm)) - if errno != unix.EINTR { - if errno != 0 { - err = fmt.Errorf("shm_open() failed with error: %w", errno) - } - break - } - } - if err == nil { - ans = os.NewFile(fd, name) - } - return -} - -type syscall_based_mmap struct { - f *os.File - pos int64 - region []byte - unlinked bool -} - -func syscall_mmap(f *os.File, size uint64, access AccessFlags, truncate bool) (MMap, error) { - if truncate { - err := truncate_or_unlink(f, size, shm_unlink) - if err != nil { - return nil, fmt.Errorf("truncate failed with error: %w", err) - } - } - region, err := mmap(int(size), access, int(f.Fd()), 0) - if err != nil { - _ = f.Close() - _ = shm_unlink(f.Name()) - return nil, fmt.Errorf("mmap failed with error: %w", err) - } - return &syscall_based_mmap{f: f, region: region}, nil -} - -func (self *syscall_based_mmap) Name() string { - return self.f.Name() -} -func (self *syscall_based_mmap) Stat() (fs.FileInfo, error) { - return self.f.Stat() -} - -func (self *syscall_based_mmap) Flush() error { - return unix.Msync(self.region, unix.MS_SYNC) -} - -func (self *syscall_based_mmap) Slice() []byte { - return self.region -} - -func (self *syscall_based_mmap) Close() (err error) { - if self.region != nil { - self.f.Close() - munmap(self.region) - self.region = nil - } - return -} - -func (self *syscall_based_mmap) Unlink() (err error) { - if self.unlinked { - return nil - } - self.unlinked = true - return shm_unlink(self.Name()) -} - -func (self *syscall_based_mmap) Seek(offset int64, whence int) (ret int64, err error) { - switch whence { - case io.SeekStart: - self.pos = offset - case io.SeekEnd: - self.pos = int64(len(self.region)) + offset - case io.SeekCurrent: - self.pos += offset - } - return self.pos, nil -} - -func (self *syscall_based_mmap) Read(b []byte) (n int, err error) { - return Read(self, b) -} - -func (self *syscall_based_mmap) Write(b []byte) (n int, err error) { - return Write(self, b) -} - -func (self *syscall_based_mmap) IsFileSystemBacked() bool { return false } -func (self *syscall_based_mmap) FileSystemName() string { return "" } - -func create_temp(pattern string, size uint64) (ans MMap, err error) { - var prefix, suffix string - prefix, suffix, err = prefix_and_suffix(pattern) - if err != nil { - return - } - if SHM_REQUIRED_PREFIX != "" && !strings.HasPrefix(pattern, SHM_REQUIRED_PREFIX) { - // FreeBSD requires name to start with / - prefix = SHM_REQUIRED_PREFIX + prefix - } - var f *os.File - try := 0 - for { - name := prefix + utils.RandomFilename() + suffix - if len(name) > SHM_NAME_MAX { - return nil, ErrPatternTooLong - } - f, err = shm_open(name, os.O_EXCL|os.O_CREATE|os.O_RDWR, 0600) - if err != nil && (errors.Is(err, fs.ErrExist) || errors.Unwrap(err) == unix.EEXIST) { - try += 1 - if try > 10000 { - return nil, &os.PathError{Op: "createtemp", Path: prefix + "*" + suffix, Err: fs.ErrExist} - } - continue - } - break - } - if err != nil { - return nil, err - } - return syscall_mmap(f, size, WRITE, true) -} - -func Open(name string, size uint64) (MMap, error) { - ans, err := shm_open(name, os.O_RDONLY, 0) - if err != nil { - return nil, err - } - if size == 0 { - s, err := ans.Stat() - if err != nil { - ans.Close() - return nil, fmt.Errorf("Failed to stat SHM file with error: %w", err) - } - size = uint64(s.Size()) - } - return syscall_mmap(ans, size, READ, false) -} diff --git a/tools/utils/shm/shm_test.go b/tools/utils/shm/shm_test.go deleted file mode 100644 index 1cd2b7de8b7..00000000000 --- a/tools/utils/shm/shm_test.go +++ /dev/null @@ -1,61 +0,0 @@ -// License: GPLv3 Copyright: 2022, Kovid Goyal, - -package shm - -import ( - "crypto/rand" - "errors" - "fmt" - "io/fs" - "os" - "reflect" - "testing" -) - -var _ = fmt.Print - -func TestSHM(t *testing.T) { - data := make([]byte, 13347) - _, _ = rand.Read(data) - mm, err := CreateTemp("test-kitty-shm-", uint64(len(data))) - if err != nil { - t.Fatal(err) - } - - copy(mm.Slice(), data) - err = mm.Flush() - if err != nil { - t.Fatalf("Failed to msync() with error: %v", err) - } - err = mm.Close() - if err != nil { - t.Fatalf("Failed to close with error: %v", err) - } - - g, err := Open(mm.Name(), uint64(len(data))) - if err != nil { - t.Fatal(err) - } - data2 := g.Slice() - if !reflect.DeepEqual(data, data2) { - t.Fatalf("Could not read back written data: Written data length: %d Read data length: %d", len(data), len(data2)) - } - err = g.Close() - if err != nil { - t.Fatalf("Failed to close with error: %v", err) - } - err = g.Unlink() - if err != nil { - t.Fatalf("Failed to unlink with error: %v", err) - } - g, err = Open(mm.Name(), uint64(len(data))) - if err == nil { - t.Fatalf("Unlinking failed could re-open the SHM data. Data equal: %v Data length: %d", reflect.DeepEqual(g.Slice(), data), len(g.Slice())) - } - if mm.IsFileSystemBacked() { - _, err = os.Stat(mm.FileSystemName()) - if !errors.Is(err, fs.ErrNotExist) { - t.Fatalf("Unlinking %s did not work", mm.Name()) - } - } -} diff --git a/tools/utils/shm/specific_darwin.go b/tools/utils/shm/specific_darwin.go deleted file mode 100644 index 99222e9523d..00000000000 --- a/tools/utils/shm/specific_darwin.go +++ /dev/null @@ -1,7 +0,0 @@ -// License: GPLv3 Copyright: 2022, Kovid Goyal, - -package shm - -const SHM_NAME_MAX = 30 -const SHM_REQUIRED_PREFIX = "" -const SHM_DIR = "" diff --git a/tools/utils/shm/specific_dragonfly.go b/tools/utils/shm/specific_dragonfly.go deleted file mode 100644 index 7e5bdcd36c8..00000000000 --- a/tools/utils/shm/specific_dragonfly.go +++ /dev/null @@ -1,12 +0,0 @@ -// License: GPLv3 Copyright: 2022, Kovid Goyal, - -package shm - -import ( - "fmt" -) - -var _ = fmt.Print - -// https://www.dragonflybsd.org/cgi/web-man?command=shm_open§ion=3 -const SHM_DIR = "/var/run/shm" diff --git a/tools/utils/shm/specific_freebsd.go b/tools/utils/shm/specific_freebsd.go deleted file mode 100644 index b2c7b6b88ee..00000000000 --- a/tools/utils/shm/specific_freebsd.go +++ /dev/null @@ -1,7 +0,0 @@ -// License: GPLv3 Copyright: 2022, Kovid Goyal, - -package shm - -const SHM_NAME_MAX = 1023 -const SHM_REQUIRED_PREFIX = "/" -const SHM_DIR = "" diff --git a/tools/utils/shm/specific_linux.go b/tools/utils/shm/specific_linux.go deleted file mode 100644 index cab8901e757..00000000000 --- a/tools/utils/shm/specific_linux.go +++ /dev/null @@ -1,11 +0,0 @@ -// License: GPLv3 Copyright: 2022, Kovid Goyal, - -package shm - -import ( - "fmt" -) - -var _ = fmt.Print - -const SHM_DIR = "/dev/shm" diff --git a/tools/utils/shm/specific_netbsd.go b/tools/utils/shm/specific_netbsd.go deleted file mode 100644 index 209095a2ebc..00000000000 --- a/tools/utils/shm/specific_netbsd.go +++ /dev/null @@ -1,11 +0,0 @@ -// License: GPLv3 Copyright: 2022, Kovid Goyal, - -package shm - -import ( - "fmt" -) - -var _ = fmt.Print - -const SHM_DIR = "/var/shm" diff --git a/tools/utils/shm/specific_openbsd.go b/tools/utils/shm/specific_openbsd.go deleted file mode 100644 index e1b604e62ee..00000000000 --- a/tools/utils/shm/specific_openbsd.go +++ /dev/null @@ -1,11 +0,0 @@ -// License: GPLv3 Copyright: 2022, Kovid Goyal, - -package shm - -import ( - "fmt" -) - -var _ = fmt.Print - -const SHM_DIR = "/tmp" From 456fa8691a94f99fae0cef7f19dd2c85c208445a Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 11 Nov 2025 09:35:55 +0530 Subject: [PATCH 20/53] Fix #9211 --- kitty_tests/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kitty_tests/__init__.py b/kitty_tests/__init__.py index bd9ce655f0a..2af8f7a3cff 100644 --- a/kitty_tests/__init__.py +++ b/kitty_tests/__init__.py @@ -402,7 +402,7 @@ def write_to_child(self, data, flush=False): self.process_input_from_child(0) def send_da1_response(self, data: str) -> None: - self.write_to_child('\x1b[' + data, flush=True) + self.write_to_child('\x1b[' + data, flush=False) # ]]]]]] def send_cmd_to_child(self, cmd, flush=False): self.callbacks.last_cmd_exit_status = sys.maxsize From 1faf786bd22a11f009fa26978627698e33e471b2 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 11 Nov 2025 09:42:42 +0530 Subject: [PATCH 21/53] Remove parse error forsystemd's useless OSC 3008 context protocol It's spamming the test logs. Just ignore it silently --- kitty/screen.c | 5 +++++ kitty/screen.h | 1 + kitty/vt-parser.c | 5 ++++- kitty/window.py | 3 +++ 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/kitty/screen.c b/kitty/screen.c index c3db48f5a01..0c042e29bd0 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -3009,6 +3009,11 @@ set_title(Screen *self, PyObject *title) { CALLBACK("title_changed", "O", title); } +void +osc_context(Screen *self, PyObject *ctx) { + CALLBACK("osc_context", "O", ctx); +} + void desktop_notify(Screen *self, unsigned int osc_code, PyObject *data) { CALLBACK("desktop_notify", "IO", osc_code, data); diff --git a/kitty/screen.h b/kitty/screen.h index 07dd46ec50f..a81c537fe36 100644 --- a/kitty/screen.h +++ b/kitty/screen.h @@ -248,6 +248,7 @@ void screen_pop_colors(Screen *, unsigned int); void screen_report_color_stack(Screen *); void screen_handle_kitty_dcs(Screen *, const char *callback_name, PyObject *cmd); void set_title(Screen *self, PyObject*); +void osc_context(Screen *self, PyObject *ctx); void desktop_notify(Screen *self, unsigned int, PyObject*); void set_icon(Screen *self, PyObject*); void set_dynamic_color(Screen *self, unsigned int code, PyObject*); diff --git a/kitty/vt-parser.c b/kitty/vt-parser.c index 8c56eef40bf..8c5f9cbe6fa 100644 --- a/kitty/vt-parser.c +++ b/kitty/vt-parser.c @@ -602,7 +602,10 @@ dispatch_osc(PS *self, uint8_t *buf, size_t limit, bool is_extended_osc) { case 666: REPORT_ERROR("Ignoring OSC 666, typically used by VTE terminals for shell integration"); break; case 697: REPORT_ERROR("Ignoring OSC 697, typically used by Fig for shell integration"); break; case 701: REPORT_ERROR("Ignoring OSC 701, used by mintty for locale"); break; - case 3008: REPORT_ERROR("Ignoring OSC 3008, used by systemd for OSC-context"); break; + case 3008: + START_DISPATCH + DISPATCH_OSC(set_title); + END_DISPATCH case 7704: REPORT_ERROR("Ignoring OSC 7704, used by mintty for ANSI colors"); break; case 7750: REPORT_ERROR("Ignoring OSC 7750, used by mintty for Emoji style"); break; case 7770: REPORT_ERROR("Ignoring OSC 7770, used by mintty for font size"); break; diff --git a/kitty/window.py b/kitty/window.py index 297ddd59c2d..63affb6ed32 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -1286,6 +1286,9 @@ def title_changed(self, new_title: memoryview | None, is_base64: bool = False) - if self.override_title is None: self.title_updated() + def osc_context(self, ctx_data: memoryview) -> None: + pass # this is systemd's useless OSC 3008 context protocol + def icon_changed(self, new_icon: memoryview) -> None: pass # TODO: Implement this From 6f588a0c29b0122bb0652fc08e69f522c8d48f2d Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 11 Nov 2025 17:09:37 +0530 Subject: [PATCH 22/53] run modernize --- kittens/choose_files/scan_test.go | 10 +++++----- kittens/choose_fonts/face.go | 2 +- kittens/clipboard/read.go | 2 +- kittens/desktop_ui/portal.go | 2 +- kittens/diff/diff.go | 7 ++----- kittens/hints/marks.go | 2 +- kittens/hyperlinked_grep/main.go | 4 ++-- kittens/notify/main.go | 2 +- kittens/transfer/ftc_test.go | 4 ++-- kittens/transfer/send_test.go | 2 +- tools/cli/completion.go | 2 +- tools/cli/files.go | 9 ++------- tools/cli/markup/prettify.go | 2 +- tools/cmd/at/main.go | 2 +- tools/cmd/at/send_text.go | 5 +---- tools/config/utils.go | 2 +- tools/rsync/api_test.go | 2 +- tools/simdstring/benchmarks_test.go | 4 ++-- tools/simdstring/intrinsics_test.go | 10 +++++----- tools/simdstring/scalar.go | 2 +- tools/themes/collection.go | 2 +- tools/tui/run.go | 2 +- tools/tui/shell_integration/api.go | 2 +- tools/utils/base85/base85.go | 6 +++--- tools/utils/humanize/times.go | 2 +- tools/utils/longest-common.go | 4 ++-- tools/utils/paths.go | 2 +- tools/utils/short-uuid.go | 5 +---- tools/vt/linebuf.go | 2 +- tools/wcswidth/wcswidth.go | 2 +- 30 files changed, 46 insertions(+), 60 deletions(-) diff --git a/kittens/choose_files/scan_test.go b/kittens/choose_files/scan_test.go index 146cc8ed0dd..0d230a7ace2 100644 --- a/kittens/choose_files/scan_test.go +++ b/kittens/choose_files/scan_test.go @@ -94,7 +94,7 @@ func (n node) ReadFile(name string) ([]byte, error) { return nil, fs.ErrNotExist } p := &n - for _, x := range strings.Split(strings.Trim(name, string(os.PathSeparator)), string(os.PathSeparator)) { + for x := range strings.SplitSeq(strings.Trim(name, string(os.PathSeparator)), string(os.PathSeparator)) { c, found := p.children[x] if !found { return nil, fs.ErrNotExist @@ -109,7 +109,7 @@ func (n node) ReadDir(name string) ([]fs.DirEntry, error) { return n.dir_entries(), nil } p := &n - for _, x := range strings.Split(strings.Trim(name, string(os.PathSeparator)), string(os.PathSeparator)) { + for x := range strings.SplitSeq(strings.Trim(name, string(os.PathSeparator)), string(os.PathSeparator)) { c, found := p.children[x] if !found { return nil, fs.ErrNotExist @@ -334,11 +334,11 @@ func TestSortedResults(t *testing.T) { } func run_scoring(b *testing.B, depth, breadth int, query string) { - b.StopTimer() + root := node{name: string(os.PathSeparator)} root.generate_random_tree(depth, breadth) - b.StartTimer() - for range b.N { + + for b.Loop() { b.StopTimer() wg := sync.WaitGroup{} wg.Add(1) diff --git a/kittens/choose_fonts/face.go b/kittens/choose_fonts/face.go index 477a10289a3..aaa6dd0d7a7 100644 --- a/kittens/choose_fonts/face.go +++ b/kittens/choose_fonts/face.go @@ -101,7 +101,7 @@ func (self *face_panel) draw_axis(sz loop.ScreenSize, y int, ax VariableAxis, ax } frac := (min(axis_value, ax.Maximum) - ax.Minimum) / (ax.Maximum - ax.Minimum) current_cell := int(math.Floor(frac * float64(num_of_cells-1))) - for i := 0; i < num_of_cells; i++ { + for i := range num_of_cells { buf.WriteString(utils.IfElse(i == current_cell, lp.SprintStyled(current_val_style, `⬤`), tui.InternalHyperlink("•", fmt.Sprintf("axis:%d/%d:%s", i, num_of_cells-1, ax.Tag)))) } diff --git a/kittens/clipboard/read.go b/kittens/clipboard/read.go index 6634336716c..e2e138caf77 100644 --- a/kittens/clipboard/read.go +++ b/kittens/clipboard/read.go @@ -258,7 +258,7 @@ func parse_escape_code(etype loop.EscapeCodeType, data []byte) (metadata map[str } } if len(parts) > 1 { - for _, record := range bytes.Split(parts[1], utils.UnsafeStringToBytes(":")) { + for record := range bytes.SplitSeq(parts[1], utils.UnsafeStringToBytes(":")) { rp := bytes.SplitN(record, utils.UnsafeStringToBytes("="), 2) v := "" if len(rp) == 2 { diff --git a/kittens/desktop_ui/portal.go b/kittens/desktop_ui/portal.go index 9f010906ec6..c0595c0e4b9 100644 --- a/kittens/desktop_ui/portal.go +++ b/kittens/desktop_ui/portal.go @@ -452,7 +452,7 @@ Exec=%s desktop-ui run-server patched_file := "" desktops := utils.Filter(strings.Split(d, ":"), func(x string) bool { return x != "" }) desktops = append(desktops, "") - for _, x := range strings.Split(d, ":") { + for x := range strings.SplitSeq(d, ":") { q := filepath.Join(cf, utils.IfElse(x == "", "portals.conf", fmt.Sprintf("%s-portals.conf", strings.ToLower(x)))) if text, err := os.ReadFile(q); err == nil { text := patch_portals_conf(text) diff --git a/kittens/diff/diff.go b/kittens/diff/diff.go index da903b3a1aa..ca9b6667c37 100644 --- a/kittens/diff/diff.go +++ b/kittens/diff/diff.go @@ -119,10 +119,7 @@ func Diff(oldName, old, newName, new string, num_of_context_lines int) []byte { // End chunk with common lines for context. if len(ctext) > 0 { - n := end.x - start.x - if n > C { - n = C - } + n := min(end.x-start.x, C) for _, s := range x[start.x : start.x+n] { ctext = append(ctext, " "+s) count.x++ @@ -237,7 +234,7 @@ func tgs(x, y []string) []pair { for i := range T { T[i] = n + 1 } - for i := 0; i < n; i++ { + for i := range n { k := sort.Search(n, func(k int) bool { return T[k] >= J[i] }) diff --git a/kittens/hints/marks.go b/kittens/hints/marks.go index 1dab50887f3..b24da2f9deb 100644 --- a/kittens/hints/marks.go +++ b/kittens/hints/marks.go @@ -79,7 +79,7 @@ func process_escape_codes(text string) (ans string, hyperlinks []Mark) { active_hyperlink_url = url active_hyperlink_start_offset = start if metadata != "" { - for _, entry := range strings.Split(metadata, ":") { + for entry := range strings.SplitSeq(metadata, ":") { if strings.HasPrefix(entry, "id=") && len(entry) > 3 { active_hyperlink_id = entry[3:] } diff --git a/kittens/hyperlinked_grep/main.go b/kittens/hyperlinked_grep/main.go index c83f77da253..38bab8a4b38 100644 --- a/kittens/hyperlinked_grep/main.go +++ b/kittens/hyperlinked_grep/main.go @@ -47,7 +47,7 @@ func get_options_for_rg() (expecting_args map[string]bool, alias_map map[string] expecting_arg := strings.Contains(s, "=") single_letter_aliases := make([]string, 0, 1) long_option_names := make([]string, 0, 1) - for _, x := range strings.Split(s, ",") { + for x := range strings.SplitSeq(s, ",") { x = strings.TrimSpace(x) if strings.HasPrefix(x, "--") { lon, _, _ := strings.Cut(x[2:], "=") @@ -136,7 +136,7 @@ func parse_args(args ...string) (delegate_to_rg bool, sanitized_args []string, k if !found || k != "hyperlink" { return fmt.Errorf("Unknown --kitten option: %s", val) } - for _, x := range strings.Split(v, ",") { + for x := range strings.SplitSeq(v, ",") { switch x { case "none": kitten_opts.context_lines = false diff --git a/kittens/notify/main.go b/kittens/notify/main.go index ff99c891846..594f09ea8d2 100644 --- a/kittens/notify/main.go +++ b/kittens/notify/main.go @@ -137,7 +137,7 @@ func (p *parsed_data) run_loop() (err error) { raw := utils.UnsafeBytesToString(data[len(ESC_CODE_PREFIX[2:]):]) metadata, payload, _ := strings.Cut(raw, ";") sent_identifier, payload_type := "", "" - for _, x := range strings.Split(metadata, ":") { + for x := range strings.SplitSeq(metadata, ":") { key, val, _ := strings.Cut(x, "=") switch key { case "i": diff --git a/kittens/transfer/ftc_test.go b/kittens/transfer/ftc_test.go index 9880b43960f..67f62bde1f5 100644 --- a/kittens/transfer/ftc_test.go +++ b/kittens/transfer/ftc_test.go @@ -18,11 +18,11 @@ func TestFTCSerialization(t *testing.T) { q := func(expected string) { actual := ftc.Serialize() ad := make(map[string]bool) - for _, x := range strings.Split(actual, ";") { + for x := range strings.SplitSeq(actual, ";") { ad[x] = true } ed := make(map[string]bool) - for _, x := range strings.Split(expected, ";") { + for x := range strings.SplitSeq(expected, ";") { ed[x] = true } if diff := cmp.Diff(ed, ad); diff != "" { diff --git a/kittens/transfer/send_test.go b/kittens/transfer/send_test.go index 89969121bc2..0290a1c05f4 100644 --- a/kittens/transfer/send_test.go +++ b/kittens/transfer/send_test.go @@ -45,7 +45,7 @@ func TestPathMappingSend(t *testing.T) { actual[f.expanded_local_path] = f.remote_path } e := make(map[string]string, len(actual)) - for _, rec := range strings.Split(expected, " ") { + for rec := range strings.SplitSeq(expected, " ") { k, v, _ := strings.Cut(rec, ":") e[mp(k, false)] = mp(v, true) } diff --git a/tools/cli/completion.go b/tools/cli/completion.go index f502c57be9f..f78b1aea662 100644 --- a/tools/cli/completion.go +++ b/tools/cli/completion.go @@ -105,7 +105,7 @@ type Delegate struct { type Completions struct { Groups []*MatchGroup `json:"groups,omitempty"` - Delegate Delegate `json:"delegate,omitempty"` + Delegate Delegate `json:"delegate"` CurrentCmd *Command `json:"-"` AllWords []string `json:"-"` // all words passed to parse_args() diff --git a/tools/cli/files.go b/tools/cli/files.go index cdb135ef79b..72e256f2b7c 100644 --- a/tools/cli/files.go +++ b/tools/cli/files.go @@ -7,6 +7,7 @@ import ( "mime" "os" "path/filepath" + "slices" "strings" "golang.org/x/sys/unix" @@ -185,13 +186,7 @@ func complete_by_fnmatch(prefix, cwd string, patterns []string) []string { } func complete_by_mimepat(prefix, cwd string, patterns []string) []string { - all_allowed := false - for _, p := range patterns { - if p == "*" { - all_allowed = true - break - } - } + all_allowed := slices.Contains(patterns, "*") return fname_based_completer(prefix, cwd, func(name string) bool { if all_allowed { return true diff --git a/tools/cli/markup/prettify.go b/tools/cli/markup/prettify.go index c6129f5c554..a2d5413968e 100644 --- a/tools/cli/markup/prettify.go +++ b/tools/cli/markup/prettify.go @@ -19,7 +19,7 @@ var _ = fmt.Print type Context struct { fmt_ctx style.Context - Cyan, Green, Blue, Magenta, Red, BrightRed, Yellow, Italic, Bold, Dim, Title, Exe, Opt, Emph, Err, Code func(args ...interface{}) string + Cyan, Green, Blue, Magenta, Red, BrightRed, Yellow, Italic, Bold, Dim, Title, Exe, Opt, Emph, Err, Code func(args ...any) string Url func(string, string) string } diff --git a/tools/cmd/at/main.go b/tools/cmd/at/main.go index f58210c4921..7ee108b39ec 100644 --- a/tools/cmd/at/main.go +++ b/tools/cmd/at/main.go @@ -184,7 +184,7 @@ func (self *ResponseData) UnmarshalJSON(data []byte) error { type Response struct { Ok bool `json:"ok"` - Data ResponseData `json:"data,omitempty"` + Data ResponseData `json:"data"` Error string `json:"error,omitempty"` Traceback string `json:"tb,omitempty"` } diff --git a/tools/cmd/at/send_text.go b/tools/cmd/at/send_text.go index 12eed10e6c3..d86d775887e 100644 --- a/tools/cmd/at/send_text.go +++ b/tools/cmd/at/send_text.go @@ -39,10 +39,7 @@ func parse_send_text(io_data *rc_io_data, args []string) error { } text := strings.Join(args, " ") text_gen := func(io_data *rc_io_data) (bool, error) { - limit := len(text) - if limit > 2048 { - limit = 2048 - } + limit := min(len(text), 2048) set_payload_data(io_data, "base64:"+base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(text[:limit]))) text = text[limit:] return len(text) == 0, nil diff --git a/tools/config/utils.go b/tools/config/utils.go index f83c4cbc474..48df04ef310 100644 --- a/tools/config/utils.go +++ b/tools/config/utils.go @@ -18,7 +18,7 @@ var _ = fmt.Print func ParseStrDict(val, record_sep, field_sep string) (map[string]string, error) { ans := make(map[string]string) - for _, record := range strings.Split(val, record_sep) { + for record := range strings.SplitSeq(val, record_sep) { key, val, found := strings.Cut(record, field_sep) if found { ans[key] = val diff --git a/tools/rsync/api_test.go b/tools/rsync/api_test.go index 63bffb02832..6d2a3a261bb 100644 --- a/tools/rsync/api_test.go +++ b/tools/rsync/api_test.go @@ -135,7 +135,7 @@ func generate_data(block_size, num_of_blocks int, extra ...string) []byte { e := strings.Join(extra, "") ans := make([]byte, num_of_blocks*block_size+len(e)) utils.Memset(ans, '_') - for i := 0; i < num_of_blocks; i++ { + for i := range num_of_blocks { offset := i * block_size copy(ans[offset:], strconv.Itoa(i)) } diff --git a/tools/simdstring/benchmarks_test.go b/tools/simdstring/benchmarks_test.go index d445d3bebc0..37587962bce 100644 --- a/tools/simdstring/benchmarks_test.go +++ b/tools/simdstring/benchmarks_test.go @@ -33,7 +33,7 @@ func BenchmarkIndexByte(b *testing.B) { f = bytes.IndexByte } b.Run(fmt.Sprintf("%s_sz=%d", which, pos), func(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { f(data, 'q') } }) @@ -54,7 +54,7 @@ func BenchmarkIndexByte2(b *testing.B) { f = index_byte2_scalar } b.Run(fmt.Sprintf("%s_sz=%d", which, pos), func(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { f(data, 'q', 'x') } }) diff --git a/tools/simdstring/intrinsics_test.go b/tools/simdstring/intrinsics_test.go index b0eedef49a6..23f29aec38e 100644 --- a/tools/simdstring/intrinsics_test.go +++ b/tools/simdstring/intrinsics_test.go @@ -48,7 +48,7 @@ func test_cmpeq_epi8(a, b []byte) []byte { func test_cmplt_epi8(t *testing.T, a, b []byte) []byte { ans := make([]byte, len(a)) var prev []byte - for which := 0; which < 3; which++ { + for which := range 3 { if len(ans) == 16 { test_cmplt_epi8_asm_128(a, b, which, ans) } else { @@ -161,7 +161,7 @@ func TestSIMDStringOps(t *testing.T) { } // test alignment issues q := []byte("abc") - for sz := 0; sz < 32; sz++ { + for sz := range 32 { test(q, '<', '>', sz) test(q, ' ', 'b', sz) test(q, '<', 'a', sz) @@ -172,7 +172,7 @@ func TestSIMDStringOps(t *testing.T) { tests := func(h string, a, b byte) { for _, sz := range []int{0, 16, 32, 64, 79} { q := strings.Repeat(" ", sz) + h - for sz := 0; sz < 32; sz++ { + for sz := range 32 { test([]byte(q), a, b, sz) } } @@ -326,7 +326,7 @@ func TestIntrinsics(t *testing.T) { if e := test_jump_if_zero(a); e != 0 { t.Fatalf("Did not detect zero register") } - for i := 0; i < sz; i++ { + for i := range sz { a = make([]byte, sz) a[i] = 1 if e := test_jump_if_zero(a); e != 1 { @@ -340,7 +340,7 @@ func TestIntrinsics(t *testing.T) { if e := test_count_to_match(a, 77); e != -1 { t.Fatalf("Unexpectedly found byte at: %d", e) } - for i := 0; i < sz; i++ { + for i := range sz { if e := test_count_to_match(a, byte(i)); e != i { t.Fatalf("Failed to find the byte: %d (%d != %d)", i, i, e) } diff --git a/tools/simdstring/scalar.go b/tools/simdstring/scalar.go index 5320aebe822..3044740ce05 100644 --- a/tools/simdstring/scalar.go +++ b/tools/simdstring/scalar.go @@ -41,7 +41,7 @@ func index_byte2_string_scalar(data string, a, b byte) int { } func index_c0_scalar(data []byte) int { - for i := 0; i < len(data); i++ { + for i := range data { if data[i] == 0x7f || data[i] < ' ' { return i } diff --git a/tools/themes/collection.go b/tools/themes/collection.go index 94022f2ed22..6191c92e6d6 100644 --- a/tools/themes/collection.go +++ b/tools/themes/collection.go @@ -688,7 +688,7 @@ func ColorSettingsAsEscapeCodes(settings map[string]string) string { set_default_color("selection_foreground", style.DefaultColors.SelectionFg, loop.SELECTION_FG) w.WriteString("\033]4") - for i := 0; i < 256; i++ { + for i := range 256 { key := "color" + strconv.Itoa(i) val := settings[key] if val != "" { diff --git a/tools/tui/run.go b/tools/tui/run.go index ff4abb4a8bc..223972772a9 100644 --- a/tools/tui/run.go +++ b/tools/tui/run.go @@ -137,7 +137,7 @@ func get_shell_name(argv0 string) (ans string) { func rc_modification_allowed(ksi string) (allowed bool, set_ksi_env_var bool) { allowed = ksi != "" set_ksi_env_var = true - for _, x := range strings.Split(ksi, " ") { + for x := range strings.SplitSeq(ksi, " ") { switch x { case "disabled": allowed = false diff --git a/tools/tui/shell_integration/api.go b/tools/tui/shell_integration/api.go index 1927a357cac..28352330254 100644 --- a/tools/tui/shell_integration/api.go +++ b/tools/tui/shell_integration/api.go @@ -110,7 +110,7 @@ func PathToTerminfoDb(term string) (ans string) { return ans } if td := os.Getenv("TERMINFO_DIRS"); td != "" { - for _, q := range strings.Split(td, string(os.PathListSeparator)) { + for q := range strings.SplitSeq(td, string(os.PathListSeparator)) { if q == "" { q = "/usr/share/terminfo" } diff --git a/tools/utils/base85/base85.go b/tools/utils/base85/base85.go index afbe98a73c1..d31199ce084 100644 --- a/tools/utils/base85/base85.go +++ b/tools/utils/base85/base85.go @@ -45,7 +45,7 @@ var decoder_array = sync.OnceValue(func() *[256]byte { 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, } - for i := 0; i < len(encode); i++ { + for i := range len(encode) { decode[encode[i]] = byte(i) } return &decode @@ -101,7 +101,7 @@ func decodeChunk(decode *[256]byte, dst, src []byte) (int, int) { var val uint32 m := DecodedLen(len(src)) buf := [5]byte{84, 84, 84, 84, 84} - for i := 0; i < len(src); i++ { + for i := range src { e := decode[src[i]] if e == 0xFF { return 0, i + 1 @@ -109,7 +109,7 @@ func decodeChunk(decode *[256]byte, dst, src []byte) (int, int) { buf[i] = e } - for i := 0; i < 5; i++ { + for i := range 5 { r := buf[i] val += uint32(r) * decode_base[i] } diff --git a/tools/utils/humanize/times.go b/tools/utils/humanize/times.go index 04eb6085b3a..72b5bce704f 100644 --- a/tools/utils/humanize/times.go +++ b/tools/utils/humanize/times.go @@ -100,7 +100,7 @@ func CustomRelTime(a, b time.Time, albl, blbl string, magnitudes []RelTimeMagnit n = len(magnitudes) - 1 } mag := magnitudes[n] - args := []interface{}{} + args := []any{} escaped := false for _, ch := range mag.Format { if escaped { diff --git a/tools/utils/longest-common.go b/tools/utils/longest-common.go index 1551395b06a..cbf46b862b3 100644 --- a/tools/utils/longest-common.go +++ b/tools/utils/longest-common.go @@ -58,14 +58,14 @@ func LongestCommon(next func() (string, bool), prefix bool) string { return "" } if prefix { - for i := 0; i < max_len; i++ { + for i := range max_len { if xfix[i] != q[i] { xfix = xfix[:i] break } } } else { - for i := 0; i < max_len; i++ { + for i := range max_len { xi := xfix_len - i - 1 si := q_len - i - 1 if xfix[xi] != q[si] { diff --git a/tools/utils/paths.go b/tools/utils/paths.go index 3c7e7e0273d..3dbced37d49 100644 --- a/tools/utils/paths.go +++ b/tools/utils/paths.go @@ -103,7 +103,7 @@ func ConfigDirForName(name string) (config_dir string) { add(xh) } if dirs := os.Getenv("XDG_CONFIG_DIRS"); dirs != "" { - for _, candidate := range strings.Split(dirs, ":") { + for candidate := range strings.SplitSeq(dirs, ":") { add(candidate) } } diff --git a/tools/utils/short-uuid.go b/tools/utils/short-uuid.go index ebdd43b6b01..c625a233ec1 100644 --- a/tools/utils/short-uuid.go +++ b/tools/utils/short-uuid.go @@ -21,10 +21,7 @@ func num_to_string(number *big.Int, alphabet []rune, alphabet_len *big.Int, pad_ if number.Sign() < 0 { *number = zero } - capacity := 64 - if pad_to_length > capacity { - capacity = pad_to_length - } + capacity := max(pad_to_length, 64) ans := make([]rune, 0, capacity) for number.Cmp(&zero) == 1 { number.DivMod(number, alphabet_len, &digit) diff --git a/tools/vt/linebuf.go b/tools/vt/linebuf.go index 73d02f1b93f..3cac1dbd4c2 100644 --- a/tools/vt/linebuf.go +++ b/tools/vt/linebuf.go @@ -18,7 +18,7 @@ type LineBuf struct { func NewLineBuf(xnum, ynum uint) *LineBuf { lm := make([]uint, ynum, ynum+extra_capacity) var i uint - for i = 0; i < ynum; i++ { + for i = range ynum { lm[i] = i } return &LineBuf{ diff --git a/tools/wcswidth/wcswidth.go b/tools/wcswidth/wcswidth.go index 02ea80a3205..332ac08f62e 100644 --- a/tools/wcswidth/wcswidth.go +++ b/tools/wcswidth/wcswidth.go @@ -49,7 +49,7 @@ func (self *WCWidthIterator) handle_csi(csi []byte) error { num_string := utils.UnsafeBytesToString(csi[:len(csi)-1]) n, err := strconv.Atoi(num_string) if err == nil && n > 0 { - for i := 0; i < n; i++ { + for range n { err = self.handle_rune(self.prev_ch) if err != nil { return err From d979be915c4c30384d59171a6f9a6c263d440783 Mon Sep 17 00:00:00 2001 From: Kevin Klement Date: Tue, 11 Nov 2025 08:33:05 -0500 Subject: [PATCH 23/53] Update main.py Fix misspellings of "within" --- kittens/choose_files/main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/kittens/choose_files/main.py b/kittens/choose_files/main.py index 4169085033f..f0657499b37 100644 --- a/kittens/choose_files/main.py +++ b/kittens/choose_files/main.py @@ -21,18 +21,18 @@ opt('show_hidden', 'last', choices=('last', 'yes', 'y', 'true', 'no', 'n', 'false'), long_text=''' Whether to show hidden files. The default value of :code:`last` means remember the last -used value. This setting can be toggled withing the program.''') +used value. This setting can be toggled within the program.''') opt('sort_by_last_modified', 'last', choices=('last', 'yes', 'y', 'true', 'no', 'n', 'false'), long_text=''' Whether to sort the list of entries by last modified, instead of name. Note that sorting only applies before any query is entered. Once a query is entered entries are sorted by their matching score. The default value of :code:`last` means remember the last -used value. This setting can be toggled withing the program.''') +used value. This setting can be toggled within the program.''') opt('respect_ignores', 'last', choices=('last', 'yes', 'y', 'true', 'no', 'n', 'false'), long_text=''' Whether to respect .gitignore and .ignore files and the :opt:`ignore` setting. The default value of :code:`last` means remember the last used value. -This setting can be toggled withing the program.''') +This setting can be toggled within the program.''') opt('+ignore', '', add_to_default=False, long_text=''' An ignore pattern to ignore matched files. Uses the same sytax as :code:`.gitignore` files (see :code:`man gitignore`). @@ -46,7 +46,7 @@ opt('show_preview', 'last', choices=('last', 'yes', 'y', 'true', 'no', 'n', 'false'), long_text=''' Whether to show a preview of the current file/directory. The default value of :code:`last` means remember the last -used value. This setting can be toggled withing the program.''') +used value. This setting can be toggled within the program.''') opt('pygments_style', 'default', long_text=''' The pygments color scheme to use for syntax highlighting of file previews. See :link:`pygments From 28afe23c45f18a0e353f6004ed16e624e95dc3f2 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 11 Nov 2025 21:26:53 +0530 Subject: [PATCH 24/53] Bump version of imaging This adds go native support for JPEG images using "non-standard" subsample ratios. Thanks to that and the rest of my work to add support for ICC profiles, we can now decode jpegli images using the builtin backend. That's a month of my life I will never get back coz Go's imaging libraries are utterly unmaintained and not fit for purpose. Fixes #8908 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index b7ff3a14553..2c71b2a2fbf 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( 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 - github.com/kovidgoyal/imaging v1.8.8 + github.com/kovidgoyal/imaging v1.8.9 github.com/seancfoley/ipaddress-go v1.7.1 github.com/shirou/gopsutil/v4 v4.25.10 github.com/zeebo/xxh3 v1.0.2 diff --git a/go.sum b/go.sum index 1a5d4acb2f6..a2f2ca8e283 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,8 @@ github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811ui github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw= github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo= github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds= -github.com/kovidgoyal/imaging v1.8.8 h1:PohlAOYuokFtmt6sjhgA90YAUKhuuL3i0dhd5gepp4g= -github.com/kovidgoyal/imaging v1.8.8/go.mod h1:GAbZkbyB86PSfosof5EnS2o6N15yUk9Vy2r61EWy1Wg= +github.com/kovidgoyal/imaging v1.8.9 h1:6YS8DCZfq/HHiOUMw2rM2GWkOe1Dc2wK5FlN7/ki4yg= +github.com/kovidgoyal/imaging v1.8.9/go.mod h1:ZcaLxFLdn9I+a9hyI847Q8CYtC5J0bAYbG3+WG8DM9Y= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a h1:N9zuLhTvBSRt0gWSiJswwQ2HqDmtX/ZCDJURnKUt1Ik= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= From 9be602f94f532d99d1cb8c18c1ce6d0afee07814 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 11 Nov 2025 21:39:12 +0530 Subject: [PATCH 25/53] ... --- tools/utils/images/to_rgba.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/utils/images/to_rgba.go b/tools/utils/images/to_rgba.go index 5e6a683017b..99287332750 100644 --- a/tools/utils/images/to_rgba.go +++ b/tools/utils/images/to_rgba.go @@ -8,6 +8,7 @@ import ( "math" "github.com/kovidgoyal/imaging" + "github.com/kovidgoyal/imaging/nrgba" "github.com/kovidgoyal/kitty/tools/tty" ) @@ -57,7 +58,7 @@ func (self *Context) run_paste(src imaging.Scanner, background image.Image, pos } func (self *Context) paste_nrgba_onto_opaque(background *image.NRGBA, img image.Image, pos image.Point, bgcol *imaging.NRGBColor) { - src := imaging.NewNRGBAScanner(img) + src := nrgba.NewNRGBAScanner(img) if bgcol == nil { self.run_paste(src, background, pos, func([]byte) {}) return From a9e71e5b5bad96ca04711356045a4c5618d08d68 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 11 Nov 2025 22:29:35 +0530 Subject: [PATCH 26/53] These days gnuplt comes with builtin KGP support --- docs/integrations.rst | 24 ++---------------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/docs/integrations.rst b/docs/integrations.rst index 957ffb51858..f1098d8d401 100644 --- a/docs/integrations.rst +++ b/docs/integrations.rst @@ -197,28 +197,8 @@ by kitty's graphics protocol for displaying plots `gnuplot `_ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -A graphing and data visualization tool that can be made to display its output in -kitty with the following bash snippet: - -.. code-block:: sh - - function iplot { - cat < Date: Wed, 12 Nov 2025 09:31:31 +0530 Subject: [PATCH 27/53] Use __block rather than __weak since we are in manual ref counting regime --- glfw/cocoa_init.m | 3 ++- glfw/cocoa_window.m | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/glfw/cocoa_init.m b/glfw/cocoa_init.m index 296949eed47..8a6ae6dfa7a 100644 --- a/glfw/cocoa_init.m +++ b/glfw/cocoa_init.m @@ -453,8 +453,9 @@ - (void)observeValueForKeyPath:(NSString *)keyPath if ([keyPath isEqualToString:@"effectiveAppearance"]) { // The initial call (from NSKeyValueObservingOptionInitial) might happen on a background thread. // Dispatch to the main thread to be safe, especially if updating UI. + __block __typeof__(self) weakSelf = self; dispatch_async(dispatch_get_main_queue(), ^{ - [self handleAppearanceChange]; + [weakSelf handleAppearanceChange]; }); } } else { diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index b4c952f13c6..7c0e1ab387e 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -2330,8 +2330,9 @@ void _glfwPlatformShowWindow(_GLFWwindow* window, bool move_to_active_screen) NSWindowCollectionBehavior old = nw.collectionBehavior; nw.collectionBehavior = (old & !NSWindowCollectionBehaviorCanJoinAllSpaces) | NSWindowCollectionBehaviorMoveToActiveSpace; [nw orderFront:nil]; + __block __typeof__(nw) weakSelf = nw; dispatch_async(dispatch_get_main_queue(), ^{ - nw.collectionBehavior = old; + weakSelf.collectionBehavior = old; }); } } From 301dc8a73615fc29f29ee31f68a1362e7d3119a4 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 12 Nov 2025 09:46:13 +0530 Subject: [PATCH 28/53] macOS: Ignore Tahoe zombie windows when cycling through windows with cmd+`. Fixes #9215 --- kitty/cocoa_window.m | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/kitty/cocoa_window.m b/kitty/cocoa_window.m index 9ec07e30bf5..9d20468bcd3 100644 --- a/kitty/cocoa_window.m +++ b/kitty/cocoa_window.m @@ -926,14 +926,20 @@ - (void)quickAccessTerminal:(NSPasteboard *)pboard userData:(NSString *)userData void cocoa_cycle_through_os_windows(void) { - NSArray *windows = [NSApp orderedWindows]; - if (windows.count < 2) return; - + NSArray *allWindows = [NSApp orderedWindows]; + if (allWindows.count < 2) return; + NSMutableArray *filteredWindows = [NSMutableArray array]; + for (NSWindow *window in allWindows) { + NSRect windowFrame = [window frame]; + // Exclude zero size windows which are likely zombie windows from the Tahoe bug + if (windowFrame.size.width > 0 && windowFrame.size.height > 0) [filteredWindows addObject:window]; + } + if (filteredWindows.count < 2) return; NSWindow *keyWindow = [NSApp keyWindow]; - NSUInteger index = [windows indexOfObject:keyWindow]; - NSUInteger nextIndex = (index + 1) % windows.count; - - NSWindow *nextWindow = windows[nextIndex]; + NSUInteger index = [filteredWindows indexOfObject:keyWindow]; + if (index < 0) return; + NSUInteger nextIndex = (index + 1) % filteredWindows.count; + NSWindow *nextWindow = filteredWindows[nextIndex]; [nextWindow makeKeyAndOrderFront:nil]; } From 7fe38ae5790276e3d9fb3b77b815f1d09a3aca6c Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 12 Nov 2025 10:36:45 +0530 Subject: [PATCH 29/53] macOS: Add a cycle through OS windows backwards action --- docs/changelog.rst | 4 ++++ kitty/boss.py | 6 +++++- kitty/child-monitor.c | 1 + kitty/cocoa_window.h | 3 ++- kitty/cocoa_window.m | 23 +++++++++++++++-------- kitty/fast_data_types.pyi | 2 +- kitty/glfw.c | 8 +++++--- kitty/main.py | 2 +- kitty/options/definition.py | 1 + kitty/options/types.py | 1 + 10 files changed, 36 insertions(+), 15 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index b42a55432ea..69638b9535e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -154,6 +154,10 @@ Detailed list of changes - Automatic color scheme switching: Fix title bar and scroll bar colors not being updated (:iss:`9167`) +- macOS: Fix cycle through OS windows only swapping between the two most recent + OS Windows. Also add a cycle through OS Windows backwards action. + (:iss:`9215`) + 0.44.0 [2025-11-03] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/kitty/boss.py b/kitty/boss.py index 3611fe9eb4a..b275946d6d6 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -1318,7 +1318,11 @@ def toggle_macos_secure_keyboard_entry(self) -> None: @ac('misc', 'Cycle through OS windows on macOS') def macos_cycle_through_os_windows(self) -> None: - macos_cycle_through_os_windows() + macos_cycle_through_os_windows(False) + + @ac('misc', 'Cycle through OS windows backwards on macOS') + def macos_cycle_through_os_windows_backwards(self) -> None: + macos_cycle_through_os_windows(True) @ac('misc', 'Hide macOS kitty application') def hide_macos_app(self) -> None: diff --git a/kitty/child-monitor.c b/kitty/child-monitor.c index a1351191481..4ed473a19c3 100644 --- a/kitty/child-monitor.c +++ b/kitty/child-monitor.c @@ -1241,6 +1241,7 @@ process_cocoa_pending_actions(void) { if (cocoa_pending_actions[RELOAD_CONFIG]) { call_boss(load_config_file, NULL); } if (cocoa_pending_actions[TOGGLE_MACOS_SECURE_KEYBOARD_ENTRY]) { call_boss(toggle_macos_secure_keyboard_entry, NULL); } if (cocoa_pending_actions[MACOS_CYCLE_THROUGH_OS_WINDOWS]) { call_boss(macos_cycle_through_os_windows, NULL); } + if (cocoa_pending_actions[MACOS_CYCLE_THROUGH_OS_WINDOWS_BACKWARDS]) { call_boss(macos_cycle_through_os_windows_backwards, NULL); } if (cocoa_pending_actions[TOGGLE_FULLSCREEN]) { call_boss(toggle_fullscreen, NULL); } if (cocoa_pending_actions[OPEN_KITTY_WEBSITE]) { call_boss(open_kitty_website, NULL); } if (cocoa_pending_actions[HIDE]) { call_boss(hide_macos_app, NULL); } diff --git a/kitty/cocoa_window.h b/kitty/cocoa_window.h index a723c5a537e..b9d916380d6 100644 --- a/kitty/cocoa_window.h +++ b/kitty/cocoa_window.h @@ -31,6 +31,7 @@ typedef enum { RELOAD_CONFIG, TOGGLE_MACOS_SECURE_KEYBOARD_ENTRY, MACOS_CYCLE_THROUGH_OS_WINDOWS, + MACOS_CYCLE_THROUGH_OS_WINDOWS_BACKWARDS, TOGGLE_FULLSCREEN, OPEN_KITTY_WEBSITE, HIDE, @@ -51,7 +52,7 @@ void cocoa_system_beep(const char*); void cocoa_set_activation_policy(bool); bool cocoa_alt_option_key_pressed(unsigned long); void cocoa_toggle_secure_keyboard_entry(void); -void cocoa_cycle_through_os_windows(void); +void cocoa_cycle_through_os_windows(bool); void cocoa_hide(void); void cocoa_clear_global_shortcuts(void); void cocoa_hide_others(void); diff --git a/kitty/cocoa_window.m b/kitty/cocoa_window.m index 9d20468bcd3..7f3d947c0be 100644 --- a/kitty/cocoa_window.m +++ b/kitty/cocoa_window.m @@ -259,6 +259,7 @@ - (void)user_menu_action:(id)sender { PENDING(reload_config, RELOAD_CONFIG) PENDING(toggle_macos_secure_keyboard_entry, TOGGLE_MACOS_SECURE_KEYBOARD_ENTRY) PENDING(macos_cycle_through_os_windows, MACOS_CYCLE_THROUGH_OS_WINDOWS) +PENDING(macos_cycle_through_os_windows_backwards, MACOS_CYCLE_THROUGH_OS_WINDOWS_BACKWARDS) PENDING(toggle_fullscreen, TOGGLE_FULLSCREEN) PENDING(open_kitty_website, OPEN_KITTY_WEBSITE) PENDING(hide_macos_app, HIDE) @@ -320,7 +321,7 @@ + (GlobalMenuTarget *) shared_instance GlobalShortcut clear_terminal_and_scrollback, clear_screen, clear_scrollback, clear_last_command; GlobalShortcut toggle_macos_secure_keyboard_entry, toggle_fullscreen, open_kitty_website; GlobalShortcut hide_macos_app, hide_macos_other_apps, minimize_macos_window, quit; - GlobalShortcut macos_cycle_through_os_windows; + GlobalShortcut macos_cycle_through_os_windows, macos_cycle_through_os_windows_backwards; } GlobalShortcuts; static GlobalShortcuts global_shortcuts; @@ -338,7 +339,8 @@ + (GlobalMenuTarget *) shared_instance else Q(clear_terminal_and_scrollback); else Q(clear_scrollback); else Q(clear_screen); else Q(clear_last_command); else Q(reload_config); else Q(toggle_macos_secure_keyboard_entry); else Q(toggle_fullscreen); else Q(open_kitty_website); else Q(hide_macos_app); else Q(hide_macos_other_apps); - else Q(minimize_macos_window); else Q(quit); else Q(macos_cycle_through_os_windows); + else Q(minimize_macos_window); else Q(quit); + else Q(macos_cycle_through_os_windows); else Q(macos_cycle_through_os_windows_backwards); #undef Q if (gs == NULL) { PyErr_SetString(PyExc_KeyError, "Unknown shortcut name"); return NULL; } int cocoa_mods; @@ -808,6 +810,7 @@ - (void)quickAccessTerminal:(NSPasteboard *)pboard userData:(NSString *)userData keyEquivalent:@""]; [windowMenu addItem:[NSMenuItem separatorItem]]; MENU_ITEM(windowMenu, @"Cycle Through OS Windows", macos_cycle_through_os_windows); + MENU_ITEM(windowMenu, @"Cycle Through OS Windows backwards", macos_cycle_through_os_windows_backwards); [windowMenu addItem:[NSMenuItem separatorItem]]; [windowMenu addItemWithTitle:@"Bring All to Front" action:@selector(arrangeInFront:) @@ -925,20 +928,24 @@ - (void)quickAccessTerminal:(NSPasteboard *)pboard userData:(NSString *)userData } void -cocoa_cycle_through_os_windows(void) { - NSArray *allWindows = [NSApp orderedWindows]; +cocoa_cycle_through_os_windows(bool backwards) { + NSArray *allWindows = [NSApp windows]; if (allWindows.count < 2) return; NSMutableArray *filteredWindows = [NSMutableArray array]; - for (NSWindow *window in allWindows) { + for (NSWindow *window in allWindows) { NSRect windowFrame = [window frame]; // Exclude zero size windows which are likely zombie windows from the Tahoe bug - if (windowFrame.size.width > 0 && windowFrame.size.height > 0) [filteredWindows addObject:window]; + if (windowFrame.size.width > 0 && windowFrame.size.height > 0 && !window.isMiniaturized && window.isVisible) [filteredWindows addObject:window]; } if (filteredWindows.count < 2) return; NSWindow *keyWindow = [NSApp keyWindow]; NSUInteger index = [filteredWindows indexOfObject:keyWindow]; - if (index < 0) return; - NSUInteger nextIndex = (index + 1) % filteredWindows.count; + NSUInteger nextIndex = 0; + if (index != NSNotFound) { + if (backwards) { + nextIndex = (index == 0) ? [filteredWindows count] - 1 : index - 1; + } else nextIndex = (index + 1) % filteredWindows.count; + } NSWindow *nextWindow = filteredWindows[nextIndex]; [nextWindow makeKeyAndOrderFront:nil]; } diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index f2f5fa7c4cc..54064b70e50 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -948,7 +948,7 @@ def toggle_secure_input() -> None: pass -def macos_cycle_through_os_windows() -> None: +def macos_cycle_through_os_windows(backwards: bool) -> None: pass diff --git a/kitty/glfw.c b/kitty/glfw.c index b62d4ae477e..b641633d9bd 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -1922,9 +1922,11 @@ toggle_secure_input(PYNOARG) { } static PyObject* -macos_cycle_through_os_windows(PYNOARG) { +macos_cycle_through_os_windows(PyObject *self UNUSED, PyObject *backwards) { #ifdef __APPLE__ - cocoa_cycle_through_os_windows(); + cocoa_cycle_through_os_windows(PyObject_IsTrue(backwards)); +#else + (void)backwards; #endif Py_RETURN_NONE; } @@ -2616,7 +2618,7 @@ static PyMethodDef module_methods[] = { METHODB(set_clipboard_data_types, METH_VARARGS), METHODB(get_clipboard_mime, METH_VARARGS), METHODB(toggle_secure_input, METH_NOARGS), - METHODB(macos_cycle_through_os_windows, METH_NOARGS), + METHODB(macos_cycle_through_os_windows, METH_O), METHODB(get_content_scale_for_window, METH_NOARGS), METHODB(ring_bell, METH_VARARGS), METHODB(toggle_fullscreen, METH_VARARGS), diff --git a/kitty/main.py b/kitty/main.py index f862659d2cd..15564a09903 100644 --- a/kitty/main.py +++ b/kitty/main.py @@ -193,7 +193,7 @@ def set_cocoa_global_shortcuts(opts: Options) -> dict[str, SingleKey]: for ac in ('new_os_window', 'close_os_window', 'close_tab', 'edit_config_file', 'previous_tab', 'next_tab', 'new_tab', 'new_window', 'close_window', 'toggle_macos_secure_keyboard_entry', - 'toggle_fullscreen', 'macos_cycle_through_os_windows', + 'toggle_fullscreen', 'macos_cycle_through_os_windows', 'macos_cycle_through_os_windows_backwards', 'hide_macos_app', 'hide_macos_other_apps', 'minimize_macos_window', 'quit'): val = get_macos_shortcut_for(func_map, ac) if val is not None: diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 0544a413baa..ef7e17af22d 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -4429,6 +4429,7 @@ ) map('macOS Cycle through OS Windows', 'macos_cycle_through_os_windows cmd+` macos_cycle_through_os_windows', only='macos') +map('macOS Cycle through OS Windows backwards', 'macos_cycle_through_os_windows_backwards cmd+shift+` macos_cycle_through_os_windows_backwards', only='macos') map('Unicode input', 'input_unicode_character kitty_mod+u kitten unicode_input', diff --git a/kitty/options/types.py b/kitty/options/types.py index cd9d8b8b6d4..c15a34e08c7 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -1006,6 +1006,7 @@ def __setattr__(self, key: str, val: typing.Any) -> typing.Any: defaults.map.append(KeyDefinition(trigger=SingleKey(mods=12, key=102), definition='toggle_fullscreen')) defaults.map.append(KeyDefinition(trigger=SingleKey(mods=10, key=115), definition='toggle_macos_secure_keyboard_entry')) defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=96), definition='macos_cycle_through_os_windows')) + defaults.map.append(KeyDefinition(trigger=SingleKey(mods=9, key=96), definition='macos_cycle_through_os_windows_backwards')) defaults.map.append(KeyDefinition(trigger=SingleKey(mods=12, key=32), definition='kitten unicode_input')) defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=44), definition='edit_config_file')) defaults.map.append(KeyDefinition(trigger=SingleKey(mods=10, key=114), definition='clear_terminal reset active')) From 81f429d52b97b7eda022be5ad2bed3fbe729d36b Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 12 Nov 2025 12:02:38 +0530 Subject: [PATCH 30/53] Move the code to cycle through OS Windows into glfw --- glfw/cocoa_window.m | 30 ++++++++++++++++++++++++++++++ glfw/glfw.py | 1 + kitty/cocoa_window.h | 1 - kitty/cocoa_window.m | 24 ------------------------ kitty/glfw-wrapper.c | 3 +++ kitty/glfw-wrapper.h | 4 ++++ kitty/glfw.c | 2 +- 7 files changed, 39 insertions(+), 26 deletions(-) diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index 7c0e1ab387e..feba4fdafb2 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -3480,6 +3480,36 @@ GLFWAPI void glfwCocoaSetWindowChrome(GLFWwindow *w, unsigned int color, bool us GLFWAPI bool glfwIsLayerShellSupported(void) { return true; } +GLFWAPI void +glfwCocoaCycleThroughOSWindows(bool backwards) { + NSArray *allWindows = [NSApp windows]; + if (allWindows.count < 2) return; + NSMutableArray *filteredWindows = [NSMutableArray array]; + for (NSWindow *window in allWindows) { + NSRect windowFrame = [window frame]; + // Exclude zero size windows which are likely zombie windows from the Tahoe bug + // if ([obj isMemberOfClass:[MyClass class]]) { + if ( + windowFrame.size.width > 0 && windowFrame.size.height > 0 && \ + !window.isMiniaturized && window.isVisible && \ + [window isMemberOfClass:[GLFWWindow class]] + ) [filteredWindows addObject:window]; + } + if (filteredWindows.count < 2) return; + NSWindow *keyWindow = [NSApp keyWindow]; + NSUInteger index = [filteredWindows indexOfObject:keyWindow]; + NSUInteger nextIndex = 0; + if (index != NSNotFound) { + if (backwards) { + nextIndex = (index == 0) ? [filteredWindows count] - 1 : index - 1; + } else nextIndex = (index + 1) % filteredWindows.count; + } + NSWindow *nextWindow = filteredWindows[nextIndex]; + [nextWindow makeKeyAndOrderFront:nil]; +} + + + ////////////////////////////////////////////////////////////////////////// ////// GLFW internal API ////// ////////////////////////////////////////////////////////////////////////// diff --git a/glfw/glfw.py b/glfw/glfw.py index e5d7ab3d921..2d1dd3f0901 100755 --- a/glfw/glfw.py +++ b/glfw/glfw.py @@ -313,6 +313,7 @@ def generate_wrappers(glfw_header: str) -> None: void* glfwGetX11Display(void) unsigned long glfwGetX11Window(GLFWwindow* window) void glfwSetPrimarySelectionString(GLFWwindow* window, const char* string) + void glfwCocoaCycleThroughOSWindows(bool backwards) void glfwCocoaSetWindowChrome(GLFWwindow* window, unsigned int color, bool use_system_color, unsigned int system_color,\ int background_blur, unsigned int hide_window_decorations, bool show_text_in_titlebar, int color_space, float background_opacity, bool resizable) const char* glfwGetPrimarySelectionString(GLFWwindow* window, void) diff --git a/kitty/cocoa_window.h b/kitty/cocoa_window.h index b9d916380d6..b7587f98911 100644 --- a/kitty/cocoa_window.h +++ b/kitty/cocoa_window.h @@ -52,7 +52,6 @@ void cocoa_system_beep(const char*); void cocoa_set_activation_policy(bool); bool cocoa_alt_option_key_pressed(unsigned long); void cocoa_toggle_secure_keyboard_entry(void); -void cocoa_cycle_through_os_windows(bool); void cocoa_hide(void); void cocoa_clear_global_shortcuts(void); void cocoa_hide_others(void); diff --git a/kitty/cocoa_window.m b/kitty/cocoa_window.m index 7f3d947c0be..a2d533098b8 100644 --- a/kitty/cocoa_window.m +++ b/kitty/cocoa_window.m @@ -927,30 +927,6 @@ - (void)quickAccessTerminal:(NSPasteboard *)pboard userData:(NSString *)userData [[NSUserDefaults standardUserDefaults] setBool:k.isDesired forKey:@"SecureKeyboardEntry"]; } -void -cocoa_cycle_through_os_windows(bool backwards) { - NSArray *allWindows = [NSApp windows]; - if (allWindows.count < 2) return; - NSMutableArray *filteredWindows = [NSMutableArray array]; - for (NSWindow *window in allWindows) { - NSRect windowFrame = [window frame]; - // Exclude zero size windows which are likely zombie windows from the Tahoe bug - if (windowFrame.size.width > 0 && windowFrame.size.height > 0 && !window.isMiniaturized && window.isVisible) [filteredWindows addObject:window]; - } - if (filteredWindows.count < 2) return; - NSWindow *keyWindow = [NSApp keyWindow]; - NSUInteger index = [filteredWindows indexOfObject:keyWindow]; - NSUInteger nextIndex = 0; - if (index != NSNotFound) { - if (backwards) { - nextIndex = (index == 0) ? [filteredWindows count] - 1 : index - 1; - } else nextIndex = (index + 1) % filteredWindows.count; - } - NSWindow *nextWindow = filteredWindows[nextIndex]; - [nextWindow makeKeyAndOrderFront:nil]; -} - - void cocoa_hide(void) { [[NSApplication sharedApplication] performSelectorOnMainThread:@selector(hide:) withObject:nil waitUntilDone:NO]; diff --git a/kitty/glfw-wrapper.c b/kitty/glfw-wrapper.c index fdc2f427d3b..8e257fcc093 100644 --- a/kitty/glfw-wrapper.c +++ b/kitty/glfw-wrapper.c @@ -473,6 +473,9 @@ load_glfw(const char* path) { *(void **) (&glfwSetPrimarySelectionString_impl) = dlsym(handle, "glfwSetPrimarySelectionString"); if (glfwSetPrimarySelectionString_impl == NULL) dlerror(); // clear error indicator + *(void **) (&glfwCocoaCycleThroughOSWindows_impl) = dlsym(handle, "glfwCocoaCycleThroughOSWindows"); + if (glfwCocoaCycleThroughOSWindows_impl == NULL) dlerror(); // clear error indicator + *(void **) (&glfwCocoaSetWindowChrome_impl) = dlsym(handle, "glfwCocoaSetWindowChrome"); if (glfwCocoaSetWindowChrome_impl == NULL) dlerror(); // clear error indicator diff --git a/kitty/glfw-wrapper.h b/kitty/glfw-wrapper.h index f1553d42e52..74850dbf29d 100644 --- a/kitty/glfw-wrapper.h +++ b/kitty/glfw-wrapper.h @@ -2330,6 +2330,10 @@ typedef void (*glfwSetPrimarySelectionString_func)(GLFWwindow*, const char*); GFW_EXTERN glfwSetPrimarySelectionString_func glfwSetPrimarySelectionString_impl; #define glfwSetPrimarySelectionString glfwSetPrimarySelectionString_impl +typedef void (*glfwCocoaCycleThroughOSWindows_func)(bool); +GFW_EXTERN glfwCocoaCycleThroughOSWindows_func glfwCocoaCycleThroughOSWindows_impl; +#define glfwCocoaCycleThroughOSWindows glfwCocoaCycleThroughOSWindows_impl + typedef void (*glfwCocoaSetWindowChrome_func)(GLFWwindow*, unsigned int, bool, unsigned int, int, unsigned int, bool, int, float, bool); GFW_EXTERN glfwCocoaSetWindowChrome_func glfwCocoaSetWindowChrome_impl; #define glfwCocoaSetWindowChrome glfwCocoaSetWindowChrome_impl diff --git a/kitty/glfw.c b/kitty/glfw.c index b641633d9bd..54556a95bff 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -1924,7 +1924,7 @@ toggle_secure_input(PYNOARG) { static PyObject* macos_cycle_through_os_windows(PyObject *self UNUSED, PyObject *backwards) { #ifdef __APPLE__ - cocoa_cycle_through_os_windows(PyObject_IsTrue(backwards)); + glfwCocoaCycleThroughOSWindows(PyObject_IsTrue(backwards)); #else (void)backwards; #endif From 98e13787a976e822a65f0a161f61b4aac0064135 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 12 Nov 2025 12:23:24 +0530 Subject: [PATCH 31/53] ... --- glfw/cocoa_window.m | 1 + 1 file changed, 1 insertion(+) diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index feba4fdafb2..a4c234d09fb 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -558,6 +558,7 @@ - (void)cleanup { - (BOOL)windowShouldClose:(id)sender { (void)sender; + if (window == nil) return YES; _glfwInputWindowCloseRequest(window); return NO; } From 35d95d7a434632bad1fe7c20814d4ba24395af7f Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 12 Nov 2025 14:01:37 +0530 Subject: [PATCH 32/53] Also remove shadow from zero sized window Hopefully that means no clickable region at all? --- glfw/cocoa_window.m | 1 + 1 file changed, 1 insertion(+) diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index a4c234d09fb..daba2e3cec9 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -1969,6 +1969,7 @@ void _glfwPlatformDestroyWindow(_GLFWwindow* window) // control. Sigh. NSRect frame = w.frame; frame.size.width = 0; frame.size.height = 0; [w setFrame:frame display:NO]; + [w setHasShadow:NO]; [w close]; // sends a release to NSWindow so we dont release it window->ns.object = nil; } From 8a1f4bda3b8c349853c56c6e44b75dd2a60687de Mon Sep 17 00:00:00 2001 From: Arsenii Kvachan Date: Wed, 12 Nov 2025 17:37:13 +0100 Subject: [PATCH 33/53] Allow browsing a directory with sessions - interpret a directory argument by listing only *.kitty-session and similar files - reuse the existing sorting logic for the directory chooser and document the workflow --- docs/sessions.rst | 7 +++++ kitty/session.py | 69 ++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 64 insertions(+), 12 deletions(-) diff --git a/docs/sessions.rst b/docs/sessions.rst index b173dee5a21..a1be3bc394f 100644 --- a/docs/sessions.rst +++ b/docs/sessions.rst @@ -57,12 +57,19 @@ easily swap between them, kitty has you covered. You can use the map f7>/ goto_session # Same as above, but the sessions are listed alphabetically instead of by most recent map f7>/ goto_session --sort-by=alphabetical + # Browse session files inside a directory and pick one + map f7>p goto_session ~/.local/share/kitty/sessions # Go to the previously active session (larger negative numbers jump further back in history) map f7>- goto_session -1 In this manner you can define as many projects/sessions as you like and easily switch between them with a keypress. +When a directory path is supplied to :ac:`goto_session`, kitty scans it for +files ending in ``.kitty-session``, ``.kitty_session`` or ``.session`` and +presents an interactive list. The ``--sort-by`` option controls the ordering of that list just like it does +for globally known sessions. + You can also close sessions using the :ac:`close_session` action, which closes all windows in the session with a single keypress. diff --git a/kitty/session.py b/kitty/session.py index 4922f352cd6..031502411aa 100644 --- a/kitty/session.py +++ b/kitty/session.py @@ -196,15 +196,30 @@ def set_cwd(self, val: str, session_base_dir: str) -> None: self.tabs[-1].cwd = session_base_dir +SESSION_FILE_EXTENSIONS = {'session', 'kitty-session', 'kitty_session'} + + +def has_session_extension(path: str) -> bool: + name = os.path.basename(path) + return name.rpartition('.')[2] in SESSION_FILE_EXTENSIONS + + def session_arg_to_name(session_arg: str) -> str: if session_arg in ('-', '/dev/stdin', 'none'): session_arg = '' session_name = os.path.basename(session_arg) - if session_name.rpartition('.')[2] in ('session', 'kitty-session', 'kitty_session'): + if has_session_extension(session_name): session_name = session_name.rpartition('.')[0] return session_name +def resolve_session_arg_path(path: str) -> str: + path = os.path.expanduser(path) + if not os.path.isabs(path): + path = os.path.join(config_dir, path) + return os.path.abspath(path) + + def parse_session( raw: str, opts: Options, environ: Mapping[str, str] | None = None, session_arg: str = '', session_path: str = '' ) -> Generator[Session, None, None]: @@ -424,10 +439,7 @@ def switch_to_session(boss: BossType, session_name: str) -> bool: def resolve_session_path_and_name(path: str) -> tuple[str, str]: - path = os.path.expanduser(path) - if not os.path.isabs(path): - path = os.path.join(config_dir, path) - path = os.path.abspath(path) + path = resolve_session_arg_path(path) return path, session_arg_to_name(path) @@ -503,8 +515,11 @@ def do_close(confirmed: bool) -> None: do_close(True) -def choose_session(boss: BossType, opts: GotoSessionOptions) -> None: - all_known_sessions = get_all_known_sessions() +def choose_session_from_map( + boss: BossType, opts: GotoSessionOptions, session_map: Mapping[str, str], title: str +) -> bool: + if not session_map: + return False hmap = {n: len(goto_session_history)-i for i, n in enumerate(goto_session_history)} if opts.sort_by == 'alphabetical': def skey(name: str) -> tuple[int, str]: @@ -512,13 +527,39 @@ def skey(name: str) -> tuple[int, str]: else: def skey(name: str) -> tuple[int, str]: return hmap.get(name, len(goto_session_history)), name.lower() - names = sorted(all_known_sessions, key=skey) + names = sorted(session_map, key=skey) def chosen(name: str | None) -> None: if name: - goto_session(boss, (all_known_sessions[name],)) - boss.choose_entry( - _('Select a session to activate'), ((name, name) for name in names), chosen) + goto_session(boss, (session_map[name],)) + boss.choose_entry(title, ((name, name) for name in names), chosen) + return True + + +def choose_session(boss: BossType, opts: GotoSessionOptions) -> None: + all_known_sessions = get_all_known_sessions() + choose_session_from_map(boss, opts, all_known_sessions, _('Select a session to activate')) + + +def choose_session_in_directory(boss: BossType, opts: GotoSessionOptions, directory_path: str) -> None: + try: + with os.scandir(directory_path) as entries: + session_map = { + session_arg_to_name(entry.path): entry.path + for entry in entries + if entry.is_file() and has_session_extension(entry.name) + } + except OSError as e: + boss.show_error( + _('Failed to list sessions'), + _('Could not list session files in {0} with error: {1}').format(directory_path, e)) + return + session_map = {name: path for name, path in session_map.items() if name} + if not choose_session_from_map( + boss, opts, session_map, _('Select a session to activate from {0}').format(directory_path) + ): + boss.show_error( + _('No session files found'), _('No session files were found inside {0}').format(directory_path)) def parse_goto_session_cmdline(args: list[str]) -> tuple[GotoSessionOptions, list[str]]: @@ -571,7 +612,11 @@ def goto_session(boss: BossType, cmdline: Sequence[str]) -> None: idx = 0 if idx < 0: return goto_previous_session(boss, idx) - path, session_name = resolve_session_path_and_name(path) + resolved_path = resolve_session_arg_path(path) + if os.path.isdir(resolved_path): + choose_session_in_directory(boss, opts, resolved_path) + return + path, session_name = resolve_session_path_and_name(resolved_path) if not session_name: boss.show_error(_('Invalid session'), _('{} is not a valid path for a session').format(path)) return From 2797b1f926e98848937dacf56faa382c115aa893 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 13 Nov 2025 08:34:21 +0530 Subject: [PATCH 34/53] Update changelog --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 69638b9535e..799dbc3e55e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -158,6 +158,9 @@ Detailed list of changes OS Windows. Also add a cycle through OS Windows backwards action. (:iss:`9215`) +- :ac:`goto_session`: allow specifying a directory to select a session file + from the directory (:pull:`9219`) + 0.44.0 [2025-11-03] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 83f0d6bc1a9165acc4f938be23cf629a800781e0 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 13 Nov 2025 14:41:46 +0530 Subject: [PATCH 35/53] Have reloading config also reload the custom tab bar python modules --- docs/changelog.rst | 2 ++ kitty/boss.py | 2 ++ kitty/tab_bar.py | 5 +++++ 3 files changed, 9 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 799dbc3e55e..2669aed6bff 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -161,6 +161,8 @@ Detailed list of changes - :ac:`goto_session`: allow specifying a directory to select a session file from the directory (:pull:`9219`) +- Have reloading config also reload the custom tab bar python modules (:disc:`9221`) + 0.44.0 [2025-11-03] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/kitty/boss.py b/kitty/boss.py index b275946d6d6..5dbcee872f6 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -2938,6 +2938,8 @@ def load_config_file(self, *paths: str, apply_overrides: bool = True, overrides: from .guess_mime_type import clear_mime_cache clear_mime_cache() store_effective_config() + from .tab_bar import clear_caches + clear_caches() def safe_delete_temp_file(self, path: str) -> None: if is_path_in_temp_dir(path): diff --git a/kitty/tab_bar.py b/kitty/tab_bar.py index 07ced098b85..ea9531f9d31 100644 --- a/kitty/tab_bar.py +++ b/kitty/tab_bar.py @@ -523,6 +523,11 @@ def draw_tab( return draw_tab +def clear_caches() -> None: + load_custom_draw_tab.clear_cached() + load_custom_draw_tab_module.clear_cached() + + class CustomDrawTitleFunc: def __init__(self, data: dict[str, Any], implementation: Callable[[dict[str, Any]], str] | None = None): From 9bc29a7fa63250486c5f554e14be34ac300b93f8 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 13 Nov 2025 18:50:42 +0530 Subject: [PATCH 36/53] Dont write to ~/.gitconfig in the tests Just in case the tests are run without setting HOME --- tools/ignorefiles/gitignore.go | 6 +++++- tools/ignorefiles/gitignore_test.go | 27 +++++++++++++++------------ 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/tools/ignorefiles/gitignore.go b/tools/ignorefiles/gitignore.go index 5377429b983..6b88b0ecdb4 100644 --- a/tools/ignorefiles/gitignore.go +++ b/tools/ignorefiles/gitignore.go @@ -261,11 +261,15 @@ func CompileGitIgnoreLine(line string) (ans GitPattern, skipped_line bool) { } func get_global_gitconfig_excludesfile() (ans string) { + return _get_global_gitconfig_excludesfile(utils.Expanduser("~/.gitconfig")) +} + +func _get_global_gitconfig_excludesfile(home_gitconfig string) (ans string) { cfhome := os.Getenv("XDG_CONFIG_HOME") if cfhome == "" { cfhome = utils.Expanduser("~/.config") } - for _, candidate := range []string{"/etc/gitconfig", filepath.Join(cfhome, "git", "config"), utils.Expanduser("~/.gitconfig")} { + for _, candidate := range []string{"/etc/gitconfig", filepath.Join(cfhome, "git", "config"), home_gitconfig} { if data, err := os.ReadFile(candidate); err == nil { s := utils.NewLineScanner(utils.UnsafeBytesToString(data)) in_core := false diff --git a/tools/ignorefiles/gitignore_test.go b/tools/ignorefiles/gitignore_test.go index 0b4dc10650d..a8bf49e8d89 100644 --- a/tools/ignorefiles/gitignore_test.go +++ b/tools/ignorefiles/gitignore_test.go @@ -4,6 +4,7 @@ import ( "fmt" "io/fs" "os" + "path/filepath" "strings" "testing" @@ -143,19 +144,21 @@ bar `: { } } } - os.WriteFile(utils.Expanduser("~/.gitconfig"), []byte(` - [core] - something - [else] - ... - [core] - ... - [core] - excludesfile = one - [core] - excludesfile = ~/global-gitignore + tdir := t.TempDir() + gc := filepath.Join(tdir, ".gitconfig") + os.WriteFile(gc, []byte(` +[core] +something +[else] +... +[core] +... +[core] +excludesfile = one +[core] +excludesfile = ~/global-gitignore `), 0600) - if ef := get_global_gitconfig_excludesfile(); ef != utils.Expanduser("~/global-gitignore") { + if ef := _get_global_gitconfig_excludesfile(gc); ef != utils.Expanduser("~/global-gitignore") { t.Fatalf("global gitconfig excludes file incorrect: %s != %s", utils.Expanduser("~/global-gitignore"), ef) } } From d4633bf5f912a2f6acfd557544583fefb676ee36 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 14 Nov 2025 07:58:04 +0530 Subject: [PATCH 37/53] Graphics: Disallow PNGs of size greater than MAX_IMAGE_DIMENSION to match behavior with loading RGB data --- kitty/graphics.c | 6 +++--- kitty/png-reader.c | 8 ++++++-- kitty/png-reader.h | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/kitty/graphics.c b/kitty/graphics.c index c06cfe69527..ad5679448bc 100644 --- a/kitty/graphics.c +++ b/kitty/graphics.c @@ -23,6 +23,7 @@ #include "png-reader.h" PyTypeObject GraphicsManager_Type; +#define MAX_IMAGE_DIMENSION 10000u #define DEFAULT_STORAGE_LIMIT 320u * (1024u * 1024u) #define REPORT_ERROR(...) { log_error(__VA_ARGS__); } #define RAII_CoalescedFrameData(name, initializer) __attribute__((cleanup(cfd_free))) CoalescedFrameData name = initializer @@ -380,7 +381,7 @@ png_error_handler(png_read_data *d UNUSED, const char *code, const char *msg) { static bool inflate_png(LoadData *load_data, uint8_t *buf, size_t bufsz) { png_read_data d = {.err_handler=png_error_handler}; - inflate_png_inner(&d, buf, bufsz); + inflate_png_inner(&d, buf, bufsz, MAX_IMAGE_DIMENSION); if (d.ok) { free_load_data(load_data); load_data->buf = d.decompressed; @@ -415,7 +416,7 @@ print_png_read_error(png_read_data *d, const char *code, const char* msg) { bool png_from_data(void *png_data, size_t png_data_sz, const char *path_for_error_messages, uint8_t** data, unsigned int* width, unsigned int* height, size_t* sz) { png_read_data d = {.err_handler=print_png_read_error}; - inflate_png_inner(&d, png_data, png_data_sz); + inflate_png_inner(&d, png_data, png_data_sz, MAX_IMAGE_DIMENSION); if (!d.ok) { log_error("Failed to decode PNG image at: %s with error: %s", path_for_error_messages, d.error.used > 0 ? d.error.buf : ""); free(d.decompressed); free(d.row_pointers); free(d.error.buf); @@ -693,7 +694,6 @@ initialize_load_data(GraphicsManager *self, const GraphicsCommand *g, Image *img tt = g->transmission_type ? g->transmission_type : 'd'; \ fmt = g->format ? g->format : RGBA; \ } -#define MAX_IMAGE_DIMENSION 10000u static void upload_to_gpu(GraphicsManager *self, Image *img, const bool is_opaque, const bool is_4byte_aligned, const uint8_t *data) { diff --git a/kitty/png-reader.c b/kitty/png-reader.c index 740d5eab0a7..4fcd2c6d467 100644 --- a/kitty/png-reader.c +++ b/kitty/png-reader.c @@ -47,7 +47,7 @@ read_png_warn_handler(png_structp UNUSED png_ptr, png_const_charp msg) { #define ABRT(code, msg) { if(d->err_handler) d->err_handler(d, #code, msg); goto err; } void -inflate_png_inner(png_read_data *d, const uint8_t *buf, size_t bufsz) { +inflate_png_inner(png_read_data *d, const uint8_t *buf, size_t bufsz, int max_image_dimension) { struct fake_file f = {.buf = buf, .sz = bufsz}; png_structp png = NULL; png_infop info = NULL; @@ -64,6 +64,10 @@ inflate_png_inner(png_read_data *d, const uint8_t *buf, size_t bufsz) { png_byte color_type, bit_depth; d->width = png_get_image_width(png, info); d->height = png_get_image_height(png, info); + // libpng uses too much memory for overly large images + if (d->width > max_image_dimension || d->height > max_image_dimension) { + ABRT(ENOMEM, "PNG image is too large"); + } color_type = png_get_color_type(png, info); bit_depth = png_get_bit_depth(png, info); double image_gamma; @@ -208,7 +212,7 @@ load_png_data(PyObject *self UNUSED, PyObject *args) { const char *data; if (!PyArg_ParseTuple(args, "s#", &data, &sz)) return NULL; png_read_data d = {.err_handler=png_error_handler}; - inflate_png_inner(&d, (const uint8_t*)data, sz); + inflate_png_inner(&d, (const uint8_t*)data, sz, 10000); PyObject *ans = NULL; if (d.ok && !PyErr_Occurred()) { ans = Py_BuildValue("y#ii", d.decompressed, (int)d.sz, d.width, d.height); diff --git a/kitty/png-reader.h b/kitty/png-reader.h index 4d6175333e7..b0eeeb72d7a 100644 --- a/kitty/png-reader.h +++ b/kitty/png-reader.h @@ -27,5 +27,5 @@ typedef struct png_read_data { } error; } png_read_data; -void inflate_png_inner(png_read_data *d, const uint8_t *buf, size_t bufsz); +void inflate_png_inner(png_read_data *d, const uint8_t *buf, size_t bufsz, int max_image_dimension); const char* png_from_32bit_rgba(uint32_t *data, size_t width, size_t height, size_t *out_size, bool flip_vertically); From 1d19942811ab8ba17e5224250b384aa9211693c9 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 15 Nov 2025 12:23:56 +0530 Subject: [PATCH 38/53] Store more metadata about pixel data shape when serializing --- kittens/choose_files/graphics.go | 3 ++- tools/tui/graphics/collection.go | 6 +++-- tools/utils/images/loading.go | 42 ++++++++++++++++++++++---------- 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/kittens/choose_files/graphics.go b/kittens/choose_files/graphics.go index 69c5bbd26f9..e6b675a5e50 100644 --- a/kittens/choose_files/graphics.go +++ b/kittens/choose_files/graphics.go @@ -219,7 +219,8 @@ func (self *GraphicsHandler) transmit(lp *loop.Loop, img *images.ImageData, m *i gc.SetLeftEdge(uint64(frame.Left)).SetTopEdge(uint64(frame.Top)) } if cached_data == nil { - transmit_by_escape_code(lp, img.Frames[frame_num].Data(), gc) + _, _, _, data := img.Frames[frame_num].Data() + transmit_by_escape_code(lp, data, gc) } else { path := cached_data[IMAGE_DATA_PREFIX+strconv.Itoa(frame_num)] transmit_by_file(lp, utils.UnsafeStringToBytes(path), gc) diff --git a/tools/tui/graphics/collection.go b/tools/tui/graphics/collection.go index f1542ea5bd7..216017d8c91 100644 --- a/tools/tui/graphics/collection.go +++ b/tools/tui/graphics/collection.go @@ -336,7 +336,8 @@ func transmit_by_escape_code(lp *loop.Loop, image_id uint32, temp_file_map map[u atomic := lp.IsAtomicUpdateActive() lp.EndAtomicUpdate() gc.SetTransmission(GRT_transmission_direct) - _ = gc.WriteWithPayloadToLoop(lp, frame.Data()) + _, _, _, data := frame.Data() + _ = gc.WriteWithPayloadToLoop(lp, data) if atomic { lp.StartAtomicUpdate() } @@ -362,7 +363,8 @@ func transmit_by_file(lp *loop.Loop, image_id uint32, temp_file_map map[uint32]* } defer f.Close() temp_file_map[image_id] = &temp_resource{path: f.Name()} - _, err = f.Write(frame.Data()) + _, _, _, data := frame.Data() + _, err = f.Write(data) if err != nil { transmit_by_escape_code(lp, image_id, temp_file_map, frame, gc) return diff --git a/tools/utils/images/loading.go b/tools/utils/images/loading.go index 0a8f18cddd7..ba8b45014fe 100644 --- a/tools/utils/images/loading.go +++ b/tools/utils/images/loading.go @@ -52,11 +52,11 @@ type SerializableImageFrame struct { Delay_ms int // negative for gapless frame, zero ignored, positive is number of ms Replace bool // do a replace rather than an alpha blend Is_opaque bool - Size int -} -func (s SerializableImageFrame) NeededSize() int { - return utils.IfElse(s.Is_opaque, 3, 4) * s.Width * s.Height + Size int // size in bytes of the serialized data + Number_of_channels int + Bits_per_channel int + Has_alpha_channel bool } func (s *ImageFrame) Serialize() SerializableImageFrame { @@ -68,7 +68,7 @@ func (s *ImageFrame) Serialize() SerializableImageFrame { } func (self *ImageFrame) DataAsSHM(pattern string) (ans shm.MMap, err error) { - d := self.Data() + _, _, _, d := self.Data() if ans, err = shm.CreateTemp(pattern, uint64(len(d))); err != nil { return nil, err } @@ -76,8 +76,10 @@ func (self *ImageFrame) DataAsSHM(pattern string) (ans shm.MMap, err error) { return } -func (self *ImageFrame) Data() (ans []byte) { - _, ans = imaging.AsRGBData8(self.Img) +func (self *ImageFrame) Data() (num_channels, bits_per_channel int, has_alpha_channel bool, ans []byte) { + num_channels, ans = imaging.AsRGBData8(self.Img) + bits_per_channel = 8 + has_alpha_channel = num_channels == 3 return } @@ -85,16 +87,26 @@ func ImageFrameFromSerialized(s SerializableImageFrame, data []byte) (aa *ImageF ans := ImageFrame{ Width: s.Width, Height: s.Height, Left: s.Left, Top: s.Top, Number: s.Number, Compose_onto: s.Compose_onto, Delay_ms: int32(s.Delay_ms), - Is_opaque: s.Is_opaque, Replace: s.Replace, + Is_opaque: s.Is_opaque || !s.Has_alpha_channel, Replace: s.Replace, + } + bpc := s.Bits_per_channel + if bpc == 0 { + bpc = 8 } - bytes_per_pixel := utils.IfElse(s.Is_opaque, 3, 4) + if bpc != 8 { + return nil, fmt.Errorf("serialized image data has unsupported number of bits per channel: %d", bpc) + } + bytes_per_pixel := bpc * s.Number_of_channels / 8 if expected := bytes_per_pixel * s.Width * s.Height; len(data) != expected { return nil, fmt.Errorf("serialized image data has size: %d != %d", len(data), expected) } - if s.Is_opaque { + switch s.Number_of_channels { + case 3, 0: ans.Img, err = nrgb.NewNRGBWithContiguousRGBPixels(data, s.Left, s.Top, s.Width, s.Height) - } else { + case 4: ans.Img, err = NewNRGBAWithContiguousRGBAPixels(data, s.Left, s.Top, s.Width, s.Height) + default: + return nil, fmt.Errorf("serialized image data has unsupported number of channels: %d", s.Number_of_channels) } return &ans, err } @@ -131,8 +143,12 @@ func (self *ImageData) Serialize() (SerializableImageMetadata, [][]byte) { m := self.SerializeOnlyMetadata() data := make([][]byte, len(self.Frames)) for i, f := range self.Frames { - data[i] = f.Data() - m.Frames[i].Size = len(data[i]) + df := &m.Frames[i] + df.Number_of_channels, df.Bits_per_channel, df.Has_alpha_channel, data[i] = f.Data() + df.Size = len(data[i]) + if !df.Has_alpha_channel { + df.Is_opaque = true + } } return m, data } From c3d01700592452e84913e791c4c49339e5bb617a Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 15 Nov 2025 12:24:35 +0530 Subject: [PATCH 39/53] Bump version of imaging --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 2c71b2a2fbf..671d018c12b 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( 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 - github.com/kovidgoyal/imaging v1.8.9 + github.com/kovidgoyal/imaging v1.8.11 github.com/seancfoley/ipaddress-go v1.7.1 github.com/shirou/gopsutil/v4 v4.25.10 github.com/zeebo/xxh3 v1.0.2 diff --git a/go.sum b/go.sum index a2f2ca8e283..0bfb252f4ac 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,8 @@ github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811ui github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw= github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo= github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds= -github.com/kovidgoyal/imaging v1.8.9 h1:6YS8DCZfq/HHiOUMw2rM2GWkOe1Dc2wK5FlN7/ki4yg= -github.com/kovidgoyal/imaging v1.8.9/go.mod h1:ZcaLxFLdn9I+a9hyI847Q8CYtC5J0bAYbG3+WG8DM9Y= +github.com/kovidgoyal/imaging v1.8.11 h1:i2NzTs8Ge0LKvilKiTw0U1kTcrBL8bo+zUYZSjSP+6M= +github.com/kovidgoyal/imaging v1.8.11/go.mod h1:rjyfCTmIGGDjbMWGJ8QN/D/AWjHXD5flIzhjJpOcOVw= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a h1:N9zuLhTvBSRt0gWSiJswwQ2HqDmtX/ZCDJURnKUt1Ik= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= From fb6332d8e2a06698f9b110371f2d2d64bccb9fe7 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 15 Nov 2025 12:41:14 +0530 Subject: [PATCH 40/53] Resize frames in parallel --- tools/utils/images/loading.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tools/utils/images/loading.go b/tools/utils/images/loading.go index ba8b45014fe..e12374b170b 100644 --- a/tools/utils/images/loading.go +++ b/tools/utils/images/loading.go @@ -10,9 +10,9 @@ import ( "os" "strings" + "github.com/kovidgoyal/go-parallel" "github.com/kovidgoyal/go-shm" "github.com/kovidgoyal/imaging/nrgb" - "github.com/kovidgoyal/kitty/tools/utils" "github.com/kovidgoyal/imaging" ) @@ -188,7 +188,14 @@ func (self *ImageFrame) Resize(x_frac, y_frac float64) *ImageFrame { func (self *ImageData) Resize(x_frac, y_frac float64) *ImageData { ans := *self - ans.Frames = utils.Map(func(f *ImageFrame) *ImageFrame { return f.Resize(x_frac, y_frac) }, self.Frames) + ans.Frames = make([]*ImageFrame, len(self.Frames)) + if err := parallel.Run_in_parallel_over_range(0, func(start, limit int) { + for i := start; i < limit; i++ { + ans.Frames[i] = self.Frames[i].Resize(x_frac, y_frac) + } + }, 0, len(ans.Frames)); err != nil { + panic(err) + } if len(ans.Frames) > 0 { ans.Width, ans.Height = ans.Frames[0].Width, ans.Frames[0].Height } From 25cf8622bcce80763e1b9034a8ef37c6b8dd1b32 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 15 Nov 2025 13:06:17 +0530 Subject: [PATCH 41/53] Avoid double is_opaque scan --- go.mod | 2 +- go.sum | 4 ++-- kittens/icat/process_images.go | 8 +++++++- tools/utils/images/loading.go | 8 ++++---- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 671d018c12b..469886f8589 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( 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 - github.com/kovidgoyal/imaging v1.8.11 + github.com/kovidgoyal/imaging v1.8.12 github.com/seancfoley/ipaddress-go v1.7.1 github.com/shirou/gopsutil/v4 v4.25.10 github.com/zeebo/xxh3 v1.0.2 diff --git a/go.sum b/go.sum index 0bfb252f4ac..285bc5dd0ad 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,8 @@ github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811ui github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw= github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo= github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds= -github.com/kovidgoyal/imaging v1.8.11 h1:i2NzTs8Ge0LKvilKiTw0U1kTcrBL8bo+zUYZSjSP+6M= -github.com/kovidgoyal/imaging v1.8.11/go.mod h1:rjyfCTmIGGDjbMWGJ8QN/D/AWjHXD5flIzhjJpOcOVw= +github.com/kovidgoyal/imaging v1.8.12 h1:S1NIlxNdI86A3MGJNajqp2Pu8/WSVrL8L1B5HbSQsB0= +github.com/kovidgoyal/imaging v1.8.12/go.mod h1:rjyfCTmIGGDjbMWGJ8QN/D/AWjHXD5flIzhjJpOcOVw= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a h1:N9zuLhTvBSRt0gWSiJswwQ2HqDmtX/ZCDJURnKUt1Ik= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/kittens/icat/process_images.go b/kittens/icat/process_images.go index f89883e49b3..3a3f0dc5b9e 100644 --- a/kittens/icat/process_images.go +++ b/kittens/icat/process_images.go @@ -216,7 +216,13 @@ func scale_image(imgd *image_data) bool { func add_frame(imgd *image_data, img image.Image, left, top int) *image_frame { const shm_template = "kitty-icat-*" - num_channels, pix := imaging.AsRGBData8(img) + num_channels := 4 + var pix []byte + if imaging.IsOpaque(img) { + num_channels, pix = 3, imaging.AsRGBData8(img) + } else { + pix = imaging.AsRGBAData8(img) + } b := img.Bounds() f := image_frame{width: b.Dx(), height: b.Dy(), number: len(imgd.frames) + 1, left: left, top: top} f.transmission_format = utils.IfElse(num_channels == 3, graphics.GRT_format_rgb, graphics.GRT_format_rgba) diff --git a/tools/utils/images/loading.go b/tools/utils/images/loading.go index e12374b170b..d00c7b50a29 100644 --- a/tools/utils/images/loading.go +++ b/tools/utils/images/loading.go @@ -77,10 +77,10 @@ func (self *ImageFrame) DataAsSHM(pattern string) (ans shm.MMap, err error) { } func (self *ImageFrame) Data() (num_channels, bits_per_channel int, has_alpha_channel bool, ans []byte) { - num_channels, ans = imaging.AsRGBData8(self.Img) - bits_per_channel = 8 - has_alpha_channel = num_channels == 3 - return + if self.Is_opaque { + return 3, 8, false, imaging.AsRGBData8(self.Img) + } + return 4, 8, true, imaging.AsRGBAData8(self.Img) } func ImageFrameFromSerialized(s SerializableImageFrame, data []byte) (aa *ImageFrame, err error) { From 68805850a5c5a24c318eb3c5271f35121a882260 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 15 Nov 2025 14:56:21 +0530 Subject: [PATCH 42/53] Preserve opacity when resizing on image load --- go.mod | 2 +- go.sum | 4 ++-- tools/utils/images/loading.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 469886f8589..d9822873f3c 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( 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 - github.com/kovidgoyal/imaging v1.8.12 + github.com/kovidgoyal/imaging v1.8.13 github.com/seancfoley/ipaddress-go v1.7.1 github.com/shirou/gopsutil/v4 v4.25.10 github.com/zeebo/xxh3 v1.0.2 diff --git a/go.sum b/go.sum index 285bc5dd0ad..56b7a3cb02f 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,8 @@ github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811ui github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw= github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo= github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds= -github.com/kovidgoyal/imaging v1.8.12 h1:S1NIlxNdI86A3MGJNajqp2Pu8/WSVrL8L1B5HbSQsB0= -github.com/kovidgoyal/imaging v1.8.12/go.mod h1:rjyfCTmIGGDjbMWGJ8QN/D/AWjHXD5flIzhjJpOcOVw= +github.com/kovidgoyal/imaging v1.8.13 h1:0UD8ZHj77MBavUUcZo4uwoEJzrirJ0ToAyg8c2RSHjw= +github.com/kovidgoyal/imaging v1.8.13/go.mod h1:rjyfCTmIGGDjbMWGJ8QN/D/AWjHXD5flIzhjJpOcOVw= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a h1:N9zuLhTvBSRt0gWSiJswwQ2HqDmtX/ZCDJURnKUt1Ik= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/tools/utils/images/loading.go b/tools/utils/images/loading.go index d00c7b50a29..6cadee576e9 100644 --- a/tools/utils/images/loading.go +++ b/tools/utils/images/loading.go @@ -179,7 +179,7 @@ func (self *ImageFrame) Resize(x_frac, y_frac float64) *ImageFrame { ans := *self ans.Width = int(x_frac * float64(width)) ans.Height = int(y_frac * float64(height)) - ans.Img = imaging.Resize(self.Img, ans.Width, ans.Height, imaging.Lanczos) + ans.Img = imaging.ResizeWithOpacity(self.Img, ans.Width, ans.Height, imaging.Lanczos, self.Is_opaque) ans.Left = int(x_frac * float64(left)) ans.Top = int(y_frac * float64(top)) return &ans From e0118e45533fc24a891e09b920d3ddc6ccaa141c Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 16 Nov 2025 09:24:24 +0530 Subject: [PATCH 43/53] Bump version of imaging --- go.mod | 3 +-- go.sum | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index d9822873f3c..90c545d5dab 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( 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 - github.com/kovidgoyal/imaging v1.8.13 + github.com/kovidgoyal/imaging v1.8.15 github.com/seancfoley/ipaddress-go v1.7.1 github.com/shirou/gopsutil/v4 v4.25.10 github.com/zeebo/xxh3 v1.0.2 @@ -34,7 +34,6 @@ require ( require ( github.com/ebitengine/purego v0.9.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/kettek/apng v0.0.0-20250827064933-2bb5f5fcf253 // indirect github.com/klauspost/cpuid/v2 v2.2.5 // indirect github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect diff --git a/go.sum b/go.sum index 56b7a3cb02f..2b22db75911 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,6 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ 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/kettek/apng v0.0.0-20250827064933-2bb5f5fcf253 h1:ar6YqPcuumkcWgAJHkmda6Q35V3OnpxeTej4iU/QFLA= -github.com/kettek/apng v0.0.0-20250827064933-2bb5f5fcf253/go.mod h1:x78/VRQYKuCftMWS0uK5e+F5RJ7S4gSlESRWI0Prl6Q= github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1 h1:rMY/hWfcVzBm6BLX6YLA+gLJEpuXBed/VP6YEkXt8R4= @@ -34,8 +32,8 @@ github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811ui github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw= github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo= github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds= -github.com/kovidgoyal/imaging v1.8.13 h1:0UD8ZHj77MBavUUcZo4uwoEJzrirJ0ToAyg8c2RSHjw= -github.com/kovidgoyal/imaging v1.8.13/go.mod h1:rjyfCTmIGGDjbMWGJ8QN/D/AWjHXD5flIzhjJpOcOVw= +github.com/kovidgoyal/imaging v1.8.15 h1:FwKRGvmbQ04OFYF5WIalcvUontcnj1chBeHmR5I7UYg= +github.com/kovidgoyal/imaging v1.8.15/go.mod h1:aXZHGm/gKcaXpxn1NnQiVtuz3Z2BCnXWyjF0ZGrmJ1g= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a h1:N9zuLhTvBSRt0gWSiJswwQ2HqDmtX/ZCDJURnKUt1Ik= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= From e49d9406212af2472703f3519240258bef79ffc1 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 16 Nov 2025 21:01:55 +0530 Subject: [PATCH 44/53] kitten @ ls: Also output the neighbors for every window --- docs/changelog.rst | 2 ++ kitty/layout/base.py | 13 +++---------- kitty/layout/grid.py | 20 ++++++++++++-------- kitty/layout/splits.py | 12 ++++++------ kitty/layout/stack.py | 3 ++- kitty/layout/tall.py | 5 ++--- kitty/layout/vertical.py | 17 ++++++++++++----- kitty/tabs.py | 5 ++++- kitty/types.py | 7 +++++++ kitty/window.py | 11 +++++++++-- kitty_tests/layout.py | 8 ++++---- 11 files changed, 63 insertions(+), 40 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2669aed6bff..2235f28f4e1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -163,6 +163,8 @@ Detailed list of changes - Have reloading config also reload the custom tab bar python modules (:disc:`9221`) +- kitten @ ls: Also output the neighbors for every window (:disc:`9225`) + 0.44.0 [2025-11-03] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/kitty/layout/base.py b/kitty/layout/base.py index 8e55e893c8a..273e1b9bc68 100644 --- a/kitty/layout/base.py +++ b/kitty/layout/base.py @@ -9,8 +9,8 @@ from kitty.borders import BorderColor from kitty.fast_data_types import Region, get_options, set_active_window, viewport_for_window from kitty.options.types import Options -from kitty.types import Edges, WindowGeometry, WindowMapper -from kitty.typing_compat import TypedDict, WindowType +from kitty.types import Edges, NeighborsMap, WindowGeometry, WindowMapper +from kitty.typing_compat import WindowType from kitty.window_list import WindowGroup, WindowList @@ -41,13 +41,6 @@ class LayoutData(NamedTuple): ListOfWindows = list[WindowType] -class NeighborsMap(TypedDict): - left: list[int] - top: list[int] - right: list[int] - bottom: list[int] - - class LayoutGlobalData: draw_minimal_borders: bool = True draw_active_borders: bool = True @@ -437,7 +430,7 @@ def do_layout(self, windows: WindowList) -> None: raise NotImplementedError() def neighbors_for_window(self, window: WindowType, windows: WindowList) -> NeighborsMap: - return {'left': [], 'right': [], 'top': [], 'bottom': []} + return {} def compute_needs_borders_map(self, all_windows: WindowList) -> dict[int, bool]: return all_windows.compute_needs_borders_map(lgd.draw_active_borders) diff --git a/kitty/layout/grid.py b/kitty/layout/grid.py index f62ea5206e7..75abd6e36dd 100644 --- a/kitty/layout/grid.py +++ b/kitty/layout/grid.py @@ -8,11 +8,11 @@ from typing import Any from kitty.borders import BorderColor -from kitty.types import Edges, WindowMapper +from kitty.types import Edges, NeighborsMap, WindowMapper from kitty.typing_compat import WindowType from kitty.window_list import WindowGroup, WindowList -from .base import BorderLine, Layout, LayoutData, LayoutDimension, ListOfWindows, NeighborsMap, layout_dimension, lgd +from .base import BorderLine, Layout, LayoutData, LayoutDimension, ListOfWindows, layout_dimension, lgd from .tall import neighbors_for_tall_window @@ -302,12 +302,16 @@ def side(row: int, col: int, delta: int) -> list[int]: xs.extend(neighbors(neighbor_row, neighbor_col)) return xs - return { - 'top': neighbors(row-1, col) if row else [], - 'bottom': neighbors(row + 1, col), - 'left': side(row, col, -1) if col else [], - 'right': side(row, col, 1) if col < ncols - 1 else [], - } + ans: NeighborsMap = {} + if row: + ans['top'] = neighbors(row-1, col) + if bottom := neighbors(row + 1, col): + ans['bottom'] = bottom + if col and (left := side(row, col, -1)): + ans['left'] = left + if col < ncols - 1: + ans['right'] = side(row, col, 1) + return ans def layout_state(self) -> dict[str, Any]: return { diff --git a/kitty/layout/splits.py b/kitty/layout/splits.py index bb83e3deda9..c13f069a7c4 100644 --- a/kitty/layout/splits.py +++ b/kitty/layout/splits.py @@ -5,11 +5,11 @@ from typing import Any, NamedTuple, Optional, TypedDict, Union from kitty.borders import BorderColor -from kitty.types import Edges, WindowGeometry, WindowMapper +from kitty.types import Edges, NeighborsMap, WindowGeometry, WindowMapper from kitty.typing_compat import EdgeLiteral, WindowType from kitty.window_list import WindowGroup, WindowList -from .base import BorderLine, Layout, LayoutOpts, NeighborsMap, blank_rects_for_window, lgd, window_geometry_from_layouts +from .base import BorderLine, Layout, LayoutOpts, blank_rects_for_window, lgd, window_geometry_from_layouts class Extent(NamedTuple): @@ -405,14 +405,14 @@ def quadrant(is_horizontal: bool, is_first: bool) -> tuple[EdgeLiteral, EdgeLite geometries = {group.id: group.geometry for group in all_windows.groups if group.geometry} def extend(other: Union[int, 'Pair', None], edge: EdgeLiteral, which: EdgeLiteral) -> None: - if not ans[which] and other: + if not ans.get(which) and other: if isinstance(other, Pair): neighbors = ( w for w in other.edge_windows(edge) if is_neighbouring_geometry(geometries[w], geometries[window_id], which)) - ans[which].extend(neighbors) + ans.setdefault(which, []).extend(neighbors) else: - ans[which].append(other) + ans.setdefault(which, []).append(other) def is_neighbouring_geometry(a: WindowGeometry, b: WindowGeometry, direction: str) -> bool: def edges(g: WindowGeometry) -> tuple[int, int]: @@ -610,7 +610,7 @@ def neighbors_for_window(self, window: WindowType, all_windows: WindowList) -> N wg = all_windows.group_for_window(window) assert wg is not None pair = self.pairs_root.pair_for_window(wg.id) - ans: NeighborsMap = {'left': [], 'right': [], 'top': [], 'bottom': []} + ans: NeighborsMap = {} if pair is not None: pair.neighbors_for_window(wg.id, ans, self, all_windows) return ans diff --git a/kitty/layout/stack.py b/kitty/layout/stack.py index 6c65a0dd130..c855b2c64a8 100644 --- a/kitty/layout/stack.py +++ b/kitty/layout/stack.py @@ -1,10 +1,11 @@ #!/usr/bin/env python # License: GPLv3 Copyright: 2020, Kovid Goyal +from kitty.types import NeighborsMap from kitty.typing_compat import WindowType from kitty.window_list import WindowList -from .base import Layout, NeighborsMap +from .base import Layout class Stack(Layout): diff --git a/kitty/layout/tall.py b/kitty/layout/tall.py index 96292523911..0d2737971a9 100644 --- a/kitty/layout/tall.py +++ b/kitty/layout/tall.py @@ -7,7 +7,7 @@ from kitty.borders import BorderColor from kitty.conf.utils import to_bool -from kitty.types import Edges, WindowMapper +from kitty.types import Edges, NeighborsMap, WindowMapper from kitty.typing_compat import EdgeLiteral, WindowType from kitty.window_list import WindowGroup, WindowList @@ -17,7 +17,6 @@ LayoutData, LayoutDimension, LayoutOpts, - NeighborsMap, lgd, normalize_biases, safe_increment_bias, @@ -38,7 +37,7 @@ def neighbors_for_tall_window( idx = groups.index(wg) prev = None if idx == 0 else groups[idx-1] nxt = None if idx == len(groups) - 1 else groups[idx+1] - ans: NeighborsMap = {'left': [], 'right': [], 'top': [], 'bottom': []} + ans: NeighborsMap = {} main_before: EdgeLiteral = 'left' if main_is_horizontal else 'top' main_after: EdgeLiteral = 'right' if main_is_horizontal else 'bottom' cross_before: EdgeLiteral = 'top' if main_is_horizontal else 'left' diff --git a/kitty/layout/vertical.py b/kitty/layout/vertical.py index 9e9e4b23ac7..000963acc4d 100644 --- a/kitty/layout/vertical.py +++ b/kitty/layout/vertical.py @@ -5,11 +5,11 @@ from typing import Any from kitty.borders import BorderColor -from kitty.types import Edges, WindowMapper -from kitty.typing_compat import WindowType +from kitty.types import Edges, NeighborsMap, WindowMapper +from kitty.typing_compat import EdgeLiteral, WindowType from kitty.window_list import WindowGroup, WindowList -from .base import BorderLine, Layout, LayoutData, LayoutDimension, NeighborsMap, lgd +from .base import BorderLine, Layout, LayoutData, LayoutDimension, lgd def borders( @@ -133,9 +133,16 @@ def neighbors_for_window(self, window: WindowType, all_windows: WindowList) -> N after = [groups[(idx + 1) % lg].id] else: before, after = [], [] + ans: NeighborsMap = {} + akey: EdgeLiteral = 'top' + bkey: EdgeLiteral = 'bottom' if self.main_is_horizontal: - return {'left': before, 'right': after, 'top': [], 'bottom': []} - return {'top': before, 'bottom': after, 'left': [], 'right': []} + akey, bkey = 'left', 'right' + if before: + ans[bkey] = before + if after: + ans[akey] = after + return ans def layout_state(self) -> dict[str, Any]: return {'biased_map': self.biased_map} diff --git a/kitty/tabs.py b/kitty/tabs.py index 70b65671e37..4c22ee15aae 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -943,12 +943,15 @@ def move_window_backward(self) -> None: def list_windows(self, self_window: Window | None = None, window_filter: Callable[[Window], bool] | None = None) -> Generator[WindowDict, None, None]: active_window = self.active_window + cl = self.current_layout for w in self: if window_filter is None or window_filter(w): yield w.as_dict( is_active=w is active_window, is_focused=w.os_window_id == current_focused_os_window_id() and w is active_window, - is_self=w is self_window) + is_self=w is self_window, + neighbors_map=cl.neighbors_for_window(w, self.windows) + ) def list_groups(self) -> list[dict[str, Any]]: return [g.as_simple_dict() for g in self.windows.groups] diff --git a/kitty/types.py b/kitty/types.py index a63db5e9a71..02906522a18 100644 --- a/kitty/types.py +++ b/kitty/types.py @@ -234,3 +234,10 @@ def w(f: _T) -> _T: WindowMapper = Callable[[int], int | None] DecoratedFunc = TypeVar('DecoratedFunc', bound=Callable[..., Any]) + + +class NeighborsMap(TypedDict, total=False): + left: list[int] + top: list[int] + right: list[int] + bottom: list[int] diff --git a/kitty/window.py b/kitty/window.py index 63affb6ed32..2fa9a397187 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -96,7 +96,7 @@ from .progress import Progress from .rgb import to_color from .terminfo import get_capabilities -from .types import MouseEvent, OverlayType, WindowGeometry, ac, run_once +from .types import MouseEvent, NeighborsMap, OverlayType, WindowGeometry, ac, run_once from .typing_compat import BossType, ChildType, EdgeLiteral, TabType, TypedDict from .utils import ( color_as_int, @@ -250,6 +250,7 @@ class WindowDict(TypedDict): at_prompt: bool created_at: int in_alternate_screen: bool + neighbors: NeighborsMap class PipeData(TypedDict): @@ -1938,7 +1939,12 @@ def current_mouse_position(self) -> Optional['MousePosition']: return get_mouse_data_for_window(self.os_window_id, self.tab_id, self.id) # Serialization {{{ - def as_dict(self, is_focused: bool = False, is_self: bool = False, is_active: bool = False) -> WindowDict: + def as_dict( + self, is_focused: bool = False, is_self: bool = False, is_active: bool = False, + neighbors_map: NeighborsMap | None = None, + ) -> WindowDict: + if neighbors_map is None: + neighbors_map = {} return { 'id': self.id, 'is_focused': is_focused, @@ -1958,6 +1964,7 @@ def as_dict(self, is_focused: bool = False, is_self: bool = False, is_active: bo 'user_vars': self.user_vars, 'created_at': self.created_at, 'in_alternate_screen': self.screen.is_using_alternate_linebuf(), + 'neighbors': neighbors_map, } def serialize_state(self) -> dict[str, Any]: diff --git a/kitty_tests/layout.py b/kitty_tests/layout.py index 10c203af95d..3f045c5c336 100644 --- a/kitty_tests/layout.py +++ b/kitty_tests/layout.py @@ -267,7 +267,7 @@ def test_splits(self): windows[1].set_geometry(WindowGeometry(11, 0, 20, 10, 0, 0)) windows[2].set_geometry(WindowGeometry(11, 11, 15, 20, 0, 0)) windows[3].set_geometry(WindowGeometry(16, 11, 20, 20, 0, 0)) - self.ae(q.neighbors_for_window(windows[0], all_windows), {'left': [], 'right': [2, 3], 'top': [], 'bottom': []}) - self.ae(q.neighbors_for_window(windows[1], all_windows), {'left': [1], 'right': [], 'top': [], 'bottom': [3, 4]}) - self.ae(q.neighbors_for_window(windows[2], all_windows), {'left': [1], 'right': [4], 'top': [2], 'bottom': []}) - self.ae(q.neighbors_for_window(windows[3], all_windows), {'left': [3], 'right': [], 'top': [2], 'bottom': []}) + self.ae(q.neighbors_for_window(windows[0], all_windows), {'right': [2, 3]}) + self.ae(q.neighbors_for_window(windows[1], all_windows), {'left': [1], 'bottom': [3, 4]}) + self.ae(q.neighbors_for_window(windows[2], all_windows), {'left': [1], 'right': [4], 'top': [2]}) + self.ae(q.neighbors_for_window(windows[3], all_windows), {'left': [3], 'top': [2]}) From 9bcbdb9f14c05e9f20b1e3fcdabd26bc5dc1fb99 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 16 Nov 2025 22:35:57 +0530 Subject: [PATCH 45/53] ... --- kitty/graphics.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kitty/graphics.c b/kitty/graphics.c index ad5679448bc..f985a757564 100644 --- a/kitty/graphics.c +++ b/kitty/graphics.c @@ -1574,7 +1574,7 @@ handle_animation_frame_load_command(GraphicsManager *self, GraphicsCommand *g, I const unsigned long bytes_per_pixel = load_data->is_opaque ? 3 : 4; if (load_data->data_sz < bytes_per_pixel * load_data->width * load_data->height) - ABRT("ENODATA", "Insufficient image data %zu < %zu", load_data->data_sz, bytes_per_pixel * g->data_width, g->data_height); + ABRT("ENODATA", "Insufficient image data %zu < %zu", load_data->data_sz, bytes_per_pixel * g->data_width * g->data_height); if (load_data->width > img->width) ABRT("EINVAL", "Frame width %u larger than image width: %u", load_data->width, img->width); if (load_data->height > img->height) From bfca1763f24f16d8cd6d55dbdacef8a0e38411f4 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 16 Nov 2025 23:28:11 +0530 Subject: [PATCH 46/53] Function to save 24bit RGB data as PNG --- kitty/gl.c | 2 +- kitty/png-reader.c | 21 ++++++++++++++++----- kitty/png-reader.h | 3 ++- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/kitty/gl.c b/kitty/gl.c index 61c26c1dacd..7ced321f915 100644 --- a/kitty/gl.c +++ b/kitty/gl.c @@ -215,7 +215,7 @@ save_texture_as_png(uint32_t texture_id, const char *filename) { data[i] = (r << 0) | (g << 8) | (b << 16) | (a << 24); } - const char *png = png_from_32bit_rgba(data, width, height, &sz, true); + const char *png = png_from_32bit_rgba((char*)data, width, height, &sz, true); if (!sz) fatal("Failed to save PNG to %s with error: %s", filename, png); free(data); FILE* file = fopen(filename, "wb"); diff --git a/kitty/png-reader.c b/kitty/png-reader.c index 4fcd2c6d467..c666d59be92 100644 --- a/kitty/png-reader.c +++ b/kitty/png-reader.c @@ -163,8 +163,8 @@ png_write_to_memory(png_structp png_ptr, png_bytep data, png_size_t length) { } static void png_flush_memory(png_structp png_ptr) { (void)png_ptr; } -const char* -png_from_32bit_rgba(uint32_t *data, size_t width, size_t height, size_t *out_size, bool flip_vertically) { +static const char* +create_png_from_data(char *data, size_t width, size_t height, size_t stride, size_t *out_size, bool flip_vertically, int color_type) { *out_size = 0; png_memory_write_state state = {.capacity=width*height * sizeof(uint32_t)}; state.buffer = malloc(state.capacity); @@ -181,7 +181,7 @@ png_from_32bit_rgba(uint32_t *data, size_t width, size_t height, size_t *out_siz return("Error during PNG creation\n"); } png_set_write_fn(png_ptr, &state, png_write_to_memory, png_flush_memory); - png_set_IHDR(png_ptr, info_ptr, width, height, 8, PNG_COLOR_TYPE_RGBA, + png_set_IHDR(png_ptr, info_ptr, width, height, 8, color_type, PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT); // Allocate memory for row pointers png_bytep *row_pointers = (png_bytep*)malloc(sizeof(png_bytep) * height); @@ -190,8 +190,8 @@ png_from_32bit_rgba(uint32_t *data, size_t width, size_t height, size_t *out_siz free(state.buffer); return ("Failed to allocate memory for row pointers"); } - if (flip_vertically) for (size_t y = 0; y < height; y++) row_pointers[height - 1 - y] = (png_byte*)&data[y * width]; - else for (size_t y = 0; y < height; y++) row_pointers[y] = (png_byte*)&data[y * width]; + if (flip_vertically) for (size_t y = 0; y < height; y++) row_pointers[height - 1 - y] = (png_byte*)&data[y * stride]; + else for (size_t y = 0; y < height; y++) row_pointers[y] = (png_byte*)&data[y * stride]; png_write_info(png_ptr, info_ptr); png_write_image(png_ptr, row_pointers); png_write_end(png_ptr, NULL); @@ -201,6 +201,17 @@ png_from_32bit_rgba(uint32_t *data, size_t width, size_t height, size_t *out_siz return (char*)state.buffer; } +const char* +png_from_32bit_rgba(char *data, size_t width, size_t height, size_t *out_size, bool flip_vertically) { + return create_png_from_data(data, width, height, 4 * width, out_size, flip_vertically, PNG_COLOR_TYPE_RGBA); +} + +const char* +png_from_24bit_rgb(char *data, size_t width, size_t height, size_t *out_size, bool flip_vertically) { + return create_png_from_data(data, width, height, 3 * width, out_size, flip_vertically, PNG_COLOR_TYPE_RGB); +} + + static void png_error_handler(png_read_data *d UNUSED, const char *code, const char *msg) { if (!PyErr_Occurred()) PyErr_Format(PyExc_ValueError, "[%s] %s", code, msg); diff --git a/kitty/png-reader.h b/kitty/png-reader.h index b0eeeb72d7a..41fdbe05c9e 100644 --- a/kitty/png-reader.h +++ b/kitty/png-reader.h @@ -28,4 +28,5 @@ typedef struct png_read_data { } png_read_data; void inflate_png_inner(png_read_data *d, const uint8_t *buf, size_t bufsz, int max_image_dimension); -const char* png_from_32bit_rgba(uint32_t *data, size_t width, size_t height, size_t *out_size, bool flip_vertically); +const char* png_from_32bit_rgba(char *data, size_t width, size_t height, size_t *out_size, bool flip_vertically); +const char* png_from_24bit_rgb(char *data, size_t width, size_t height, size_t *out_size, bool flip_vertically); From e6d7e910006f6276f966d0b6154930ebfb04c7db Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 17 Nov 2025 11:02:07 +0530 Subject: [PATCH 47/53] Avoid using lseek() to track disk cache file write offset It's slow and not thread safe. We use pwrite() so it's not reliable anyway. --- kitty/disk-cache.c | 69 +++++++++++++++++++-------------------- kitty/disk-cache.h | 1 - kitty/fast_data_types.pyi | 2 +- kitty_tests/graphics.py | 33 +++++++++---------- 4 files changed, 50 insertions(+), 55 deletions(-) diff --git a/kitty/disk-cache.c b/kitty/disk-cache.c index bcab2fb5b0c..8f0f187187f 100644 --- a/kitty/disk-cache.c +++ b/kitty/disk-cache.c @@ -87,6 +87,7 @@ typedef struct { cache_map map; Holes holes; unsigned long long total_size; + off_t end_of_data_offset; } DiskCache; #define mutex(op) pthread_mutex_##op(&self->lock) @@ -136,23 +137,6 @@ open_cache_file(const char *cache_path) { // Write loop {{{ -static off_t -size_of_cache_file(DiskCache *self) { - off_t pos = lseek(self->cache_file_fd, 0, SEEK_CUR); - off_t ans = lseek(self->cache_file_fd, 0, SEEK_END); - lseek(self->cache_file_fd, pos, SEEK_SET); - return ans; -} - -size_t -disk_cache_size_on_disk(PyObject *self) { - if (((DiskCache*)self)->cache_file_fd > -1) { - off_t ans = size_of_cache_file((DiskCache*)self); - return MAX(0, ans); - } - return 0; -} - typedef struct { CacheKey key; off_t old_offset, new_offset; @@ -181,7 +165,7 @@ defrag(DiskCache *self) { RAII_FreeFastFileCopyBuffer(fcb); bool lock_released = false, ok = false; - off_t size_on_disk = size_of_cache_file(self); + off_t size_on_disk = self->end_of_data_offset; if (size_on_disk <= 0) goto cleanup; size_t num_entries = vt_size(&self->map); if (!num_entries) goto cleanup; @@ -236,6 +220,7 @@ defrag(DiskCache *self) { if (!vt_is_end(i)) i.data->val->pos_in_cache_file = e->new_offset; free(e->key.hash_key); } + self->end_of_data_offset = lseek(self->cache_file_fd, 0, SEEK_CUR); } if (new_cache_file > -1) safe_close(new_cache_file, __FILE__, __LINE__); } @@ -306,8 +291,7 @@ find_hole_to_use(DiskCache *self, const off_t required_sz) { static inline bool needs_defrag(DiskCache *self) { - off_t size_on_disk = size_of_cache_file(self); - return self->total_size && size_on_disk > 0 && (size_t)size_on_disk > self->total_size * self->defrag_factor; + return self->total_size && self->end_of_data_offset > 0 && (size_t)self->end_of_data_offset > self->total_size * self->defrag_factor; } static void @@ -395,7 +379,7 @@ write_dirty_entry(DiskCache *self) { size_t left = self->currently_writing.val.data_sz; uint8_t *p = self->currently_writing.val.data; if (self->currently_writing.val.pos_in_cache_file < 0) { - self->currently_writing.val.pos_in_cache_file = size_of_cache_file(self); + self->currently_writing.val.pos_in_cache_file = self->end_of_data_offset; if (self->currently_writing.val.pos_in_cache_file < 0) { perror("Failed to seek in disk cache file"); return false; @@ -418,6 +402,7 @@ write_dirty_entry(DiskCache *self) { left -= n; p += n; offset += n; + self->end_of_data_offset = MAX(self->end_of_data_offset, offset); } return true; } @@ -434,6 +419,18 @@ retire_currently_writing(DiskCache *self) { self->currently_writing.val.data_sz = 0; } +static int +clear_disk_cache_with_lock_held(DiskCache *self) { + vt_cleanup(&self->map); + cleanup_holes(&self->holes); + self->total_size = 0; + self->end_of_data_offset = 0; + if (self->cache_file_fd > -1) { + if (ftruncate(self->cache_file_fd, 0) == -1) return errno; + } + return 0; +} + static void* write_loop(void *data) { DiskCache *self = (DiskCache*)data; @@ -457,9 +454,7 @@ write_loop(void *data) { } else if (!count) { mutex(lock); count = vt_size(&self->map); - if (!count && self->cache_file_fd > -1) { - if (ftruncate(self->cache_file_fd, 0) == 0) lseek(self->cache_file_fd, 0, SEEK_END); - } + if (!count) clear_disk_cache_with_lock_held(self); // failure to truncate is not fatal mutex(unlock); } @@ -628,17 +623,18 @@ remove_from_disk_cache(PyObject *self_, const void *key, size_t key_sz) { return removed; } -void +static int clear_disk_cache(PyObject *self_) { + // This is currently only used in testing DiskCache *self = (DiskCache*)self_; - if (!ensure_state(self)) return; + if (!ensure_state(self)) return 0; + int saved_errno = 0; + disk_cache_wait_for_write(self_, 0); mutex(lock); - vt_cleanup(&self->map); - cleanup_holes(&self->holes); - self->total_size = 0; - if (self->cache_file_fd > -1) add_hole(self, 0, size_of_cache_file(self)); + saved_errno = clear_disk_cache_with_lock_held(self); mutex(unlock); wakeup_write_loop(self); + return saved_errno; } static void @@ -780,7 +776,7 @@ PYWRAP(read_from_cache_file) { Py_ssize_t pos = 0, sz = -1; PA("|nn", &pos, &sz); mutex(lock); - if (sz < 0) sz = size_of_cache_file(self); + if (sz < 0) sz = self->end_of_data_offset; mutex(unlock); PyObject *ans = PyBytes_FromStringAndSize(NULL, sz); if (ans) { @@ -798,17 +794,20 @@ wait_for_write(PyObject *self, PyObject *args) { } static PyObject* -size_on_disk(PyObject *self_, PyObject *args UNUSED) { +end_of_data_offset(PyObject *self_, PyObject *args UNUSED) { + // Only used for testing DiskCache *self = (DiskCache*)self_; + unsigned long long ans = 0; mutex(lock); - unsigned long long ans = disk_cache_size_on_disk(self_); + if (self->cache_file_fd > -1) ans = MAX(0, self->end_of_data_offset); mutex(unlock); return PyLong_FromUnsignedLongLong(ans); } static PyObject* clear(PyObject *self, PyObject *args UNUSED) { - clear_disk_cache(self); + int saved_errno = clear_disk_cache(self); + if (saved_errno) return PyErr_SetFromErrno(PyExc_OSError); Py_RETURN_NONE; } @@ -910,7 +909,7 @@ static PyMethodDef methods[] = { {"num_cached_in_ram", num_cached_in_ram, METH_NOARGS, NULL}, {"get", get, METH_VARARGS, NULL}, {"wait_for_write", wait_for_write, METH_VARARGS, NULL}, - {"size_on_disk", size_on_disk, METH_NOARGS, NULL}, + {"end_of_data_offset", end_of_data_offset, METH_NOARGS, NULL}, {"clear", clear, METH_NOARGS, NULL}, {"holes", holes, METH_NOARGS, NULL}, diff --git a/kitty/disk-cache.h b/kitty/disk-cache.h index de48556aec3..d6c1ea26fd7 100644 --- a/kitty/disk-cache.h +++ b/kitty/disk-cache.h @@ -16,7 +16,6 @@ PyObject* read_from_disk_cache_python(PyObject *self_, const void *key, size_t k bool disk_cache_wait_for_write(PyObject *self, monotonic_t timeout); size_t disk_cache_total_size(PyObject *self); size_t disk_cache_size_on_disk(PyObject *self); -void clear_disk_cache(PyObject *self); size_t disk_cache_clear_from_ram(PyObject *self_, bool(matches)(void* data, void *key, unsigned keysz), void*); size_t disk_cache_num_cached_in_ram(PyObject *self_); diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index 54064b70e50..aa0be1be557 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -1812,5 +1812,5 @@ class DiskCache: def remove_from_ram(self, predicate: Callable[[bytes], bool]) -> int: ... def num_cached_in_ram(self) -> int: ... def get(self, key: bytes, store_in_ram: bool = False) -> bytes: ... # raises KeyError if not found - def size_on_disk(self) -> int: ... + def end_of_data_offset(self) -> int: ... def clear(self) -> None: ... diff --git a/kitty_tests/graphics.py b/kitty_tests/graphics.py index 659d60197d9..b254232713d 100644 --- a/kitty_tests/graphics.py +++ b/kitty_tests/graphics.py @@ -253,7 +253,7 @@ def reset(small_hole_threshold=0, defrag_factor=2): self.assertEqual(dc.total_size, sum(map(len, data.values()))) self.assertTrue(dc.wait_for_write()) check_data() - sz = dc.size_on_disk() + sz = dc.end_of_data_offset() self.assertEqual(sz, sum(map(len, data.values()))) self.assertFalse(dc.holes()) holes = set() @@ -262,9 +262,9 @@ def reset(small_hole_threshold=0, defrag_factor=2): holes.add(x) check_data() self.assertRaises(KeyError, dc.get, key_as_bytes(x)) - self.assertEqual(sz, dc.size_on_disk()) + self.assertEqual(sz, dc.end_of_data_offset()) self.assertEqual(holes, {x[1] for x in dc.holes()}) - self.assertEqual(sz, dc.size_on_disk()) + self.assertEqual(sz, dc.end_of_data_offset()) # fill holes largest first to ensure small one doesn't go into large accidentally causing fragmentation for i, x in enumerate(sorted(holes, reverse=True)): x = 'ABCDEFGH'[i] * x @@ -273,13 +273,10 @@ def reset(small_hole_threshold=0, defrag_factor=2): check_data() holes.discard(len(x)) self.assertEqual(holes, {x[1] for x in dc.holes()}) - self.assertEqual(sz, dc.size_on_disk(), f'Disk cache has unexpectedly grown from {sz} to {dc.size_on_disk} with data: {x!r}') + self.assertEqual(sz, dc.end_of_data_offset(), f'Disk cache has unexpectedly grown from {sz} to {dc.end_of_data_offset()} with data: {x!r}') check_data() dc.clear() - st = time.monotonic() - while dc.size_on_disk() and time.monotonic() - st < 2: - time.sleep(0.001) - self.assertEqual(dc.size_on_disk(), 0) + self.assertEqual(dc.end_of_data_offset(), 0) data.clear() for i in range(25): @@ -287,25 +284,25 @@ def reset(small_hole_threshold=0, defrag_factor=2): dc.wait_for_write() check_data() - before = dc.size_on_disk() + before = dc.end_of_data_offset() while dc.total_size > before // 3: key = random.choice(tuple(data)) self.assertTrue(remove(key)) check_data() add('trigger defrag', 'XXX') dc.wait_for_write() - self.assertLess(dc.size_on_disk(), before) + self.assertLess(dc.end_of_data_offset(), before) check_data() dc.clear() st = time.monotonic() - while dc.size_on_disk() and time.monotonic() - st < 20: + while dc.end_of_data_offset() and time.monotonic() - st < 20: time.sleep(0.01) - self.assertEqual(dc.size_on_disk(), 0) + self.assertEqual(dc.end_of_data_offset(), 0) for frame in range(32): add(f'1:{frame}', f'{frame:02d}' * 8) dc.wait_for_write() - self.assertEqual(dc.size_on_disk(), 32 * 16) + self.assertEqual(dc.end_of_data_offset(), 32 * 16) self.assertEqual(dc.num_cached_in_ram(), 0) num_in_ram = 0 for frame in range(32): @@ -326,18 +323,18 @@ def clear_predicate(key): self.assertIsNone(add(1, '1' * 1024)) self.assertIsNone(add(2, '2' * 1024)) dc.wait_for_write() - sz = dc.size_on_disk() + sz = dc.end_of_data_offset() remove(1) - self.ae(sz, dc.size_on_disk()) + self.ae(sz, dc.end_of_data_offset()) self.ae({x[1] for x in dc.holes()}, {1024}) self.assertIsNone(add(3, '3' * 800)) dc.wait_for_write() self.assertFalse(dc.holes()) - self.ae(sz, dc.size_on_disk()) + self.ae(sz, dc.end_of_data_offset()) self.assertIsNone(add(4, '4' * 100)) sz += 100 dc.wait_for_write() - self.ae(sz, dc.size_on_disk()) + self.ae(sz, dc.end_of_data_offset()) check_data() self.assertFalse(dc.holes()) remove(4) @@ -345,7 +342,7 @@ def clear_predicate(key): self.assertIsNone(add(5, '5' * 10)) sz += 10 dc.wait_for_write() - self.ae(sz, dc.size_on_disk()) + self.ae(sz, dc.end_of_data_offset()) # test hole coalescing reset(defrag_factor=20) From 478294a33555ab293517badcdbbd3c8d7578a778 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 17 Nov 2025 12:23:10 +0530 Subject: [PATCH 48/53] Fix typo that caused OSC 3008 to set title rather than just be ignored Fixes #9226 --- kitty/vt-parser.c | 2 +- kitty_tests/__init__.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/kitty/vt-parser.c b/kitty/vt-parser.c index 8c5f9cbe6fa..a2d308b1c07 100644 --- a/kitty/vt-parser.c +++ b/kitty/vt-parser.c @@ -604,7 +604,7 @@ dispatch_osc(PS *self, uint8_t *buf, size_t limit, bool is_extended_osc) { case 701: REPORT_ERROR("Ignoring OSC 701, used by mintty for locale"); break; case 3008: START_DISPATCH - DISPATCH_OSC(set_title); + DISPATCH_OSC(osc_context); END_DISPATCH case 7704: REPORT_ERROR("Ignoring OSC 7704, used by mintty for ANSI colors"); break; case 7750: REPORT_ERROR("Ignoring OSC 7750, used by mintty for Emoji style"); break; diff --git a/kitty_tests/__init__.py b/kitty_tests/__init__.py index 2af8f7a3cff..cc1db836b4f 100644 --- a/kitty_tests/__init__.py +++ b/kitty_tests/__init__.py @@ -81,6 +81,9 @@ def p(x): def title_changed(self, data, is_base64=False) -> None: self.titlebuf.append(process_title_from_child(data, is_base64, '')) + def osc_context(self, data): + pass + def icon_changed(self, data) -> None: self.iconbuf += str(data, 'utf-8') From 1c470fe1f76d4332afab679e5eaf937587669d46 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 17 Nov 2025 12:46:54 +0530 Subject: [PATCH 49/53] ... --- kitty/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kitty/window.py b/kitty/window.py index 2fa9a397187..f6d4243b8e4 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -1288,7 +1288,7 @@ def title_changed(self, new_title: memoryview | None, is_base64: bool = False) - self.title_updated() def osc_context(self, ctx_data: memoryview) -> None: - pass # this is systemd's useless OSC 3008 context protocol + pass # this is systemd's useless OSC 3008 context protocol https://systemd.io/OSC_CONTEXT/ def icon_changed(self, new_icon: memoryview) -> None: pass # TODO: Implement this From 6de4e5237f99214233a29a6115f44ad92eff92a8 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 17 Nov 2025 12:59:04 +0530 Subject: [PATCH 50/53] Resize method should not use bounds --- tools/utils/images/loading.go | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/tools/utils/images/loading.go b/tools/utils/images/loading.go index 6cadee576e9..622edbe1eba 100644 --- a/tools/utils/images/loading.go +++ b/tools/utils/images/loading.go @@ -173,23 +173,19 @@ func ImageFromSerialized(m SerializableImageMetadata, data [][]byte) (*ImageData return &ans, nil } -func (self *ImageFrame) Resize(x_frac, y_frac float64) *ImageFrame { - b := self.Img.Bounds() - left, top, width, height := b.Min.X, b.Min.Y, b.Dx(), b.Dy() - ans := *self - ans.Width = int(x_frac * float64(width)) - ans.Height = int(y_frac * float64(height)) - ans.Img = imaging.ResizeWithOpacity(self.Img, ans.Width, ans.Height, imaging.Lanczos, self.Is_opaque) - ans.Left = int(x_frac * float64(left)) - ans.Top = int(y_frac * float64(top)) +func (ans ImageFrame) Resize(x_frac, y_frac float64) *ImageFrame { + ans.Width = int(x_frac * float64(ans.Width)) + ans.Height = int(y_frac * float64(ans.Height)) + ans.Img = imaging.ResizeWithOpacity(ans.Img, ans.Width, ans.Height, imaging.Lanczos, ans.Is_opaque) + ans.Left = int(x_frac * float64(ans.Left)) + ans.Top = int(y_frac * float64(ans.Top)) return &ans - } func (self *ImageData) Resize(x_frac, y_frac float64) *ImageData { ans := *self ans.Frames = make([]*ImageFrame, len(self.Frames)) - if err := parallel.Run_in_parallel_over_range(0, func(start, limit int) { + if err := parallel.Run_in_parallel_over_range(1, func(start, limit int) { for i := start; i < limit; i++ { ans.Frames[i] = self.Frames[i].Resize(x_frac, y_frac) } From 1240a17b2da0f4e058d998dc22ae57a550fa7595 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 17 Nov 2025 14:30:15 +0530 Subject: [PATCH 51/53] Disk cache: Do not encrypt disk cache contents when the disk cache file was opened with O_TMPFILE Avoids paying the XOR overhead on all cached data. Temp files opened using O_TMPFILE are secure enough, as far as I know. Processes without elevated privileges cannot read from them unless they inherit the file descriptor. --- kitty/disk-cache.c | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/kitty/disk-cache.c b/kitty/disk-cache.c index 8f0f187187f..316b346f111 100644 --- a/kitty/disk-cache.c +++ b/kitty/disk-cache.c @@ -28,7 +28,7 @@ typedef struct CacheKey { typedef struct { uint8_t *data; size_t data_sz; - bool written_to_disk; + bool written_to_disk, uses_encryption; off_t pos_in_cache_file; uint8_t encryption_key[64]; } CacheValue; @@ -88,6 +88,7 @@ typedef struct { Holes holes; unsigned long long total_size; off_t end_of_data_offset; + bool needs_encryption; } DiskCache; #define mutex(op) pthread_mutex_##op(&self->lock) @@ -100,6 +101,7 @@ new_diskcache_object(PyTypeObject *type, PyObject UNUSED *args, PyObject UNUSED self->cache_file_fd = -1; self->small_hole_threshold = 512; self->defrag_factor = 2; + self->needs_encryption = true; } return (PyObject*) self; } @@ -121,14 +123,16 @@ open_cache_file_without_tmpfile(const char *cache_path) { } static int -open_cache_file(const char *cache_path) { +open_cache_file(const char *cache_path, bool *opened_securely) { int fd = -1; + *opened_securely = false; #ifdef O_TMPFILE while (fd < 0) { fd = safe_open(cache_path, O_TMPFILE | O_CLOEXEC | O_EXCL | O_RDWR, S_IRUSR | S_IWUSR); if (fd > -1 || errno != EINTR) break; } if (fd == -1) fd = open_cache_file_without_tmpfile(cache_path); + else *opened_securely = true; #else fd = open_cache_file_without_tmpfile(cache_path); #endif @@ -169,7 +173,8 @@ defrag(DiskCache *self) { if (size_on_disk <= 0) goto cleanup; size_t num_entries = vt_size(&self->map); if (!num_entries) goto cleanup; - new_cache_file = open_cache_file(self->cache_dir); + bool opened_securely; + new_cache_file = open_cache_file(self->cache_dir, &opened_securely); if (new_cache_file < 0) { perror("Failed to open second file for defrag of disk cache"); goto cleanup; @@ -221,6 +226,7 @@ defrag(DiskCache *self) { free(e->key.hash_key); } self->end_of_data_offset = lseek(self->cache_file_fd, 0, SEEK_CUR); + self->needs_encryption = !opened_securely; } if (new_cache_file > -1) safe_close(new_cache_file, __FILE__, __LINE__); } @@ -360,7 +366,11 @@ find_cache_entry_to_write(DiskCache *self) { s->data = NULL; self->currently_writing.val.data_sz = s->data_sz; self->currently_writing.val.pos_in_cache_file = -1; - xor_data64(s->encryption_key, self->currently_writing.val.data, s->data_sz); + s->uses_encryption = false; + if (self->needs_encryption && secure_random_bytes(s->encryption_key, sizeof(s->encryption_key))) { + xor_data64(s->encryption_key, self->currently_writing.val.data, s->data_sz); + s->uses_encryption = true; + } self->currently_writing.key.hash_keylen = MIN(i.data->key.hash_keylen, MAX_KEY_SIZE); memcpy(self->currently_writing.key.hash_key, i.data->key.hash_key, self->currently_writing.key.hash_keylen); find_hole_to_use(self, self->currently_writing.val.data_sz); @@ -512,11 +522,13 @@ ensure_state(DiskCache *self) { } if (self->cache_file_fd < 0) { - self->cache_file_fd = open_cache_file(self->cache_dir); + bool opened_securely; + self->cache_file_fd = open_cache_file(self->cache_dir, &opened_securely); if (self->cache_file_fd < 0) { PyErr_SetFromErrnoWithFilename(PyExc_OSError, self->cache_dir); return false; } + self->needs_encryption = !opened_securely; } vt_init(&self->map); vt_init(&self->holes.pos_map); vt_init(&self->holes.size_map); vt_init(&self->holes.end_pos_map); self->fully_initialized = true; @@ -561,7 +573,6 @@ static CacheValue* create_cache_entry(void) { CacheValue *s = calloc(1, sizeof(CacheValue)); if (!s) return (CacheValue*)PyErr_NoMemory(); - if (!secure_random_bytes(s->encryption_key, sizeof(s->encryption_key))) { free(s); PyErr_SetFromErrno(PyExc_OSError); return NULL; } s->pos_in_cache_file = -2; return s; } @@ -689,11 +700,11 @@ read_from_disk_cache(PyObject *self_, const void *key, size_t key_sz, void*(allo if (s->data) { memcpy(data, s->data, s->data_sz); } else if (self->currently_writing.val.data && self->currently_writing.key.hash_key && keys_are_equal(self->currently_writing.key, k)) { memcpy(data, self->currently_writing.val.data, s->data_sz); - xor_data64(s->encryption_key, data, s->data_sz); + if (s->uses_encryption) xor_data64(s->encryption_key, data, s->data_sz); } else { read_from_cache_entry(self, s, data); - xor_data64(s->encryption_key, data, s->data_sz); + if (s->uses_encryption) xor_data64(s->encryption_key, data, s->data_sz); } if (store_in_ram && !s->data && s->data_sz) { void *copy = malloc(s->data_sz); From 4ea6862dd33f7d111d0646cbcff8bb277199f67a Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 17 Nov 2025 17:23:40 +0530 Subject: [PATCH 52/53] Remove unused code --- kitty/disk-cache.c | 16 ++-------------- kitty/fast_data_types.pyi | 16 ---------------- 2 files changed, 2 insertions(+), 30 deletions(-) diff --git a/kitty/disk-cache.c b/kitty/disk-cache.c index 316b346f111..83e85bb6554 100644 --- a/kitty/disk-cache.c +++ b/kitty/disk-cache.c @@ -775,6 +775,7 @@ disk_cache_num_cached_in_ram(PyObject *self_) { } +// The Python interface used only for testing {{{ #define PYWRAP(name) static PyObject* py##name(DiskCache *self, PyObject *args) #define PA(fmt, ...) if (!PyArg_ParseTuple(args, fmt, __VA_ARGS__)) return NULL; PYWRAP(ensure_state) { @@ -783,19 +784,6 @@ PYWRAP(ensure_state) { Py_RETURN_NONE; } -PYWRAP(read_from_cache_file) { - Py_ssize_t pos = 0, sz = -1; - PA("|nn", &pos, &sz); - mutex(lock); - if (sz < 0) sz = self->end_of_data_offset; - mutex(unlock); - PyObject *ans = PyBytes_FromStringAndSize(NULL, sz); - if (ans) { - read_from_cache_file(self, pos, sz, PyBytes_AS_STRING(ans)); - } - return ans; -} - static PyObject* wait_for_write(PyObject *self, PyObject *args) { double timeout = 0; @@ -913,7 +901,6 @@ num_cached_in_ram(PyObject *self, PyObject *args UNUSED) { #define MW(name, arg_type) {#name, (PyCFunction)py##name, arg_type, NULL} static PyMethodDef methods[] = { MW(ensure_state, METH_NOARGS), - MW(read_from_cache_file, METH_VARARGS), {"add", add, METH_VARARGS, NULL}, {"remove", pyremove, METH_VARARGS, NULL}, {"remove_from_ram", remove_from_ram, METH_O, NULL}, @@ -949,3 +936,4 @@ PyTypeObject DiskCache_Type = { INIT_TYPE(DiskCache) PyObject* create_disk_cache(void) { return new_diskcache_object(&DiskCache_Type, NULL, NULL); } +// }}} diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index aa0be1be557..b3681c2ba31 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -1798,19 +1798,3 @@ class StreamingBase64Encodeer: def reset(self) -> bytes: ... # encode the specified data, return number of bytes written dest should be at least 4/3 *src + 2 bytes in size def encode_into(self, dest: WriteableBuffer, src: ReadableBuffer) -> int: ... - - - -class DiskCache: - small_hole_threshold: int - defrag_factor: int - @property - def total_size(self) -> int: ... - - def add(self, key: bytes, data: bytes) -> None: ... - def remove(self, key: bytes) -> bool: ... - def remove_from_ram(self, predicate: Callable[[bytes], bool]) -> int: ... - def num_cached_in_ram(self) -> int: ... - def get(self, key: bytes, store_in_ram: bool = False) -> bytes: ... # raises KeyError if not found - def end_of_data_offset(self) -> int: ... - def clear(self) -> None: ... From 11dd7eeb8ecadecdce99f1490d366e8bd18eba2f Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 17 Nov 2025 17:42:26 +0530 Subject: [PATCH 53/53] Have the --start-as flag be respected when used with --single-instance Fixes #9228 --- docs/changelog.rst | 3 +++ kitty/boss.py | 3 ++- kitty/simple_cli_definitions.py | 9 ++++++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2235f28f4e1..610fa93c66a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -165,6 +165,9 @@ Detailed list of changes - kitten @ ls: Also output the neighbors for every window (:disc:`9225`) +- Have the :option:`kitty --start-as` flag be respected when used with + :option:`kitty --single-instance` (:iss:`9228`) + 0.44.0 [2025-11-03] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/kitty/boss.py b/kitty/boss.py index 5dbcee872f6..d1d3efb87d5 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -941,9 +941,10 @@ def peer_message_received(self, msg_bytes: bytes, peer_id: int, is_remote_contro assert isinstance(window.launch_spec, LaunchSpec) launch(get_boss(), window.launch_spec.opts, window.launch_spec.args) continue + wstate = args.start_as if args.start_as and args.start_as != 'normal' else None os_window_id = self.add_os_window( session, wclass=args.cls, wname=args.name, opts_for_size=opts, startup_id=startup_id, - override_title=args.title or None) + override_title=args.title or None, window_state=wstate) if session.focus_os_window: focused_os_window = os_window_id if opts.background_opacity != get_options().background_opacity: diff --git a/kitty/simple_cli_definitions.py b/kitty/simple_cli_definitions.py index 1c73964ab93..af40b461449 100644 --- a/kitty/simple_cli_definitions.py +++ b/kitty/simple_cli_definitions.py @@ -387,7 +387,7 @@ def generate_c_parsers() -> Iterator[str]: def kitty_options_spec() -> str: if not hasattr(kitty_options_spec, 'ans'): - OPTIONS = ''' + OPTIONS = """ --class --app-id dest=cls default={appname} @@ -484,7 +484,10 @@ def kitty_options_spec() -> str: type=choices default=normal choices=normal,fullscreen,maximized,minimized,hidden -Control how the initial kitty window is created. +Control how the initial kitty OS window is created. Note that +this is applies to all OS Windows if you use the :option:`{appname} --session` +option to create multiple OS Windows. Any OS Windows state in +specified in the session file gets overriden. --position @@ -552,7 +555,7 @@ def kitty_options_spec() -> str: --execute -e type=bool-set ! -''' +""" setattr(kitty_options_spec, 'ans', OPTIONS.format( appname=appname, conf_name=appname, listen_on_defn=listen_on_defn, grab_keyboard_docs=grab_keyboard_docs, wait_for_single_instance_defn=wait_for_single_instance_defn,