diff --git a/build.zig.zon b/build.zig.zon index c737ea9f1..96cda01a0 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -66,7 +66,7 @@ .gobject = .{ // https://github.com/ghostty-org/zig-gobject based on zig_gobject // Temporary until we generate them at build time automatically. - .url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.10.0-2026-07-28-36-1/ghostty-gobject-0.10.0-2026-07-28-36-1.tar.zst", + .url = "https://deps.files.ghostty.org/gobject-2026-07-28-36-1.tar.zst", .hash = "gobject-0.3.2-Skun7F6HogCMynX2JqeSHS7xr-8pK4ob-qRFIcEasVi3", .lazy = true, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 900da9c2f..376ca05cb 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -36,7 +36,7 @@ }, "gobject-0.3.2-Skun7F6HogCMynX2JqeSHS7xr-8pK4ob-qRFIcEasVi3": { "name": "gobject", - "url": "https://github.com/ghostty-org/zig-gobject/releases/download/0.10.0-2026-07-28-36-1/ghostty-gobject-0.10.0-2026-07-28-36-1.tar.zst", + "url": "https://deps.files.ghostty.org/gobject-2026-07-28-36-1.tar.zst", "hash": "sha256-dyCfm2XjiAk30zccjD6AgKFBdE7IRsJuoqnscfvnWSQ=" }, "N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": { @@ -151,8 +151,8 @@ }, "N-V-__8AAP5JWgCGP_AD0teWpa4krRvE9VPZzvviGdbmN4jI": { "name": "wuffs", - "url": "git+https://github.com/google/wuffs-mirror-release-c.git?rev=v0.4.0-alpha.10#7411f488fe2e2c205c3d3b3d28638b7356522930", - "hash": "sha256-AMuAaCbNJYnSeac1B1IRSUh4rlE2KphT04/Ak4Pig5M=" + "url": "https://deps.files.ghostty.org/wuffs-7411f488fe2e2c205c3d3b3d28638b7356522930.tar.gz", + "hash": "sha256-F4d9NG95iGUdbGLkWy47BchoCaZELF40YSN0sqlxmhw=" }, "z2d-0.12.1-j5P_Hsw8EQAKyZTQICCQnAH2xYkLDW8k9uefbsYdfPZ-": { "name": "z2d", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 411963637..e955d4dc4 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -164,7 +164,7 @@ in name = "gobject-0.3.2-Skun7F6HogCMynX2JqeSHS7xr-8pK4ob-qRFIcEasVi3"; path = fetchZigArtifact { name = "gobject"; - url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.10.0-2026-07-28-36-1/ghostty-gobject-0.10.0-2026-07-28-36-1.tar.zst"; + url = "https://deps.files.ghostty.org/gobject-2026-07-28-36-1.tar.zst"; hash = "sha256-dyCfm2XjiAk30zccjD6AgKFBdE7IRsJuoqnscfvnWSQ="; unpack = true; }; @@ -371,9 +371,9 @@ in name = "N-V-__8AAP5JWgCGP_AD0teWpa4krRvE9VPZzvviGdbmN4jI"; path = fetchZigArtifact { name = "wuffs"; - url = "git+https://github.com/google/wuffs-mirror-release-c.git?rev=v0.4.0-alpha.10#7411f488fe2e2c205c3d3b3d28638b7356522930"; - hash = "sha256-AMuAaCbNJYnSeac1B1IRSUh4rlE2KphT04/Ak4Pig5M="; - unpack = true; + url = "https://deps.files.ghostty.org/wuffs-7411f488fe2e2c205c3d3b3d28638b7356522930.tar.gz"; + hash = "sha256-F4d9NG95iGUdbGLkWy47BchoCaZELF40YSN0sqlxmhw="; + unpack = false; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 2c98db17c..82e00bd4b 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -1,4 +1,3 @@ -git+https://github.com/google/wuffs-mirror-release-c.git?rev=v0.4.0-alpha.10#7411f488fe2e2c205c3d3b3d28638b7356522930 git+https://github.com/rockorager/libvaxis.git#c1e1f23be38951c425cdf31af455ba23ef178940 git+https://github.com/zigimg/zigimg#d695acd97c02e57bb151e8f659d1280f5cd6ca70 https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz @@ -12,6 +11,7 @@ https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab0 https://deps.files.ghostty.org/gettext-0.24.tar.gz https://deps.files.ghostty.org/ghostty-themes-release-20260810-152212-0173c3c.tgz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz +https://deps.files.ghostty.org/gobject-2026-07-28-36-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz @@ -29,10 +29,10 @@ https://deps.files.ghostty.org/vaxis-1dbbe575dff4586fe51e3217aa5c3fecdcbb6089.ta https://deps.files.ghostty.org/wayland-0.6.0-lQa1kqz8AQADQmdNJsNhLoNHcnEGEUjrOaPV-dtEnEmX.tar.gz https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz +https://deps.files.ghostty.org/wuffs-7411f488fe2e2c205c3d3b3d28638b7356522930.tar.gz https://deps.files.ghostty.org/z2d-7dbae85c81784dba9988320bf9543ed9a81350c8.tar.gz https://deps.files.ghostty.org/zf-c35c421f84895193246db06c40683c1a30e616ef.tar.gz https://deps.files.ghostty.org/zig_js-3c23860e47fdcdc5af805efb7fd0bdac5fd3e9bc.tar.gz https://deps.files.ghostty.org/zig_objc-c8de82ff80281215ad92900866dab7103a8efa8b.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz -https://github.com/ghostty-org/zig-gobject/releases/download/0.10.0-2026-07-28-36-1/ghostty-gobject-0.10.0-2026-07-28-36-1.tar.zst https://github.com/vancluever/arocc/archive/ecbc5c799574e0da2758a961b12efa586007f03c.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 9526f1eb8..ad54aa752 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -43,7 +43,7 @@ }, { "type": "archive", - "url": "https://github.com/ghostty-org/zig-gobject/releases/download/0.10.0-2026-07-28-36-1/ghostty-gobject-0.10.0-2026-07-28-36-1.tar.zst", + "url": "https://deps.files.ghostty.org/gobject-2026-07-28-36-1.tar.zst", "dest": "vendor/p/gobject-0.3.2-Skun7F6HogCMynX2JqeSHS7xr-8pK4ob-qRFIcEasVi3", "sha256": "9014db570db06e3fbfcfadb7b32aa8addacc6969e701650683c1c74428adba6c" }, @@ -180,10 +180,10 @@ "sha256": "5cedcadde81b75e60f23e5e83b5dd2b8eb4efb9f8f79bd7a347d148aeb0530f8" }, { - "type": "git", - "url": "https://github.com/google/wuffs-mirror-release-c.git", - "commit": "7411f488fe2e2c205c3d3b3d28638b7356522930", - "dest": "vendor/p/N-V-__8AAP5JWgCGP_AD0teWpa4krRvE9VPZzvviGdbmN4jI" + "type": "archive", + "url": "https://deps.files.ghostty.org/wuffs-7411f488fe2e2c205c3d3b3d28638b7356522930.tar.gz", + "dest": "vendor/p/N-V-__8AAP5JWgCGP_AD0teWpa4krRvE9VPZzvviGdbmN4jI", + "sha256": "17877d346f7988651d6c62e45b2e3b05c86809a6442c5e34612374b2a9719a1c" }, { "type": "archive", diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h index 22f6ac864..99cee60ac 100644 --- a/include/ghostty/vt/kitty_graphics.h +++ b/include/ghostty/vt/kitty_graphics.h @@ -399,6 +399,12 @@ typedef enum GHOSTTY_ENUM_TYPED { * time, before the image is stored. Consumers can upload this * directly to the GPU without any decode step. * + * For an animated image (Kitty graphics animation, actions a=f/a=a) + * this is the pixel data of the current animation frame. The + * image's GHOSTTY_KITTY_IMAGE_DATA_GENERATION changes whenever the + * current frame changes, so generation-keyed caches remain + * coherent. + * * Output type: const uint8_t ** */ GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR = 7, diff --git a/nix/build-support/update-mirror.nu b/nix/build-support/update-mirror.nu index b234172bf..974e9d0f5 100755 --- a/nix/build-support/update-mirror.nu +++ b/nix/build-support/update-mirror.nu @@ -5,12 +5,43 @@ # directory, and updates the build.zig.zon files (the root one as well as # every pkg/*/build.zig.zon) to point to the new mirror URLs. # -# The downloaded files are unmodified so their checksums and content hashes -# will match the originals. +# HTTP files are downloaded unmodified. Git dependencies use Zig's normalized +# cache archive so their content hashes match the originals. # # After running this script, the files in the output directory can be uploaded # to blob storage, and the build.zig.zon files will already be updated with # the new URLs. +def download-git-package [url: string, expected_hash: string, destination: string] { + let cache_dir = (mktemp --directory) + let result = (do { ^zig fetch --global-cache-dir $cache_dir $url } | complete) + + if $result.exit_code != 0 { + rm --recursive $cache_dir + error make { + msg: $"zig fetch failed for ($url): ($result.stderr | str trim)" + } + } + + let actual_hash = ($result.stdout | str trim) + if $actual_hash != $expected_hash { + rm --recursive $cache_dir + error make { + msg: $"zig fetch hash mismatch for ($url): expected ($expected_hash), got ($actual_hash)" + } + } + + let archive = ($cache_dir | path join "p" $"($actual_hash).tar.gz") + if not ($archive | path exists) { + rm --recursive $cache_dir + error make { + msg: $"zig fetch did not produce a cache archive for ($url)" + } + } + + cp $archive $destination + rm --recursive $cache_dir +} + def main [ --output: string = "tmp-mirror", # Output directory for the mirrored files --prefix: string = "https://deps.files.ghostty.org/", # Final URL prefix to ignore @@ -45,8 +76,11 @@ def main [ let name = $entry.value.name let url = $entry.value.url - # Skip URLs that don't start with http(s) - if not ($url | str starts-with "http") { + let is_git_url = ($url | str starts-with "git+http") + let is_http_url = ($url | str starts-with "http") + + # Skip URLs that aren't HTTP downloads or HTTP-backed Git repositories. + if not ($is_http_url or $is_git_url) { continue } @@ -55,8 +89,13 @@ def main [ continue } - # Extract the file extension from the URL - let extension = ($url | parse -r '(\.[a-z0-9]+(?:\.[a-z0-9]+)?)$' | get -o capture0.0 | default "") + # Git dependencies are mirrored using an archive of the normalized package + # tree produced by Zig. Fetching it over HTTP produces the same package hash. + let extension = if $is_git_url { + ".tar.gz" + } else { + $url | parse -r '(\.[a-z0-9]+(?:\.[a-z0-9]+)?)$' | get -o capture0.0 | default "" + } # Try to extract commit hash (40 hex chars) from URL let commit_hash = ($url | parse -r '([a-f0-9]{40})' | get -o capture0.0 | default "") @@ -80,7 +119,12 @@ def main [ # Download the file if not $dry_run { - http get $url | save -f ($output_dir | path join $filename) + let destination = ($output_dir | path join $filename) + if $is_git_url { + download-git-package $url $key $destination + } else { + http get $url | save -f $destination + } } } diff --git a/pkg/afl++/build.zig b/pkg/afl++/build.zig index 9f6f70e96..42e514a0b 100644 --- a/pkg/afl++/build.zig +++ b/pkg/afl++/build.zig @@ -9,11 +9,6 @@ pub fn addInstrumentedExe( b: *std.Build, obj: *std.Build.Step.Compile, ) std.Build.LazyPath { - // Force the build system to produce the binary artifact even though we - // only consume the LLVM bitcode below. Without this, the dependency - // tracking doesn't wire up correctly. - _ = obj.getEmittedBin(); - const pkg = b.dependencyFromBuildZig( @This(), .{}, @@ -34,6 +29,25 @@ pub fn addInstrumentedExe( const fuzz_exe = afl_cc.addOutputFileArg(obj.name); afl_cc.addFileArg(pkg.path("afl.c")); afl_cc.addFileArg(obj.getEmittedLlvmBc()); + + // The LLVM bitcode only contains the Zig code in the compilation. + // C source files in the module graph are compiled to native objects + // that live only in the static archive, so link the archive after + // the bitcode to resolve those symbols. The archive members holding + // the Zig code are never pulled in (and so can't conflict) because + // the bitcode object already defines every symbol they provide. + // Those C objects are built with UBSan in debug modes and we link + // with an external compiler that doesn't provide Zig's ubsan + // runtime, so it must be bundled. The ubsan runtime uses f128 + // conversion builtins that the external compiler's runtime may not + // provide (e.g. Apple's), so Zig's compiler-rt must be bundled too. + // The archive members must also be built as PIC since external + // compilers typically default to PIE executables. + obj.bundle_ubsan_rt = true; + obj.bundle_compiler_rt = true; + obj.root_module.pic = true; + afl_cc.addFileArg(obj.getEmittedBin()); + return fuzz_exe; } diff --git a/pkg/wuffs/build.zig b/pkg/wuffs/build.zig index edda0aac6..2ae45585f 100644 --- a/pkg/wuffs/build.zig +++ b/pkg/wuffs/build.zig @@ -71,7 +71,13 @@ pub fn build(b: *std.Build) !void { var flags: std.ArrayList([]const u8) = .empty; defer flags.deinit(b.allocator); try flags.append(b.allocator, "-DWUFFS_IMPLEMENTATION"); - if (target.result.abi == .msvc) { + + // Disable ubsan on Windows to avoid undefined __ubsan_handle_* + // references: Zig's ubsan runtime can't be bundled on Windows + // (its /exclude-symbols directives break the MSVC linker), so + // these handlers would go unresolved. This affects both the + // MSVC and GNU ABIs. + if (windows) { try flags.append(b.allocator, "-fno-sanitize=undefined"); try flags.append(b.allocator, "-fno-sanitize-trap=undefined"); } diff --git a/pkg/wuffs/build.zig.zon b/pkg/wuffs/build.zig.zon index e2f4bd8fe..3016da7c9 100644 --- a/pkg/wuffs/build.zig.zon +++ b/pkg/wuffs/build.zig.zon @@ -11,7 +11,7 @@ // google/wuffs .wuffs = .{ - .url = "git+https://github.com/google/wuffs-mirror-release-c.git?rev=v0.4.0-alpha.10#7411f488fe2e2c205c3d3b3d28638b7356522930", + .url = "https://deps.files.ghostty.org/wuffs-7411f488fe2e2c205c3d3b3d28638b7356522930.tar.gz", .hash = "N-V-__8AAP5JWgCGP_AD0teWpa4krRvE9VPZzvviGdbmN4jI", .lazy = true, }, diff --git a/pkg/wuffs/src/swizzle.zig b/pkg/wuffs/src/swizzle.zig index c1e64b91c..d873efea2 100644 --- a/pkg/wuffs/src/swizzle.zig +++ b/pkg/wuffs/src/swizzle.zig @@ -55,6 +55,35 @@ pub fn bgraToRgba(alloc: Allocator, src: []const u8) Error![]u8 { ); } +/// Composite `src` over `dst` in place. Both are straight +/// (non-premultiplied) alpha RGBA of the same length. A transparent +/// destination pixel takes the source pixel exactly; wuffs composites +/// everything else in 16-bit integer space. +pub fn rgbaSrcOver(dst: []u8, src: []const u8) void { + assert(dst.len == src.len); + assert(dst.len % 4 == 0); + + var swizzler: c.wuffs_base__pixel_swizzler = undefined; + const status = c.wuffs_base__pixel_swizzler__prepare( + &swizzler, + c.wuffs_base__make_pixel_format(c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL), + c.wuffs_base__empty_slice_u8(), + c.wuffs_base__make_pixel_format(c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL), + c.wuffs_base__empty_slice_u8(), + c.WUFFS_BASE__PIXEL_BLEND__SRC_OVER, + ); + // This format pair and blend mode is a supported swizzle, so + // preparation can only fail on a programming error. + assert(c.wuffs_base__status__is_ok(&status)); + + _ = c.wuffs_base__pixel_swizzler__swizzle_interleaved_from_slice( + &swizzler, + c.wuffs_base__make_slice_u8(dst.ptr, dst.len), + c.wuffs_base__empty_slice_u8(), + c.wuffs_base__make_slice_u8(@constCast(src.ptr), src.len), + ); +} + test "gaToRgba" { const rgba = try gaToRgba(std.testing.allocator, &.{ 7, 100, 8, 200 }); defer std.testing.allocator.free(rgba); @@ -65,6 +94,30 @@ test "gaToRgba" { }, rgba); } +test "rgbaSrcOver" { + // 50% white over opaque black, opaque over anything, transparent + // source over anything, and anything over a transparent + // destination (exact source passthrough). + var dst = [_]u8{ + 0, 0, 0, 255, + 10, 20, 30, 40, + 10, 20, 30, 40, + 0, 0, 0, 0, + }; + rgbaSrcOver(&dst, &.{ + 255, 255, 255, 128, + 100, 110, 120, 255, + 100, 110, 120, 0, + 200, 100, 50, 128, + }); + try std.testing.expectEqualSlices(u8, &.{ + 128, 128, 128, 255, + 100, 110, 120, 255, + 10, 20, 30, 40, + 200, 100, 50, 128, + }, &dst); +} + fn swizzle( alloc: Allocator, src: []const u8, diff --git a/po/zh_TW.po b/po/zh_TW.po index ce39415d9..eaa0ddbb0 100644 --- a/po/zh_TW.po +++ b/po/zh_TW.po @@ -2,14 +2,16 @@ # Copyright (C) 2025 Mitchell Hashimoto # This file is distributed under the same license as the com.mitchellh.ghostty package. # Peter Dave Hello , 2025. +# Yi-Jyun Pan , 2025. +# Alang Hsu , 2026. # msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2026-08-10 10:06-0500\n" -"PO-Revision-Date: 2026-02-18 13:58+0800\n" -"Last-Translator: Yi-Jyun Pan \n" +"PO-Revision-Date: 2026-08-14 10:59+0800\n" +"Last-Translator: Alang Hsu \n" "Language-Team: Chinese (traditional)\n" "Language: zh_TW\n" "MIME-Version: 1.0\n" @@ -170,7 +172,7 @@ msgstr "向右分割" #: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" -msgstr "" +msgstr "關閉窗格" #: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" @@ -198,7 +200,7 @@ msgstr "視窗" #: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 msgid "Change Window Title…" -msgstr "" +msgstr "變更視窗標題…" #: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 #: src/input/command.zig:421 @@ -216,11 +218,11 @@ msgstr "設定" #: src/apprt/gtk/ui/1.2/surface.blp:427 src/apprt/gtk/ui/1.5/window.blp:306 msgid "Open Configuration in OS Editor" -msgstr "" +msgstr "使用作業系統編輯器開啟設定" #: src/apprt/gtk/ui/1.2/surface.blp:433 src/apprt/gtk/ui/1.5/window.blp:312 msgid "Open Configuration in New Window" -msgstr "" +msgstr "在新視窗中開啟設定" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -264,27 +266,27 @@ msgstr "執行命令…" #: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." -msgstr "" +msgstr "系統已撤銷此按鍵綁定。" #: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." -msgstr "" +msgstr "系統已拒絕此按鍵綁定。" #: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" -msgstr "" +msgstr "無法使用全域按鍵綁定" #: src/apprt/gtk/class/application.zig:2259 msgid "Export Terminal IO Events" -msgstr "" +msgstr "匯出終端機 IO 事件" #: src/apprt/gtk/class/application.zig:2262 msgid "Export" -msgstr "" +msgstr "匯出" #: src/apprt/gtk/class/application.zig:2783 msgid "Editing configuration file" -msgstr "" +msgstr "正在編輯設定檔" #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" @@ -364,7 +366,7 @@ msgstr "變更分頁標題" #: src/apprt/gtk/class/title_dialog.zig:229 msgid "Change Window Title" -msgstr "" +msgstr "變更視窗標題" #: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" @@ -384,710 +386,710 @@ msgstr "Ghostty 開發者" #: src/input/command.zig:149 msgid "Reset Terminal" -msgstr "" +msgstr "重設終端機" #: src/input/command.zig:150 msgid "Reset the terminal to a clean state." -msgstr "" +msgstr "將終端機重設為初始狀態。" #: src/input/command.zig:155 msgid "Copy to Clipboard" -msgstr "" +msgstr "複製到剪貼簿" #: src/input/command.zig:156 msgid "" "Copy the selected text to the clipboard in both plain and styled formats." -msgstr "" +msgstr "將選取的文字同時以純文字與 HTML 兩種格式複製到剪貼簿。" #: src/input/command.zig:159 msgid "Copy Selection as Plain Text to Clipboard" -msgstr "" +msgstr "將選取範圍以純文字複製到剪貼簿" #: src/input/command.zig:160 msgid "Copy the selected text as plain text to the clipboard." -msgstr "" +msgstr "將選取的文字以純文字複製到剪貼簿。" #: src/input/command.zig:163 msgid "Copy Selection as ANSI Sequences to Clipboard" -msgstr "" +msgstr "將選取範圍以 ANSI 跳脫序列複製到剪貼簿" #: src/input/command.zig:164 msgid "Copy the selected text as ANSI escape sequences to the clipboard." -msgstr "" +msgstr "將選取的文字以 ANSI 跳脫序列複製到剪貼簿。" #: src/input/command.zig:167 msgid "Copy Selection as HTML to Clipboard" -msgstr "" +msgstr "將選取範圍以 HTML 複製到剪貼簿" #: src/input/command.zig:168 msgid "Copy the selected text as HTML to the clipboard." -msgstr "" +msgstr "將選取的文字以 HTML 複製到剪貼簿。" #: src/input/command.zig:173 msgid "Copy URL to Clipboard" -msgstr "" +msgstr "將 URL 複製到剪貼簿" #: src/input/command.zig:174 msgid "Copy the URL under the cursor to the clipboard." -msgstr "" +msgstr "將游標下的 URL 複製到剪貼簿。" #: src/input/command.zig:179 msgid "Copy Terminal Title to Clipboard" -msgstr "" +msgstr "將終端機標題複製到剪貼簿" #: src/input/command.zig:180 msgid "" "Copy the terminal title to the clipboard. If the terminal title is not set " "this has no effect." -msgstr "" +msgstr "將終端機標題複製到剪貼簿。若未設定終端機標題,此操作將不會有任何作用。" #: src/input/command.zig:185 msgid "Paste from Clipboard" -msgstr "" +msgstr "從剪貼簿貼上" #: src/input/command.zig:186 msgid "Paste the contents of the main clipboard." -msgstr "" +msgstr "貼上主剪貼簿的內容。" #: src/input/command.zig:191 msgid "Paste from Selection" -msgstr "" +msgstr "從選取範圍貼上" #: src/input/command.zig:192 msgid "Paste the contents of the selection clipboard." -msgstr "" +msgstr "貼上選取剪貼簿的內容。" #: src/input/command.zig:197 msgid "Start Search" -msgstr "" +msgstr "開始搜尋" #: src/input/command.zig:198 msgid "Start a search if one isn't already active." -msgstr "" +msgstr "若目前沒有進行中的搜尋,則開始搜尋。" #: src/input/command.zig:203 msgid "Search Selection" -msgstr "" +msgstr "搜尋選取範圍" #: src/input/command.zig:204 msgid "Start a search for the current text selection." -msgstr "" +msgstr "以目前選取的文字開始搜尋。" #: src/input/command.zig:209 msgid "End Search" -msgstr "" +msgstr "結束搜尋" #: src/input/command.zig:210 msgid "End the current search if any and hide any GUI elements." -msgstr "" +msgstr "結束目前的搜尋(若有)並隱藏所有 GUI 元件。" #: src/input/command.zig:215 msgid "Next Search Result" -msgstr "" +msgstr "下一個搜尋結果" #: src/input/command.zig:216 msgid "Navigate to the next search result, if any." -msgstr "" +msgstr "前往下一個搜尋結果(若有)。" #: src/input/command.zig:219 msgid "Previous Search Result" -msgstr "" +msgstr "上一個搜尋結果" #: src/input/command.zig:220 msgid "Navigate to the previous search result, if any." -msgstr "" +msgstr "前往上一個搜尋結果(若有)。" #: src/input/command.zig:225 msgid "Increase Font Size" -msgstr "" +msgstr "放大字型" #: src/input/command.zig:226 msgid "Increase the font size by 1 point." -msgstr "" +msgstr "將字型大小放大 1 pt。" #: src/input/command.zig:231 msgid "Decrease Font Size" -msgstr "" +msgstr "縮小字型" #: src/input/command.zig:232 msgid "Decrease the font size by 1 point." -msgstr "" +msgstr "將字型大小縮小 1 pt。" #: src/input/command.zig:237 msgid "Reset Font Size" -msgstr "" +msgstr "重設字型大小" #: src/input/command.zig:238 msgid "Reset the font size to the default." -msgstr "" +msgstr "將字型大小重設為預設值。" #: src/input/command.zig:243 msgid "Clear Screen" -msgstr "" +msgstr "清除螢幕" #: src/input/command.zig:244 msgid "Clear the screen and scrollback." -msgstr "" +msgstr "清除螢幕與捲動緩衝區。" #: src/input/command.zig:249 msgid "Select All" -msgstr "" +msgstr "全選" #: src/input/command.zig:250 msgid "Select all text on the screen." -msgstr "" +msgstr "選取螢幕上的所有文字。" #: src/input/command.zig:255 msgid "Scroll to Top" -msgstr "" +msgstr "捲動到頂端" #: src/input/command.zig:256 msgid "Scroll to the top of the screen." -msgstr "" +msgstr "捲動到螢幕頂端。" #: src/input/command.zig:261 msgid "Scroll to Bottom" -msgstr "" +msgstr "捲動到底端" #: src/input/command.zig:262 msgid "Scroll to the bottom of the screen." -msgstr "" +msgstr "捲動到螢幕底端。" #: src/input/command.zig:267 msgid "Scroll to Selection" -msgstr "" +msgstr "捲動到選取範圍" #: src/input/command.zig:268 msgid "Scroll to the selected text." -msgstr "" +msgstr "捲動到選取的文字。" #: src/input/command.zig:273 msgid "Scroll Page Up" -msgstr "" +msgstr "向上捲動一頁" #: src/input/command.zig:274 msgid "Scroll the screen up by a page." -msgstr "" +msgstr "將螢幕向上捲動一頁。" #: src/input/command.zig:279 msgid "Scroll Page Down" -msgstr "" +msgstr "向下捲動一頁" #: src/input/command.zig:280 msgid "Scroll the screen down by a page." -msgstr "" +msgstr "將螢幕向下捲動一頁。" #: src/input/command.zig:286 msgid "Copy Screen to Temporary File and Copy Path" -msgstr "" +msgstr "將螢幕內容複製到暫存檔並複製路徑" #: src/input/command.zig:287 msgid "" "Copy the screen contents to a temporary file and copy the path to the " "clipboard." -msgstr "" +msgstr "將螢幕內容複製到暫存檔,並將路徑複製到剪貼簿。" #: src/input/command.zig:291 msgid "Copy Screen to Temporary File and Paste Path" -msgstr "" +msgstr "將螢幕內容複製到暫存檔並貼上路徑" #: src/input/command.zig:292 msgid "" "Copy the screen contents to a temporary file and paste the path to the file." -msgstr "" +msgstr "將螢幕內容複製到暫存檔,並貼上該檔案的路徑。" #: src/input/command.zig:296 msgid "Copy Screen to Temporary File and Open" -msgstr "" +msgstr "將螢幕內容複製到暫存檔並開啟" #: src/input/command.zig:297 msgid "Copy the screen contents to a temporary file and open it." -msgstr "" +msgstr "將螢幕內容複製到暫存檔並開啟。" #: src/input/command.zig:305 msgid "Copy Screen as HTML to Temporary File and Copy Path" -msgstr "" +msgstr "將螢幕內容以 HTML 複製到暫存檔並複製路徑" #: src/input/command.zig:306 msgid "" "Copy the screen contents as HTML to a temporary file and copy the path to " "the clipboard." -msgstr "" +msgstr "將螢幕內容以 HTML 複製到暫存檔,並將路徑複製到剪貼簿。" #: src/input/command.zig:313 msgid "Copy Screen as HTML to Temporary File and Paste Path" -msgstr "" +msgstr "將螢幕內容以 HTML 複製到暫存檔並貼上路徑" #: src/input/command.zig:314 msgid "" "Copy the screen contents as HTML to a temporary file and paste the path to " "the file." -msgstr "" +msgstr "將螢幕內容以 HTML 複製到暫存檔,並貼上該檔案的路徑。" #: src/input/command.zig:321 msgid "Copy Screen as HTML to Temporary File and Open" -msgstr "" +msgstr "將螢幕內容以 HTML 複製到暫存檔並開啟" #: src/input/command.zig:322 msgid "Copy the screen contents as HTML to a temporary file and open it." -msgstr "" +msgstr "將螢幕內容以 HTML 複製到暫存檔並開啟。" #: src/input/command.zig:330 msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" -msgstr "" +msgstr "將螢幕內容以 ANSI 跳脫序列複製到暫存檔並複製路徑" #: src/input/command.zig:331 msgid "" "Copy the screen contents as ANSI escape sequences to a temporary file and " "copy the path to the clipboard." -msgstr "" +msgstr "將螢幕內容以 ANSI 跳脫序列複製到暫存檔,並將路徑複製到剪貼簿。" #: src/input/command.zig:338 msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" -msgstr "" +msgstr "將螢幕內容以 ANSI 跳脫序列複製到暫存檔並貼上路徑" #: src/input/command.zig:339 msgid "" "Copy the screen contents as ANSI escape sequences to a temporary file and " "paste the path to the file." -msgstr "" +msgstr "將螢幕內容以 ANSI 跳脫序列複製到暫存檔,並貼上該檔案的路徑。" #: src/input/command.zig:346 msgid "Copy Screen as ANSI Sequences to Temporary File and Open" -msgstr "" +msgstr "將螢幕內容以 ANSI 跳脫序列複製到暫存檔並開啟" #: src/input/command.zig:347 msgid "" "Copy the screen contents as ANSI escape sequences to a temporary file and " "open it." -msgstr "" +msgstr "將螢幕內容以 ANSI 跳脫序列複製到暫存檔並開啟。" #: src/input/command.zig:354 msgid "Copy Selection to Temporary File and Copy Path" -msgstr "" +msgstr "將選取範圍複製到暫存檔並複製路徑" #: src/input/command.zig:355 msgid "" "Copy the selection contents to a temporary file and copy the path to the " "clipboard." -msgstr "" +msgstr "將選取範圍的內容複製到暫存檔,並將路徑複製到剪貼簿。" #: src/input/command.zig:359 msgid "Copy Selection to Temporary File and Paste Path" -msgstr "" +msgstr "將選取範圍複製到暫存檔並貼上路徑" #: src/input/command.zig:360 msgid "" "Copy the selection contents to a temporary file and paste the path to the " "file." -msgstr "" +msgstr "將選取範圍的內容複製到暫存檔,並貼上該檔案的路徑。" #: src/input/command.zig:364 msgid "Copy Selection to Temporary File and Open" -msgstr "" +msgstr "將選取範圍複製到暫存檔並開啟" #: src/input/command.zig:365 msgid "Copy the selection contents to a temporary file and open it." -msgstr "" +msgstr "將選取範圍的內容複製到暫存檔並開啟。" #: src/input/command.zig:373 msgid "Copy Selection as HTML to Temporary File and Copy Path" -msgstr "" +msgstr "將選取範圍以 HTML 複製到暫存檔並複製路徑" #: src/input/command.zig:374 msgid "" "Copy the selection contents as HTML to a temporary file and copy the path to " "the clipboard." -msgstr "" +msgstr "將選取範圍的內容以 HTML 複製到暫存檔,並將路徑複製到剪貼簿。" #: src/input/command.zig:381 msgid "Copy Selection as HTML to Temporary File and Paste Path" -msgstr "" +msgstr "將選取範圍以 HTML 複製到暫存檔並貼上路徑" #: src/input/command.zig:382 msgid "" "Copy the selection contents as HTML to a temporary file and paste the path " "to the file." -msgstr "" +msgstr "將選取範圍的內容以 HTML 複製到暫存檔,並貼上該檔案的路徑。" #: src/input/command.zig:389 msgid "Copy Selection as HTML to Temporary File and Open" -msgstr "" +msgstr "將選取範圍以 HTML 複製到暫存檔並開啟" #: src/input/command.zig:390 msgid "Copy the selection contents as HTML to a temporary file and open it." -msgstr "" +msgstr "將選取範圍的內容以 HTML 複製到暫存檔並開啟。" #: src/input/command.zig:398 msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" -msgstr "" +msgstr "將選取範圍以 ANSI 跳脫序列複製到暫存檔並複製路徑" #: src/input/command.zig:399 msgid "" "Copy the selection contents as ANSI escape sequences to a temporary file and " "copy the path to the clipboard." -msgstr "" +msgstr "將選取範圍的內容以 ANSI 跳脫序列複製到暫存檔,並將路徑複製到剪貼簿。" #: src/input/command.zig:406 msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" -msgstr "" +msgstr "將選取範圍以 ANSI 跳脫序列複製到暫存檔並貼上路徑" #: src/input/command.zig:407 msgid "" "Copy the selection contents as ANSI escape sequences to a temporary file and " "paste the path to the file." -msgstr "" +msgstr "將選取範圍的內容以 ANSI 跳脫序列複製到暫存檔,並貼上該檔案的路徑。" #: src/input/command.zig:414 msgid "Copy Selection as ANSI Sequences to Temporary File and Open" -msgstr "" +msgstr "將選取範圍以 ANSI 跳脫序列複製到暫存檔並開啟" #: src/input/command.zig:415 msgid "" "Copy the selection contents as ANSI escape sequences to a temporary file and " "open it." -msgstr "" +msgstr "將選取範圍的內容以 ANSI 跳脫序列複製到暫存檔並開啟。" #: src/input/command.zig:422 msgid "Open a new window." -msgstr "" +msgstr "開啟新視窗。" #: src/input/command.zig:428 msgid "Open a new tab." -msgstr "" +msgstr "開啟新分頁。" #: src/input/command.zig:434 msgid "Move Tab Left" -msgstr "" +msgstr "向左移動分頁" #: src/input/command.zig:435 msgid "Move the current tab to the left." -msgstr "" +msgstr "將目前的分頁向左移動。" #: src/input/command.zig:439 msgid "Move Tab Right" -msgstr "" +msgstr "向右移動分頁" #: src/input/command.zig:440 msgid "Move the current tab to the right." -msgstr "" +msgstr "將目前的分頁向右移動。" #: src/input/command.zig:446 msgid "Move Tab to New Window" -msgstr "" +msgstr "將分頁移至新視窗" #: src/input/command.zig:447 msgid "Move the current tab to a new window." -msgstr "" +msgstr "將目前的分頁移至新視窗。" #: src/input/command.zig:452 msgid "Toggle Tab Overview" -msgstr "" +msgstr "切換分頁總覽" #: src/input/command.zig:453 msgid "Toggle the tab overview." -msgstr "" +msgstr "切換分頁總覽。" #: src/input/command.zig:458 msgid "Change Terminal Title…" -msgstr "" +msgstr "變更終端機標題…" #: src/input/command.zig:459 msgid "Prompt for a new title for the current terminal." -msgstr "" +msgstr "提示輸入目前終端機的新標題。" #: src/input/command.zig:465 msgid "Prompt for a new title for the current tab." -msgstr "" +msgstr "提示輸入目前分頁的新標題。" #: src/input/command.zig:478 msgid "Split the terminal to the left." -msgstr "" +msgstr "向左分割終端機。" #: src/input/command.zig:483 msgid "Split the terminal to the right." -msgstr "" +msgstr "向右分割終端機。" #: src/input/command.zig:488 msgid "Split the terminal up." -msgstr "" +msgstr "向上分割終端機。" #: src/input/command.zig:493 msgid "Split the terminal down." -msgstr "" +msgstr "向下分割終端機。" #: src/input/command.zig:500 msgid "Focus Split: Previous" -msgstr "" +msgstr "聚焦窗格:上一個" #: src/input/command.zig:501 msgid "Focus the previous split, if any." -msgstr "" +msgstr "聚焦上一個窗格(若有)。" #: src/input/command.zig:505 msgid "Focus Split: Next" -msgstr "" +msgstr "聚焦窗格:下一個" #: src/input/command.zig:506 msgid "Focus the next split, if any." -msgstr "" +msgstr "聚焦下一個窗格(若有)。" #: src/input/command.zig:510 msgid "Focus Split: Left" -msgstr "" +msgstr "聚焦窗格:左" #: src/input/command.zig:511 msgid "Focus the split to the left, if it exists." -msgstr "" +msgstr "聚焦左側的窗格(若存在)。" #: src/input/command.zig:515 msgid "Focus Split: Right" -msgstr "" +msgstr "聚焦窗格:右" #: src/input/command.zig:516 msgid "Focus the split to the right, if it exists." -msgstr "" +msgstr "聚焦右側的窗格(若存在)。" #: src/input/command.zig:520 msgid "Focus Split: Up" -msgstr "" +msgstr "聚焦窗格:上" #: src/input/command.zig:521 msgid "Focus the split above, if it exists." -msgstr "" +msgstr "聚焦上方的窗格(若存在)。" #: src/input/command.zig:525 msgid "Focus Split: Down" -msgstr "" +msgstr "聚焦窗格:下" #: src/input/command.zig:526 msgid "Focus the split below, if it exists." -msgstr "" +msgstr "聚焦下方的窗格(若存在)。" #: src/input/command.zig:533 msgid "Focus Window: Previous" -msgstr "" +msgstr "聚焦視窗:上一個" #: src/input/command.zig:534 msgid "Focus the previous window, if any." -msgstr "" +msgstr "聚焦上一個視窗(若有)。" #: src/input/command.zig:538 msgid "Focus Window: Next" -msgstr "" +msgstr "聚焦視窗:下一個" #: src/input/command.zig:539 msgid "Focus the next window, if any." -msgstr "" +msgstr "聚焦下一個視窗(若有)。" #: src/input/command.zig:545 msgid "Toggle Split Zoom" -msgstr "" +msgstr "切換窗格縮放" #: src/input/command.zig:546 msgid "Toggle the zoom state of the current split." -msgstr "" +msgstr "切換目前窗格的縮放狀態。" #: src/input/command.zig:551 msgid "Toggle Read-Only Mode" -msgstr "" +msgstr "切換唯讀模式" #: src/input/command.zig:552 msgid "Toggle read-only mode for the current surface." -msgstr "" +msgstr "切換目前終端機的唯讀模式。" #: src/input/command.zig:557 msgid "Equalize Splits" -msgstr "" +msgstr "均分窗格" #: src/input/command.zig:558 msgid "Equalize the size of all splits." -msgstr "" +msgstr "將所有窗格的大小均分。" #: src/input/command.zig:563 msgid "Reset Window Size" -msgstr "" +msgstr "重設視窗大小" #: src/input/command.zig:564 msgid "Reset the window size to the default." -msgstr "" +msgstr "將視窗大小重設為預設值。" #: src/input/command.zig:569 msgid "Toggle Inspector" -msgstr "" +msgstr "切換檢查工具" #: src/input/command.zig:570 msgid "Toggle the inspector." -msgstr "" +msgstr "切換檢查工具。" #: src/input/command.zig:575 msgid "Show the GTK Inspector" -msgstr "" +msgstr "顯示 GTK 檢查工具" #: src/input/command.zig:576 msgid "Show the GTK inspector." -msgstr "" +msgstr "顯示 GTK 檢查工具。" #: src/input/command.zig:581 msgid "Show On-Screen Keyboard" -msgstr "" +msgstr "顯示螢幕鍵盤" #: src/input/command.zig:582 msgid "Show the on-screen keyboard if present." -msgstr "" +msgstr "顯示螢幕鍵盤(若存在)。" #: src/input/command.zig:588 msgid "Open Config Using OS editor" -msgstr "" +msgstr "使用作業系統編輯器開啟設定" #: src/input/command.zig:589 msgid "Open the config file with the OS's default editor." -msgstr "" +msgstr "使用作業系統的預設編輯器開啟設定檔。" #: src/input/command.zig:593 msgid "Open Config in New Terminal Window" -msgstr "" +msgstr "在新終端機視窗中開啟設定" #: src/input/command.zig:594 msgid "Open the config file in a new window using $EDITOR or $VISUAL." -msgstr "" +msgstr "使用 $EDITOR 或 $VISUAL 在新視窗中開啟設定檔。" #: src/input/command.zig:600 msgid "Reload Config" -msgstr "" +msgstr "重新載入設定" #: src/input/command.zig:601 msgid "Reload the config file." -msgstr "" +msgstr "重新載入設定檔。" #: src/input/command.zig:606 msgid "Close Terminal" -msgstr "" +msgstr "關閉終端機" #: src/input/command.zig:607 msgid "Close the current terminal." -msgstr "" +msgstr "關閉目前終端機。" #: src/input/command.zig:614 msgid "Close the current tab." -msgstr "" +msgstr "關閉目前分頁。" #: src/input/command.zig:618 msgid "Close Other Tabs" -msgstr "" +msgstr "關閉其他分頁" #: src/input/command.zig:619 msgid "Close all tabs in this window except the current one." -msgstr "" +msgstr "關閉此視窗中目前分頁以外的所有分頁。" #: src/input/command.zig:623 msgid "Close Tabs to the Right" -msgstr "" +msgstr "關閉右側的分頁" #: src/input/command.zig:624 msgid "Close all tabs to the right of the current one." -msgstr "" +msgstr "關閉目前分頁右側的所有分頁。" #: src/input/command.zig:631 msgid "Close the current window." -msgstr "" +msgstr "關閉目前視窗。" #: src/input/command.zig:636 msgid "Close All Windows" -msgstr "" +msgstr "關閉所有視窗" #: src/input/command.zig:637 msgid "Close all windows." -msgstr "" +msgstr "關閉所有視窗。" #: src/input/command.zig:642 msgid "Toggle Maximize" -msgstr "" +msgstr "切換最大化" #: src/input/command.zig:643 msgid "Toggle the maximized state of the current window." -msgstr "" +msgstr "切換目前視窗的最大化狀態。" #: src/input/command.zig:648 msgid "Toggle Fullscreen" -msgstr "" +msgstr "切換全螢幕" #: src/input/command.zig:649 msgid "Toggle the fullscreen state of the current window." -msgstr "" +msgstr "切換目前視窗的全螢幕狀態。" #: src/input/command.zig:654 msgid "Toggle Window Decorations" -msgstr "" +msgstr "切換視窗裝飾" #: src/input/command.zig:655 msgid "Toggle the window decorations." -msgstr "" +msgstr "切換視窗裝飾。" #: src/input/command.zig:660 msgid "Toggle Float on Top" -msgstr "" +msgstr "切換置頂" #: src/input/command.zig:661 msgid "Toggle the float on top state of the current window." -msgstr "" +msgstr "切換目前視窗的置頂狀態。" #: src/input/command.zig:666 msgid "Toggle Secure Input" -msgstr "" +msgstr "切換安全輸入" #: src/input/command.zig:667 msgid "Toggle secure input mode." -msgstr "" +msgstr "切換安全輸入模式。" #: src/input/command.zig:672 msgid "Toggle Mouse Reporting" -msgstr "" +msgstr "切換滑鼠回報" #: src/input/command.zig:673 msgid "Toggle whether mouse events are reported to terminal applications." -msgstr "" +msgstr "切換是否將滑鼠事件回報給終端機應用程式。" #: src/input/command.zig:678 msgid "Toggle Background Opacity" -msgstr "" +msgstr "切換背景不透明度" #: src/input/command.zig:679 msgid "Toggle the background opacity of a window that started transparent." -msgstr "" +msgstr "切換以透明狀態啟動之視窗的背景不透明度。" #: src/input/command.zig:684 msgid "Check for Updates" -msgstr "" +msgstr "檢查更新" #: src/input/command.zig:685 msgid "Check for updates to the application." -msgstr "" +msgstr "檢查應用程式是否有更新。" #: src/input/command.zig:690 msgid "Undo" -msgstr "" +msgstr "復原" #: src/input/command.zig:691 msgid "Undo the last action." -msgstr "" +msgstr "復原上一個動作。" #: src/input/command.zig:696 msgid "Redo" -msgstr "" +msgstr "重做" #: src/input/command.zig:697 msgid "Redo the last undone action." -msgstr "" +msgstr "重做上一個已復原的動作。" #: src/input/command.zig:703 msgid "Quit the application." -msgstr "" +msgstr "結束應用程式。" #: src/input/command.zig:708 msgid "Ghostty" -msgstr "" +msgstr "Ghostty" #: src/input/command.zig:709 msgid "Put a little Ghostty in your terminal." -msgstr "" +msgstr "在您的終端機裡放一隻小小的 Ghostty。" diff --git a/src/build/GhosttyZig.zig b/src/build/GhosttyZig.zig index 4671219e0..46ab05d56 100644 --- a/src/build/GhosttyZig.zig +++ b/src/build/GhosttyZig.zig @@ -137,6 +137,20 @@ fn initVt( // We need uucode for grapheme break support vt.addImport("uucode", deps.uucode_mod); + // We need wuffs for Kitty graphics pixel operations (format + // conversion and alpha blending). Unlike pure Zig dependencies + // its C code is compiled whenever the module is in the build + // graph regardless of analysis, so only wire it in when Kitty + // graphics is actually enabled. + if (vt_options.kittyGraphics(cfg.target.result)) { + if (b.lazyDependency("wuffs", .{ + .target = cfg.target, + .optimize = cfg.optimize, + })) |dep| { + vt.addImport("wuffs", dep.module("wuffs")); + } + } + // If SIMD is enabled, add all our SIMD dependencies. if (cfg.simd) { try SharedDeps.addSimd(b, vt, simd_libs); diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index e3e2ceccf..8fd746375 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -18,7 +18,6 @@ const App = @import("../App.zig"); const Allocator = std.mem.Allocator; const log = std.log.scoped(.renderer_thread); -const DRAW_INTERVAL = 8; // 120 FPS const CURSOR_BLINK_INTERVAL = 600; /// Whether calls to `drawFrame` must be done from the app thread. @@ -53,16 +52,15 @@ wakeup_c: xev.Completion = .{}, stop: xev.Async, stop_c: xev.Completion = .{}, -/// The timer used for rendering +/// The timer used for animations (custom shaders, Kitty graphics). +/// Normal rendering is driven by wakeup messages instead. render_h: xev.Timer, render_c: xev.Completion = .{}, +render_c_cancel: xev.Completion = .{}, -/// The timer used for draw calls. Draw calls don't update from the -/// terminal state so they're much cheaper. They're used for animation -/// and are paused when the terminal is not focused. -draw_h: xev.Timer, -draw_c: xev.Completion = .{}, -draw_active: bool = false, +/// The kind of work the currently scheduled animation wake needs, +/// stored when the timer is armed. +animation_wake: rendererpkg.Renderer.AnimationWake.Kind = .draw, /// This async is used to force a draw immediately. This does not /// coalesce like the wakeup does. @@ -115,12 +113,10 @@ flags: packed struct { } = .{}, pub const DerivedConfig = struct { - custom_shader_animation: configpkg.CustomShaderAnimation, scrollback_compression: bool, pub fn init(config: *const configpkg.Config) DerivedConfig { return .{ - .custom_shader_animation = config.@"custom-shader-animation", .scrollback_compression = config.@"scrollback-compression", }; } @@ -153,10 +149,6 @@ pub fn init( var render_h = try xev.Timer.init(); errdefer render_h.deinit(); - // Draw timer, see comments. - var draw_h = try xev.Timer.init(); - errdefer draw_h.deinit(); - // Draw now async, see comments. var draw_now = try xev.Async.init(); errdefer draw_now.deinit(); @@ -176,7 +168,6 @@ pub fn init( .wakeup = wakeup_h, .stop = stop_h, .render_h = render_h, - .draw_h = draw_h, .draw_now = draw_now, .cursor_h = cursor_timer, .surface = surface, @@ -201,7 +192,6 @@ pub fn deinit(self: *Thread) void { self.stop.deinit(); self.wakeup.deinit(); self.render_h.deinit(); - self.draw_h.deinit(); self.draw_now.deinit(); self.cursor_h.deinit(); if (comptime terminalpkg.compression_enabled) @@ -270,8 +260,9 @@ fn threadMain_(self: *Thread) !void { cursorTimerCallback, ); - // Start the draw timer - self.syncDrawTimer(); + // Arm the animation timer in case the renderer already needs + // animation wakes (e.g. custom shaders loaded at startup). + self.armAnimationTimer(); // Run log.debug("starting renderer thread", .{}); @@ -309,47 +300,6 @@ fn setQosClass(self: *const Thread) void { } } -fn syncDrawTimer(self: *Thread) void { - skip: { - // If our renderer supports animations and has them, then we - // can apply draw timer based on custom shader animation configuration. - if (@hasDecl(rendererpkg.Renderer, "hasAnimations") and - self.renderer.hasAnimations()) - { - // If our config says to always animate, we do so. - switch (self.config.custom_shader_animation) { - // Always animate - .always => break :skip, - // Only when focused - .true => if (self.flags.focused) break :skip, - // Never animate - .false => {}, - } - } - - // We're skipping the draw timer. Stop it on the next iteration. - self.draw_active = false; - return; - } - - // Set our active state so it knows we're running. We set this before - // even checking the active state in case we have a pending shutdown. - self.draw_active = true; - - // If our draw timer is already active, then we don't have to do anything. - if (self.draw_c.state() == .active) return; - - // Start the timer which loops - self.draw_h.run( - &self.loop, - &self.draw_c, - DRAW_INTERVAL, - Thread, - self, - drawCallback, - ); -} - /// Drain the mailbox. fn drainMailbox(self: *Thread) !void { // There's probably a more elegant way to do this... @@ -378,15 +328,11 @@ fn drainMailbox(self: *Thread) !void { self.setQosClass(); // If we became visible then we immediately rebuild cells - // (renderCallback skips updateFrame while invisible) and draw. - if (v) { - self.renderer.updateFrame( - self.state, - self.flags.cursor_blink_visible, - ) catch |err| - log.warn("error rendering on visibility regain err={}", .{err}); - self.drawFrame(false); - } + // (renderCallback skips updateFrame while invisible) and + // draw. Going through renderCallback also reschedules + // any Kitty graphics animation wakeup that lapsed + // while we were invisible. + if (v) _ = renderCallback(self, undefined, undefined, {}); // Notify the renderer so it can update any state. self.renderer.setVisible(v); @@ -412,8 +358,9 @@ fn drainMailbox(self: *Thread) !void { // Set it on the renderer try self.renderer.setFocus(v); - // We always resync our draw timer (may disable it) - self.syncDrawTimer(); + // Focus gates custom shader animation, so re-arm + // the animation timer for the new state. + self.armAnimationTimer(); if (!v) { // If we're not focused, then we stop the cursor blink @@ -474,9 +421,9 @@ fn drainMailbox(self: *Thread) !void { try self.changeConfig(config.thread); try self.renderer.changeConfig(config.impl); - // Stop and start the draw timer to capture the new - // hasAnimations value. - self.syncDrawTimer(); + // The config affects what animation wakes the + // renderer needs (custom shaders, animation mode). + self.armAnimationTimer(); }, .search_viewport_matches => |v| { @@ -606,37 +553,19 @@ fn drawNowCallback( return .rearm; } -fn drawCallback( - self_: ?*Thread, - _: *xev.Loop, - _: *xev.Completion, - r: xev.Timer.RunError!void, -) xev.CallbackAction { - _ = r catch unreachable; - const t: *Thread = self_ orelse { - // This shouldn't happen so we log it. - log.warn("render callback fired without data set", .{}); - return .disarm; - }; - - // Draw - t.drawFrame(false); - - // Only continue if we're still active - if (t.draw_active) { - t.draw_h.run(&t.loop, &t.draw_c, DRAW_INTERVAL, Thread, t, drawCallback); - } - - return .disarm; -} - fn renderCallback( self_: ?*Thread, _: *xev.Loop, _: *xev.Completion, r: xev.Timer.RunError!void, ) xev.CallbackAction { - _ = r catch unreachable; + _ = r catch |err| switch (err) { + // Sent when a scheduled animation wakeup is superseded by a + // newer one (Timer.reset cancels the pending run). Nothing to + // do; the replacement timer carries on. + error.Canceled => return .disarm, + else => unreachable, + }; const t: *Thread = self_ orelse { // This shouldn't happen so we log it. log.warn("render callback fired without data set", .{}); @@ -645,6 +574,7 @@ fn renderCallback( // If we're not visible there's no point spending CPU rebuilding cells — // we'll catch up when the .visible mailbox message flips us back on. + // Kitty graphics animations pause with us and resume on visibility. if (!t.flags.visible) return .disarm; // Update our frame data @@ -657,9 +587,78 @@ fn renderCallback( // Draw t.drawFrame(false); + // Schedule the next animation wake, if the renderer needs one. + t.armAnimationTimer(); + return .disarm; } +/// Schedule the animation timer for the renderer's next animation +/// wake, if it needs one. +/// +/// This is called after every frame update or animation draw and +/// whenever the wake inputs change (focus, config, visibility +/// regain). Resetting a pending timer is always safe: every call +/// recomputes the wake, so the deadline only ever moves toward the +/// actual next wake. +fn armAnimationTimer(self: *Thread) void { + const wake = self.renderer.animationWake() orelse return; + self.animation_wake = wake.kind; + self.render_h.reset( + &self.loop, + &self.render_c, + &self.render_c_cancel, + wake.delay_ms, + Thread, + self, + animationTimerCallback, + ); +} + +fn animationTimerCallback( + self_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Timer.RunError!void, +) xev.CallbackAction { + _ = r catch |err| switch (err) { + // Sent when a scheduled animation wake is superseded by a + // newer one (Timer.reset cancels the pending run). Nothing to + // do; the replacement timer carries on. + error.Canceled => return .disarm, + else => unreachable, + }; + const t: *Thread = self_ orelse { + // This shouldn't happen so we log it. + log.warn("animation callback fired without data set", .{}); + return .disarm; + }; + + // Animations pause entirely while we're invisible; the .visible + // mailbox message re-arms us when we can be seen again. + if (!t.flags.visible) return .disarm; + + switch (t.animation_wake) { + // Frame data must be updated (a Kitty animation frame is + // due). renderCallback updates, draws, and re-arms us. + .update => return renderCallback( + t, + undefined, + undefined, + {}, + ), + + // A redraw alone suffices (custom shader time uniform). + // Draw calls don't update from the terminal state so they + // are much cheaper than a frame update. + .draw => { + t.drawFrame(false); + t.armAnimationTimer(); + return .disarm; + }, + } +} + fn cursorTimerCallback( self_: ?*Thread, _: *xev.Loop, diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index cd62a7bbd..c09f2cb4d 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -236,6 +236,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// Our overlay state, if any. overlay: ?Overlay = null, + /// The base timestamp for the Kitty graphics animation clock. + /// Animation frame timing is expressed as milliseconds since + /// this instant. Set on the first frame update that observes + /// Kitty images. + kitty_animation_clock: ?std.Io.Timestamp = null, + + /// When the next Kitty animation frame is due, in + /// milliseconds on the animation clock, from the most recent + /// frame update. Null when no running animation needs a + /// wakeup. + kitty_animation_next_ms: ?u64 = null, + const HighlightTag = enum(u8) { search_match, search_match_selected, @@ -577,6 +589,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { blending: configpkg.Config.AlphaBlending, background_blur: configpkg.Config.BackgroundBlur, scroll_to_bottom_on_output: bool, + custom_shader_animation: configpkg.CustomShaderAnimation, pub fn init( alloc_gpa: Allocator, @@ -651,6 +664,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .blending = config.@"alpha-blending", .background_blur = config.@"background-blur", .scroll_to_bottom_on_output = config.@"scroll-to-bottom".output, + .custom_shader_animation = config.@"custom-shader-animation", .arena = arena, }; } @@ -997,10 +1011,74 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.syncDisplayLink(id, draw_now); } - /// True if our renderer has animations so that a higher frequency - /// timer is used. - pub fn hasAnimations(self: *const Self) bool { - return self.has_custom_shaders; + /// The cadence of continuous (draw-only) animation wakes, + /// i.e. 120fps, and the floor for any animation wake delay. + pub const draw_interval_ms: u64 = 8; + + /// A point in the future when the renderer needs to be driven + /// again to keep animating, and what kind of drive it needs. + pub const AnimationWake = struct { + /// Delay in milliseconds until the wake is due. + delay_ms: u64, + kind: Kind, + + pub const Kind = enum { + /// A redraw alone suffices, no updateFrame. Much cheaper + /// than `update`. + draw, + + /// Frame data must be updated first: updateFrame, then draw. + update, + }; + }; + + /// The soonest animation wake this renderer needs, if any: + /// custom shader animation wants continuous draw-only wakes + /// at draw_interval_ms while active, and a running Kitty + /// graphics animation wants an update wake when its next + /// frame is due. The renderer thread drives its animation + /// timer off this, re-querying after every wake. + /// + /// Must be called on the render thread. + pub fn animationWake(self: *const Self) ?AnimationWake { + // Custom shaders animate by redrawing on a fixed cadence, + // gated by configuration and focus. + const shader_delay: ?u64 = shader: { + if (!self.has_custom_shaders) break :shader null; + break :shader switch (self.config.custom_shader_animation) { + .false => null, + .always => draw_interval_ms, + .true => if (self.focused) draw_interval_ms else null, + }; + }; + + // Kitty animations tick during updateFrame; between + // updates the deadline is absolute on the animation + // clock, so a stream of draw wakes recomputing this + // cannot starve it into the future. + const kitty_delay: ?u64 = kitty: { + const next = self.kitty_animation_next_ms orelse break :kitty null; + const base = self.kitty_animation_clock orelse break :kitty null; + const now: std.Io.Timestamp = .now(global.io(), .awake); + const now_ms: u64 = @intCast(@divTrunc( + base.durationTo(now).nanoseconds, + std.time.ns_per_ms, + )); + // Never wake faster than the draw interval; an + // overdue frame is picked up on the next wake. + break :kitty @max(next -| now_ms, draw_interval_ms); + }; + + // An update wake includes a draw, so it wins ties. + if (kitty_delay) |k| { + if (shader_delay == null or k <= shader_delay.?) { + return .{ .delay_ms = k, .kind = .update }; + } + } + + if (shader_delay) |s| return .{ .delay_ms = s, .kind = .draw }; + + return null; } /// True if our renderer is using vsync. If true, the renderer or apprt @@ -1248,6 +1326,33 @@ pub fn Renderer(comptime GraphicsAPI: type) type { break :preedit try p.clone(arena_alloc); }; + // Advance any running Kitty graphics animations to the + // frame due now, and remember when the next frame is + // due (as an absolute deadline, see animationWake) so + // the renderer thread can schedule a wakeup for it. + // This must happen before the dirty check below: + // advancing a frame marks the image state dirty. + self.kitty_animation_next_ms = next: { + // Likely case: we have no kitty images, so do nothing. + const storage = &state.terminal.screens.active.kitty_images; + if (storage.images.count() == 0) break :next null; + + const now: std.Io.Timestamp = .now(global.io(), .awake); + const base = self.kitty_animation_clock orelse base: { + self.kitty_animation_clock = now; + break :base now; + }; + const now_ms: u64 = @intCast(@divTrunc( + base.durationTo(now).nanoseconds, + std.time.ns_per_ms, + )); + const delay = storage.animationTick( + global.io(), + now_ms, + ) orelse break :next null; + break :next now_ms + delay; + }; + // If we have Kitty graphics data, we enter a SLOW SLOW SLOW path. // We only do this if the Kitty image state is dirty meaning only if // it changes. @@ -1492,10 +1597,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Conditions under which we need to draw the frame, otherwise we // don't need to since the previous frame should be identical. + // + // While any animation is in progress (a pending animation wake) + // every draw must actually render. const needs_redraw = size_changed or self.cells_rebuilt or - self.hasAnimations() or + self.animationWake() != null or sync; if (!needs_redraw) { diff --git a/src/renderer/image.zig b/src/renderer/image.zig index bf8a606d5..d5b470332 100644 --- a/src/renderer/image.zig +++ b/src/renderer/image.zig @@ -769,7 +769,10 @@ pub const State = struct { alloc: Allocator, image: *const terminal.kitty.graphics.Image, ) PrepImageError!void { - const data = image.data.bytes() orelse unreachable; + // For animated images this is the current animation frame; + // the image generation changes whenever the current frame + // does, so the upload cache stays coherent. + const data = image.renderData().bytes() orelse unreachable; try self.prepImage( alloc, .{ .kitty = image.id }, @@ -1439,3 +1442,66 @@ test "kitty renderer positions relative placements from virtual parent placehold try testing.expectEqual(@as(i32, 2), child.x); try testing.expectEqual(@as(i32, 3), child.y); } + +test "kitty renderer uploads the current animation frame" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + t.width_px = 30; + t.height_px = 30; + + var state: State = .empty; + defer state.deinit(alloc); + + const storage = &t.screens.active.kitty_images; + try storage.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 1, + .height = 1, + .format = .rgba, + .data = .{ .complete = try alloc.dupe(u8, &.{ 255, 0, 0, 255 }) }, + }); + const pin = try t.screens.active.pages.trackPin( + t.screens.active.cursor.page_pin.*, + ); + try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = pin }, + .columns = 1, + .rows = 1, + }); + + state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 }); + const gen1 = state.images.get(.{ .kitty = 1 }).?.generation; + try testing.expectEqualSlices( + u8, + &.{ 255, 0, 0, 255 }, + state.images.get(.{ .kitty = 1 }).?.image.pending.dataSlice(), + ); + + // Attach an animation and make its extra frame current, the way + // an animation tick would. + const img = storage.images.getPtr(1).?; + const anim = try alloc.create(terminal.kitty.graphics.Animation); + anim.* = .{}; + img.animation = anim; + try anim.frames.append(alloc, .{ + .data = try alloc.dupe(u8, &.{ 0, 0, 255, 255 }), + .gap_ms = 40, + }); + anim.current_index = 1; + storage.markImageContentChanged(io, img); + + // The renderer must pick up the frame's pixels under a fresh + // generation. + state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 }); + const entry = state.images.get(.{ .kitty = 1 }).?; + try testing.expect(entry.generation > gen1); + try testing.expectEqualSlices( + u8, + &.{ 0, 0, 255, 255 }, + entry.image.pending.dataSlice(), + ); +} diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index a0333ee2f..9c89b02b1 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -417,8 +417,14 @@ pub fn reset(self: *Screen) void { if (comptime build_options.kitty_graphics) { // Reset kitty graphics storage + const image_limits = self.kitty_images.image_limits; + const total_limit = self.kitty_images.total_limit; self.kitty_images.deinit(self.alloc, self); - self.kitty_images = .{ .dirty = true }; + self.kitty_images = .{ + .dirty = true, + .image_limits = image_limits, + .total_limit = total_limit, + }; } // Reset our basic state diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index ef8c2586c..21c087446 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -15406,6 +15406,34 @@ test "Terminal: fullReset status display" { try testing.expect(t.status_display == .main); } +test "Terminal: fullReset preserves kitty graphics limits" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + const alloc = testing.allocator; + const temp_dir = "/tmp/ghostty-kitty-images"; + + var t = try init(testing.io, alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + t.setKittyGraphicsLoadingLimits(.allWithTempDir(temp_dir)); + for ([_]usize{ 1234, 0 }) |total_limit| { + t.setKittyGraphicsSizeLimit(alloc, total_limit); + t.fullReset(); + + const storage = &t.screens.active.kitty_images; + try testing.expectEqual(total_limit, storage.total_limit); + try testing.expect(storage.image_limits.file); + try testing.expect(storage.image_limits.shared_memory); + switch (storage.image_limits.temporary_file) { + .enabled => |value| try testing.expectEqualStrings( + temp_dir, + value.directory, + ), + .disabled => return error.TestUnexpectedResult, + } + } +} + // https://github.com/mitchellh/ghostty/issues/1607 test "Terminal: fullReset clears alt screen kitty keyboard state" { var t = try init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); diff --git a/src/terminal/build_options.zig b/src/terminal/build_options.zig index 124929d28..725fccfd0 100644 --- a/src/terminal/build_options.zig +++ b/src/terminal/build_options.zig @@ -257,6 +257,17 @@ pub const Options = struct { } }; + /// Whether the Kitty graphics feature is effectively enabled for + /// the given target. Kitty graphics requires the ability to get + /// timestamps and there is no way to do that on freestanding + /// targets, so it is always disabled there regardless of the + /// feature setting. + pub fn kittyGraphics(self: Options, target: std.Target) bool { + if (target.cpu.arch == .wasm32 and target.os.tag == .freestanding) + return false; + return self.features.kitty_graphics; + } + /// Add the required build options for the terminal module. /// /// The memory referenced by self is expected to stick around (it isn't @@ -281,12 +292,10 @@ pub const Options = struct { inline for (@typeInfo(Features).@"struct".fields) |field| { var value = @field(self.features, field.name); - // Kitty graphics requires the ability to get timestamps and - // there is no way to do that on freestanding targets, so it - // is always disabled there regardless of the feature setting. + // Kitty graphics is force-disabled on some targets; see + // kittyGraphics for details. if (comptime std.mem.eql(u8, field.name, "kitty_graphics")) { - if (target.cpu.arch == .wasm32 and target.os.tag == .freestanding) - value = false; + value = self.kittyGraphics(target); } opts.addOption(bool, field.name, value); diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index a509d23cf..2bd5e8620 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -262,8 +262,11 @@ fn imageGetTyped( .height => out.* = image.height, .format => out.* = image.format, .compression => out.* = image.compression, - .data_ptr => out.* = (image.data.bytes() orelse return .no_value).ptr, - .data_len => out.* = image.data.len(), + // For animated images this is the current animation frame's + // data; the image generation changes whenever the current + // frame does, so generation-keyed caches stay coherent. + .data_ptr => out.* = (image.renderData().bytes() orelse return .no_value).ptr, + .data_len => out.* = image.renderData().len(), .generation => out.* = image.generation, } diff --git a/src/terminal/kitty/graphics.zig b/src/terminal/kitty/graphics.zig index 1f639fab8..ef45ee8d0 100644 --- a/src/terminal/kitty/graphics.zig +++ b/src/terminal/kitty/graphics.zig @@ -3,11 +3,6 @@ //! Documentation: //! https://sw.kovidgoyal.net/kitty/graphics-protocol //! -//! Unimplemented features that are still todo: -//! - shared memory transmit -//! - virtual placement w/ unicode -//! - animation -//! //! Performance: //! The performance of this particular subsystem of Ghostty is not great. //! We can avoid a lot more allocations, we can replace some C code (which @@ -21,7 +16,10 @@ const command = @import("graphics_command.zig"); const exec = @import("graphics_exec.zig"); const image = @import("graphics_image.zig"); const storage = @import("graphics_storage.zig"); +pub const animation = @import("graphics_animation.zig"); +pub const pixel = @import("graphics_pixel.zig"); pub const unicode = @import("graphics_unicode.zig"); +pub const Animation = animation.Animation; pub const Command = command.Command; pub const CommandParser = command.Parser; pub const Image = image.Image; diff --git a/src/terminal/kitty/graphics_animation.zig b/src/terminal/kitty/graphics_animation.zig new file mode 100644 index 000000000..0822a451c --- /dev/null +++ b/src/terminal/kitty/graphics_animation.zig @@ -0,0 +1,149 @@ +//! Kitty graphics protocol animation support. +//! +//! https://sw.kovidgoyal.net/kitty/graphics-protocol/#animation +//! +//! An animation is a set of frames attached to an existing image. +//! Frame 1 (the "root frame") is the image's own base data. Frames +//! 2..N are stored here. Unlike Kitty, which stores frames as delta +//! rectangles in a disk cache and composes them lazily, we compose +//! every frame eagerly into a full image-sized RGBA buffer at load +//! time. This trades some memory for a much simpler model: a frame is +//! always ready to display and there are no reference chains to +//! maintain, coalesce, or garbage collect. Frame storage is bounded +//! by the same byte limit as image storage. +//! +//! To keep composition to a single pixel format, an image is +//! converted to RGBA the first time an animation command composes +//! into it, and all transmitted frame data is converted to RGBA +//! before composition. Blending an opaque (alpha=255) source pixel +//! degenerates to a copy, so this loses no information relative to +//! Kitty's separate RGB/RGBA paths. The pixel-level primitives +//! (conversion, fill, rectangle composition) live in +//! graphics_pixel.zig. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// The gap assigned to a newly created frame when the command doesn't +/// specify one (z omitted or z=0). Taken from Kitty (DEFAULT_GAP). +pub const default_gap_ms: u32 = 40; + +/// Animation state for a single image. Heap-allocated and owned by +/// the Image. This is created lazily by the first animation command that +/// needs it. +pub const Animation = struct { + /// The frames following the root frame: `frames.items[i]` is + /// protocol frame number `i + 2`. The root frame (frame 1) is the + /// image's base data and is not stored here. + frames: std.ArrayListUnmanaged(Frame) = .empty, + + /// The gap of the root frame in milliseconds. Zero means gapless: + /// the frame is skipped during playback. Kitty creates the root + /// frame with a zero gap; the client gives it one with a=a,r=1,z=N. + root_gap_ms: u32 = 0, + + /// The zero-based index of the frame currently displayed: 0 is + /// the root frame, i >= 1 is frames.items[i - 1]. + current_index: u32 = 0, + + /// Playback state. Every image starts stopped (client-driven). + state: State = .stopped, + + /// Maximum number of loops to play (0 = infinite). Set from the + /// a=a v key as v-1, per the protocol's off-by-one encoding. + max_loops: u32 = 0, + + /// Number of completed loops since playback started. + current_loop: u32 = 0, + + /// Timestamp in milliseconds (on the ticker's clock, see + /// ImageStorage.animationTick) when the current frame was shown. + /// Null means "not yet shown"; the next tick stamps it. + frame_shown_at_ms: ?u64 = null, + + pub const State = enum { + /// Not advancing; frames change only via a=a c=N (client-driven). + stopped, + /// Advancing, but playback parks on the last frame waiting + /// for more frames instead of looping (a=a s=2). + loading, + /// Advancing and looping (a=a s=3). + running, + }; + + pub const Frame = struct { + /// Fully composed pixel data, always image width * height * 4 + /// bytes of RGBA. + data: []u8, + + /// Milliseconds this frame is displayed before advancing. + /// Zero means gapless: skipped during playback. + gap_ms: u32, + }; + + pub fn deinit(self: *Animation, alloc: Allocator) void { + for (self.frames.items) |frame| alloc.free(frame.data); + self.frames.deinit(alloc); + } + + /// The total number of frames including the root frame. + pub fn frameCount(self: *const Animation) u32 { + return @intCast(self.frames.items.len + 1); + } + + /// The gap of the frame at the given zero-based index. + pub fn gapAt(self: *const Animation, index: u32) u32 { + if (index == 0) return self.root_gap_ms; + return self.frames.items[index - 1].gap_ms; + } + + /// Set the gap of the frame at the given zero-based index. + pub fn setGapAt(self: *Animation, index: u32, gap_ms: u32) void { + if (index == 0) { + self.root_gap_ms = gap_ms; + } else { + self.frames.items[index - 1].gap_ms = gap_ms; + } + } + + /// The sum of all frame gaps. An animation with a zero duration + /// (every frame gapless) never advances; this doubles as the + /// guard that keeps the gapless-skip loop in animationTick from + /// spinning forever, exactly like Kitty's animation_duration. + pub fn durationMs(self: *const Animation) u64 { + var total: u64 = self.root_gap_ms; + for (self.frames.items) |frame| total += frame.gap_ms; + return total; + } + + /// Bytes of frame data held by this animation, counted against + /// the image storage limit. + pub fn frameBytes(self: *const Animation) usize { + var total: usize = 0; + for (self.frames.items) |frame| total += frame.data.len; + return total; + } +}; + +test "animation gap helpers" { + const testing = std.testing; + const alloc = testing.allocator; + + var anim: Animation = .{}; + defer anim.deinit(alloc); + try anim.frames.append(alloc, .{ + .data = try alloc.alloc(u8, 4), + .gap_ms = 100, + }); + + try testing.expectEqual(@as(u32, 2), anim.frameCount()); + try testing.expectEqual(@as(u32, 0), anim.gapAt(0)); + try testing.expectEqual(@as(u32, 100), anim.gapAt(1)); + try testing.expectEqual(@as(u64, 100), anim.durationMs()); + try testing.expectEqual(@as(usize, 4), anim.frameBytes()); + + anim.setGapAt(0, 40); + anim.setGapAt(1, 60); + try testing.expectEqual(@as(u64, 100), anim.durationMs()); + try testing.expectEqual(@as(u32, 40), anim.root_gap_ms); +} diff --git a/src/terminal/kitty/graphics_command.zig b/src/terminal/kitty/graphics_command.zig index 8d5d9319c..e57ad174f 100644 --- a/src/terminal/kitty/graphics_command.zig +++ b/src/terminal/kitty/graphics_command.zig @@ -237,8 +237,8 @@ pub const Parser = struct { 'p' => .{ .display = try .parse(self.kv) }, 'd' => .{ .delete = try .parse(self.kv) }, 'f' => .{ .transmit_animation_frame = try .parse(self.kv) }, - 'a' => .{ .control_animation = try .parse(self.kv) }, - 'c' => .{ .compose_animation = try .parse(self.kv) }, + 'a' => .{ .control_animation = .parse(self.kv) }, + 'c' => .{ .compose_animation = .parse(self.kv) }, else => return error.InvalidFormat, }; @@ -333,6 +333,10 @@ pub const Response = struct { id: u32 = 0, image_number: u32 = 0, placement_id: u32 = 0, + /// The 1-based animation frame number, echoed as "r=" for the + /// animation frame actions. This is how clients learn the frame + /// number assigned to a newly created frame. + frame: u32 = 0, message: []const u8 = "OK", pub fn encode(self: Response, writer: *std.Io.Writer) !void { @@ -356,6 +360,10 @@ pub const Response = struct { if (prior) try writer.writeByte(',') else prior = true; try writer.print("p={}", .{self.placement_id}); } + if (self.frame > 0) { + if (prior) try writer.writeByte(',') else prior = true; + try writer.print("r={}", .{self.frame}); + } try writer.writeByte(';'); try writer.writeAll(self.message); try writer.writeAll("\x1b\\"); @@ -437,9 +445,9 @@ pub const Command = struct { .placement_id = d.placement_id, }, .transmit_animation_frame => |f| .{ - .image_id = f.image_id, - .image_number = f.image_number, - .placement_id = f.placement_id, + .image_id = f.transmission.image_id, + .image_number = f.transmission.image_number, + .placement_id = f.transmission.placement_id, }, .control_animation => |a| .{ .image_id = a.image_id, @@ -469,6 +477,7 @@ pub const Command = struct { .query => |t| t, .transmit => |t| t, .transmit_and_display => |t| t.transmission, + .transmit_animation_frame => |f| f.transmission, else => null, }; } @@ -747,38 +756,51 @@ pub const Display = struct { }; pub const AnimationFrameLoading = struct { - image_id: u32 = 0, // i - image_number: u32 = 0, // I - placement_id: u32 = 0, // p + /// Frame data is transmitted exactly like image data, so an a=f + /// command carries a full set of transmission keys: medium (t), + /// format (f), the frame rectangle size (s/v), chunking (m), etc. + /// The image id/number here identify the (existing) image the + /// frame belongs to. + transmission: Transmission = .{}, + + /// The left/top edge (pixels) of the rectangle within the frame + /// that the transmitted data updates. x: u32 = 0, // x y: u32 = 0, // y + + /// The 1-based frame number whose pixels serve as the base canvas + /// for a newly created frame. Zero means no base frame: the + /// canvas is filled with `background` instead. create_frame: u32 = 0, // c + + /// The 1-based frame number of an existing frame to edit. Zero + /// (or any value past the next frame number) creates a new frame. edit_frame: u32 = 0, // r - gap_ms: u32 = 0, // z + + /// The gap in milliseconds before the next frame is shown. For + /// new frames, zero selects the default gap and a negative value + /// creates a gapless (never displayed) frame. For edits, zero + /// leaves the gap unchanged. + gap_ms: i32 = 0, // z + composition_mode: CompositionMode = .alpha_blend, // X background: Background = .{}, // Y + /// The canvas background color as a 32-bit RGBA value where R is + /// the most significant byte (0xRRGGBBAA), e.g. Y=4278190335 + /// (0xff0000ff) is opaque red. Bit-cast from the little-endian + /// u32 value, so the field order is reversed from the byte order. pub const Background = packed struct(u32) { - r: u8 = 0, - g: u8 = 0, - b: u8 = 0, a: u8 = 0, + b: u8 = 0, + g: u8 = 0, + r: u8 = 0, }; - fn parse(kv: KV) !AnimationFrameLoading { - var result: AnimationFrameLoading = .{}; - - if (kv.get('i')) |v| { - result.image_id = v; - } - - if (kv.get('I')) |v| { - result.image_number = v; - } - - if (kv.get('p')) |v| { - result.placement_id = v; - } + fn parse(kv: KV) error{InvalidFormat}!AnimationFrameLoading { + var result: AnimationFrameLoading = .{ + .transmission = try .parse(kv), + }; if (kv.get('x')) |v| { result.x = v; @@ -797,11 +819,14 @@ pub const AnimationFrameLoading = struct { } if (kv.get('z')) |v| { - result.gap_ms = v; + // 'z' is one of the keys Parser.finishValue parses as an + // i32, so the u32 in the KV table is the bit pattern of + // that i32 and this reconstructs it exactly. + result.gap_ms = @bitCast(v); } + // Tested only against 1 like Kitty. if (kv.get('X')) |v| { - // Kitty tests this only against 1 result.composition_mode = if (v == 1) .overwrite else .alpha_blend; } @@ -817,17 +842,33 @@ pub const AnimationFrameComposition = struct { image_id: u32 = 0, // i image_number: u32 = 0, // I placement_id: u32 = 0, // p - frame: u32 = 0, // c - edit_frame: u32 = 0, // r + + /// The 1-based frame number being edited (the destination). + dest_frame: u32 = 0, // c + + /// The 1-based frame number whose pixels are composed onto the + /// destination (the source). + source_frame: u32 = 0, // r + + /// The left/top edge (pixels) of the destination rectangle. x: u32 = 0, // x y: u32 = 0, // y + + /// The size of the rectangle to compose. Zero selects the full + /// image width/height. width: u32 = 0, // w height: u32 = 0, // h + + /// The left/top edge (pixels) of the source rectangle. left_edge: u32 = 0, // X top_edge: u32 = 0, // Y + composition_mode: CompositionMode = .alpha_blend, // C - fn parse(kv: KV) !AnimationFrameComposition { + // Cannot fail: every key is stored as parsed, with no range + // validation (Kitty range-checks these while handling the + // command; see the comment above the execution-deferral tests). + fn parse(kv: KV) AnimationFrameComposition { var result: AnimationFrameComposition = .{}; if (kv.get('i')) |v| { @@ -843,11 +884,11 @@ pub const AnimationFrameComposition = struct { } if (kv.get('c')) |v| { - result.frame = v; + result.dest_frame = v; } if (kv.get('r')) |v| { - result.edit_frame = v; + result.source_frame = v; } if (kv.get('x')) |v| { @@ -888,9 +929,20 @@ pub const AnimationControl = struct { image_number: u32 = 0, // I placement_id: u32 = 0, // p action: AnimationAction = .invalid, // s + + /// The 1-based frame number whose gap is set from gap_ms. frame: u32 = 0, // r - gap_ms: u32 = 0, // z + + /// The new gap for `frame` in milliseconds. Zero is ignored and a + /// negative value makes the frame gapless. + gap_ms: i32 = 0, // z + + /// The 1-based frame number to make current (client-driven + /// animation). Zero is ignored. current_frame: u32 = 0, // c + + /// The loop count: zero is ignored, 1 loops forever, and any + /// larger value plays that many minus one loops. loops: u32 = 0, // v pub const AnimationAction = enum { @@ -900,7 +952,10 @@ pub const AnimationControl = struct { run, // 3 }; - fn parse(kv: KV) !AnimationControl { + // Cannot fail: every key is stored as parsed. Out-of-range values + // (frame numbers, states) are silently ignored during execution, + // matching Kitty. + fn parse(kv: KV) AnimationControl { var result: AnimationControl = .{}; if (kv.get('i')) |v| { @@ -931,7 +986,10 @@ pub const AnimationControl = struct { } if (kv.get('z')) |v| { - result.gap_ms = v; + // 'z' is one of the keys Parser.finishValue parses as an + // i32, so the u32 in the KV table is the bit pattern of + // that i32 and this reconstructs it exactly. + result.gap_ms = @bitCast(v); } if (kv.get('c')) |v| { @@ -983,7 +1041,7 @@ pub const Delete = struct { intersect_cursor: bool, // f/F - animation_frames: bool, + animation_frames: AnimationFrames, // p/P intersect_cell: struct { @@ -1025,7 +1083,14 @@ pub const Delete = struct { z: i32 = 0, // z }, - fn parse(kv: KV) !Action { + pub const AnimationFrames = struct { + delete: bool = false, // uppercase + image_id: u32 = 0, // i + image_number: u32 = 0, // I + frame: u32 = 0, // r + }; + + fn parse(kv: KV) error{InvalidFormat}!Action { const what: u8 = what: { const value = kv.get('d') orelse break :what 'a'; const c = std.math.cast(u8, value) orelse return error.InvalidFormat; @@ -1061,7 +1126,20 @@ pub const Delete = struct { 'c', 'C' => .{ .intersect_cursor = what == 'C' }, - 'f', 'F' => .{ .animation_frames = what == 'F' }, + 'f', 'F' => blk: { + var result: Action = .{ .animation_frames = .{ .delete = what == 'F' } }; + if (kv.get('i')) |v| { + result.animation_frames.image_id = v; + } + if (kv.get('I')) |v| { + result.animation_frames.image_number = v; + } + if (kv.get('r')) |v| { + result.animation_frames.frame = v; + } + + break :blk result; + }, 'p', 'P' => blk: { var result: Action = .{ .intersect_cell = .{ .delete = what == 'P' } }; diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index e27500aab..b1b968b28 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -5,6 +5,8 @@ const Allocator = std.mem.Allocator; const Terminal = @import("../Terminal.zig"); const command = @import("graphics_command.zig"); const image = @import("graphics_image.zig"); +const animation = @import("graphics_animation.zig"); +const pixel = @import("graphics_pixel.zig"); const Command = command.Command; const Response = command.Response; const LoadingImage = image.LoadingImage; @@ -69,7 +71,7 @@ pub fn execute( .display => display(io, alloc, terminal, cmd), .delete => delete(io, alloc, terminal, cmd), - .transmit, .transmit_and_display => resp: { + .transmit, .transmit_and_display, .transmit_animation_frame => resp: { // If we're transmitting, then our `q` setting value is complicated. // The `q` setting inherits the value from the starting command // unless `q` is set >= 1 on this command. If it is, then we save @@ -86,13 +88,35 @@ pub fn execute( }, }; - break :resp transmit(io, alloc, terminal, cmd); + break :resp switch (cmd.control) { + .transmit_animation_frame => transmitAnimationFrame( + io, + alloc, + terminal, + cmd, + ), + else => transmit( + io, + alloc, + terminal, + cmd, + ), + }; }, - .transmit_animation_frame, - .control_animation, - .compose_animation, - => .{ .message = "ERROR: unimplemented action" }, + .control_animation => controlAnimation( + io, + alloc, + terminal, + cmd, + ), + + .compose_animation => composeAnimation( + io, + alloc, + terminal, + cmd, + ), }; // Handle the quiet settings @@ -169,14 +193,26 @@ fn transmit( ) Response { const t = cmd.transmission().?; const storage = &terminal.screens.active.kitty_images; - var result: Response = if (storage.loading) |loading| - loading.response - else - .{ - .id = t.image_id, - .image_number = t.image_number, - .placement_id = t.placement_id, - }; + + var result: Response = if (storage.loading) |loading| loading: { + // Any transmit-like command received while a load is in progress + // is a continuation chunk of that load, and the load completes + // according to how it started. If an animation frame load (a=f) + // is in progress, this chunk belongs to it even when the client + // didn't repeat a=f on the chunk. + if (loading.frame != null) return transmitAnimationFrame( + io, + alloc, + terminal, + cmd, + ); + + break :loading loading.response; + } else .{ + .id = t.image_id, + .image_number = t.image_number, + .placement_id = t.placement_id, + }; const load = loadAndAddImage(io, alloc, terminal, cmd) catch |err| { encodeError(&result, err); @@ -399,6 +435,527 @@ fn display( return result; } +/// Transmit animation frame data (a=f). +/// +/// Frame data is loaded exactly like image data, including chunking +/// and every transmission medium, and once complete it is composed +/// into a frame of an existing image's animation. +fn transmitAnimationFrame( + io: std.Io, + alloc: Allocator, + terminal: *Terminal, + cmd: *const Command, +) Response { + const storage = &terminal.screens.active.kitty_images; + + // A chunk arriving while a load is in progress continues that load. + if (storage.loading) |loading| { + // If this is the first frame, then we treat it like a normal image + // because the first frame is just the image. + if (loading.frame == null) return transmit( + io, + alloc, + terminal, + cmd, + ); + + // Not the first frame, so continue loading similar to transmit + // but this is for a subsequent frame. + var result = loading.response; + loading.addData(alloc, cmd.data) catch |err| { + encodeError(&result, err); + return result; + }; + + // If more chunks are expected we don't respond yet. + const t = cmd.transmission().?; + if (t.more_chunks) return .{}; + + // Final chunk: copy the loading state out and free the + // pointer, mirroring loadAndAddImage. + var loading_copy = loading.*; + alloc.destroy(loading); + storage.loading = null; + defer loading_copy.deinit(alloc); + return completeAnimationFrame( + io, + alloc, + terminal, + &loading_copy, + ); + } + + // We are starting our load, either the first frame or a subsequent frame. + const f = cmd.control.transmit_animation_frame; + const t = f.transmission; + var result: Response = .{ + .id = t.image_id, + .image_number = t.image_number, + .placement_id = t.placement_id, + // Errors echo the client's frame number; on success this is + // replaced with the resolved (possibly newly assigned) one. + .frame = f.edit_frame, + }; + + // A frame can only be added to an existing image. + if (t.image_id == 0 and t.image_number == 0) { + result.message = "EINVAL: image ID or number required"; + return result; + } + const img = storage.imagePtrByIdOrNumber( + t.image_id, + t.image_number, + ) orelse { + result.message = "ENOENT: image not found"; + return result; + }; + result.id = img.id; + + var loading = LoadingImage.init( + io, + alloc, + cmd, + storage.image_limits, + ) catch |err| { + encodeError(&result, err); + return result; + }; + loading.frame = .{ + .cmd = f, + .image_generation = img.generation, + }; + loading.response.id = img.id; + + // Chunked: store the loading state and wait for the rest. The + // frame parameters above are saved with it; continuation chunks + // contribute only payload bytes. + if (t.more_chunks) { + const loading_ptr = alloc.create(LoadingImage) catch |err| { + loading.deinit(alloc); + encodeError(&result, err); + return result; + }; + loading_ptr.* = loading; + storage.loading = loading_ptr; + return .{}; + } + + defer loading.deinit(alloc); + return completeAnimationFrame( + io, + alloc, + terminal, + &loading, + ); +} + +/// Complete a fully loaded animation frame: validate it against its +/// image and compose it into the image's animation. +fn completeAnimationFrame( + io: std.Io, + alloc: Allocator, + terminal: *Terminal, + loading: *LoadingImage, +) Response { + const storage = &terminal.screens.active.kitty_images; + var result = loading.response; + const f = loading.frame.?.cmd; + + // Re-resolve the image: it may have been deleted, evicted, or + // replaced while the frame data was being transmitted. The saved + // generation pins the exact image the load started against. + var img = storage.imagePtrByIdOrNumber( + loading.image.id, + loading.image.number, + ) orelse { + result.message = "ENOENT: image not found"; + return result; + }; + if (img.generation != loading.frame.?.image_generation) { + result.message = "ENOENT: image not found"; + return result; + } + if (img.data.bytes() == null) { + result.message = "EINVAL: image data incomplete"; + return result; + } + + // Finish decoding the frame data: decompression, PNG decoding, + // and length validation all match image loading. + var frame_img = loading.complete(alloc) catch |err| { + encodeError(&result, err); + return result; + }; + defer frame_img.deinit(alloc); + + // The frame rectangle may not exceed the image's size. The x/y + // offsets are deliberately not validated: composition clips, + // matching Kitty. + if (frame_img.width > img.width or frame_img.height > img.height) { + result.message = "EINVAL: frame dimensions exceed image"; + return result; + } + + // All composition happens in RGBA; convert both sides as needed. + if (frame_img.format != .rgba) { + const rgba = pixel.rgbaFromFormat( + alloc, + frame_img.format, + frame_img.data.bytes().?, + ) catch |err| { + encodeError(&result, err); + return result; + }; + frame_img.data.deinit(alloc); + frame_img.data = .{ .complete = rgba }; + frame_img.format = .rgba; + } + storage.convertImageToRgba(io, alloc, img) catch |err| { + encodeError(&result, err); + return result; + }; + + const anim = ensureAnimation(alloc, img) catch |err| { + encodeError(&result, err); + return result; + }; + + // Resolve the frame number: r in 1..count edits that existing + // frame, anything else (including omitted) creates a new frame + // appended at count+1. The resolved number is echoed in the + // response so clients can learn assigned frame numbers. + const count: u32 = anim.frameCount(); + const number: u32 = number: { + const r = f.edit_frame; + if (r == 0 or r > count + 1) break :number count + 1; + break :number r; + }; + result.frame = number; + + const src = frame_img.data.bytes().?; + if (number == count + 1) { + // Creating a new frame. The gap defaults to 40ms when omitted + // and a negative gap creates a gapless (never shown) frame. + const gap: u32 = if (f.gap_ms > 0) + @intCast(f.gap_ms) + else if (f.gap_ms < 0) + 0 + else + animation.default_gap_ms; + + // The base canvas frame must exist before we reserve space. + if (f.create_frame > 0 and img.frameData(f.create_frame) == null) { + result.message = "EINVAL: base frame not found"; + return result; + } + + // Reserve room for the new frame, evicting other images if + // needed. Eviction can remove images (never this one), so we + // re-resolve our pointer afterwards to be safe. + const frame_len: usize = @as(usize, img.width) * img.height * 4; + const image_id = img.id; + storage.reserveAnimationBytes( + io, + alloc, + terminal.screens.active, + image_id, + frame_len, + ) catch { + result.message = "ENOSPC: animation frame storage full"; + return result; + }; + img = storage.imagePtrByIdOrNumber(image_id, 0).?; + + const canvas = alloc.alloc(u8, frame_len) catch { + storage.releaseAnimationBytes(frame_len); + result.message = "ENOMEM: out of memory"; + return result; + }; + if (f.create_frame > 0) { + @memcpy(canvas, img.frameData(f.create_frame).?); + } else { + pixel.fillBackground(canvas, f.background); + } + pixel.composeRect( + canvas, + img.width, + img.height, + src, + frame_img.width, + frame_img.height, + f.x, + f.y, + f.composition_mode, + ); + + anim.frames.append(alloc, .{ + .data = canvas, + .gap_ms = gap, + }) catch { + alloc.free(canvas); + storage.releaseAnimationBytes(frame_len); + result.message = "ENOMEM: out of memory"; + return result; + }; + + // A new frame never changes the displayed frame (playback + // reaches it later), but the storage content changed. + storage.markMutated(io); + } else { + // Editing an existing frame. A nonzero gap also updates the + // frame's gap; the 40ms default doesn't apply to edits. + // + // Unlike frame creation there is no byte reservation here: + // frames are always stored at full image size, so the edit + // composes into the existing buffer in place and storage + // usage cannot change. Kitty likewise exempts frame edits + // from its quota check. + if (f.gap_ms != 0) anim.setGapAt( + number - 1, + if (f.gap_ms > 0) @intCast(f.gap_ms) else 0, + ); + + // The frame data is owned by this storage, so the const cast + // is safe (same reasoning as the renderer's image uploads). + const dst = @constCast(img.frameData(number).?); + pixel.composeRect( + dst, + img.width, + img.height, + src, + frame_img.width, + frame_img.height, + f.x, + f.y, + f.composition_mode, + ); + + if (number - 1 == anim.current_index) { + // The displayed pixels changed; restart the frame's gap + // timer like Kitty's re-upload does. + anim.frame_shown_at_ms = null; + storage.markImageContentChanged(io, img); + } else { + storage.markMutated(io); + } + } + + return result; +} + +/// Control animation playback (a=a). +/// +/// Successful commands never respond. The only possible responses are +/// for a missing image or identifier. Invalid key values are silently +/// ignored, matching Kitty. +fn controlAnimation( + io: std.Io, + alloc: Allocator, + terminal: *Terminal, + cmd: *const Command, +) Response { + const a = cmd.control.control_animation; + const storage = &terminal.screens.active.kitty_images; + + var result: Response = .{ + .id = a.image_id, + .image_number = a.image_number, + .placement_id = a.placement_id, + }; + if (a.image_id == 0 and a.image_number == 0) { + result.message = "EINVAL: image ID or number required"; + return result; + } + const img = storage.imagePtrByIdOrNumber( + a.image_id, + a.image_number, + ) orelse { + result.message = "ENOENT: image not found"; + return result; + }; + + const anim = ensureAnimation(alloc, img) catch |err| { + encodeError(&result, err); + return result; + }; + + // The keys below are applied independently and in the same order as Kitty. + + // Set a frame's gap (r= together with z=). This is the only way + // to give the root frame a gap, since it is created gapless. + if (a.frame != 0 and a.frame <= anim.frameCount() and a.gap_ms != 0) { + anim.setGapAt( + a.frame - 1, + if (a.gap_ms > 0) @intCast(a.gap_ms) else 0, + ); + storage.markMutated(io); + } + + // Set the current frame (c=), the client-driven animation + // primitive. + if (a.current_frame != 0 and a.current_frame <= anim.frameCount() and + a.current_frame - 1 != anim.current_index) + { + anim.current_index = a.current_frame - 1; + anim.frame_shown_at_ms = null; + storage.markImageContentChanged(io, img); + } + + // Set the playback state (s=). Any state change resets the loop + // counter; leaving the stopped state restarts the gap timer. + if (a.action != .invalid) { + const old = anim.state; + anim.state = switch (a.action) { + .invalid => unreachable, + .stop => .stopped, + .run_wait => .loading, + .run => .running, + }; + if (old == .stopped and anim.state != .stopped) { + anim.frame_shown_at_ms = null; + } + anim.current_loop = 0; + storage.markMutated(io); + } + + // Set the loop count (v=), stored off by one per the protocol: + // v=1 loops forever, v=n plays n-1 loops. + if (a.loops != 0) { + anim.max_loops = a.loops - 1; + storage.markMutated(io); + } + + // Successful animation control commands never respond. + return .{}; +} + +/// Compose a rectangle of pixels from one animation frame onto +/// another (a=c). Frame 1 (the root frame) always exists, so this also works +/// on images without any animation state. +fn composeAnimation( + io: std.Io, + alloc: Allocator, + terminal: *Terminal, + cmd: *const Command, +) Response { + const c = cmd.control.compose_animation; + const storage = &terminal.screens.active.kitty_images; + + var result: Response = .{ + .id = c.image_id, + .image_number = c.image_number, + .placement_id = c.placement_id, + }; + if (c.image_id == 0 and c.image_number == 0) { + result.message = "EINVAL: image ID or number required"; + return result; + } + const img = storage.imagePtrByIdOrNumber( + c.image_id, + c.image_number, + ) orelse { + result.message = "ENOENT: image not found"; + return result; + }; + result.id = img.id; + if (img.data.bytes() == null) { + result.message = "EINVAL: image data incomplete"; + return result; + } + + // Both frames must exist. Note that r is the source frame and c + // the destination; the spec's reference table describes these + // backwards (see AnimationFrameComposition). + if (img.frameData(c.source_frame) == null) { + result.message = "ENOENT: source frame not found"; + return result; + } + if (img.frameData(c.dest_frame) == null) { + result.message = "ENOENT: destination frame not found"; + return result; + } + + // Rectangle validation is done in u64 so untrusted 32-bit values + // can't overflow. Out-of-bounds rectangles are errors here, + // unlike a=f which clips. + const width: u64 = if (c.width > 0) c.width else img.width; + const height: u64 = if (c.height > 0) c.height else img.height; + if (@as(u64, c.x) + width > img.width or + @as(u64, c.y) + height > img.height) + { + result.message = "EINVAL: destination rectangle out of bounds"; + return result; + } + if (@as(u64, c.left_edge) + width > img.width or + @as(u64, c.top_edge) + height > img.height) + { + result.message = "EINVAL: source rectangle out of bounds"; + return result; + } + + // Composing a frame onto itself requires non-overlapping + // rectangles. + if (c.source_frame == c.dest_frame) { + const x_overlaps = @max(c.left_edge, c.x) < @min(c.left_edge, c.x) + width; + const y_overlaps = @max(c.top_edge, c.y) < @min(c.top_edge, c.y) + height; + if (x_overlaps and y_overlaps) { + result.message = "EINVAL: source and destination rectangles overlap"; + return result; + } + } + + // All composition happens in RGBA. + storage.convertImageToRgba( + io, + alloc, + img, + ) catch |err| { + encodeError(&result, err); + return result; + }; + + // The frame data is owned by this storage, so the const cast is + // safe. The rectangles were validated disjoint above, so in-place + // composition within one frame is well-defined. + const src = img.frameData(c.source_frame).?; + const dst = @constCast(img.frameData(c.dest_frame).?); + pixel.composeCanvasRect( + dst, + src, + img.width, + @intCast(width), + @intCast(height), + c.left_edge, + c.top_edge, + c.x, + c.y, + c.composition_mode, + ); + + // If the destination is the displayed frame then the on-screen + // content changed. + const current: u32 = if (img.animation) |anim| anim.current_index else 0; + if (c.dest_frame - 1 == current) { + storage.markImageContentChanged(io, img); + } else { + storage.markMutated(io); + } + + return result; +} + +/// Get or lazily create the animation state for an image. +fn ensureAnimation( + alloc: Allocator, + img: *Image, +) Allocator.Error!*animation.Animation { + if (img.animation) |anim| return anim; + const anim = try alloc.create(animation.Animation); + anim.* = .{}; + img.animation = anim; + return anim; +} + /// Display a previously transmitted image. fn delete( io: std.Io, @@ -521,6 +1078,7 @@ const EncodeableError = Image.Error || Allocator.Error; fn encodeError(r: *Response, err: EncodeableError) void { switch (err) { error.OutOfMemory => r.message = "ENOMEM: out of memory", + error.InsufficientData => r.message = "ENODATA: insufficient data", error.InvalidData => r.message = "EINVAL: invalid data", error.DecompressionFailed => r.message = "EINVAL: decompression failed", error.FilePathTooLong => r.message = "EINVAL: file path too long", @@ -2140,3 +2698,906 @@ test "kittygfx uppercase delete frees image of cascaded placements" { try testing.expectEqual(@as(usize, 0), storage.placements.count()); try testing.expect(storage.imageById(1) == null); } + +// Animation tests frequently start from a 1x1 RGB red image with +// id=1 ("/wAA" is FF0000). Composition converts images to RGBA, so +// after the first frame command the base data is FF0000FF. + +test "kittygfx animation: new frame with default gap responds with frame number" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Transmit a 1x1 RGB blue frame ("AAD/" is 0000FF). + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1;AAD/", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + try testing.expectEqual(@as(u32, 1), resp.id); + try testing.expectEqual(@as(u32, 2), resp.frame); + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try resp.encode(&writer); + try testing.expectEqualStrings("\x1b_Gi=1,r=2;OK\x1b\\", writer.buffered()); + + const img = storage.imagePtrByIdOrNumber(1, 0).?; + try testing.expectEqual(command.Transmission.Format.rgba, img.format); + try testing.expectEqualSlices(u8, &.{ 255, 0, 0, 255 }, img.data.bytes().?); + + const anim = img.animation.?; + try testing.expectEqual(@as(u32, 2), anim.frameCount()); + try testing.expectEqual(@as(u32, 0), anim.root_gap_ms); + try testing.expectEqual(@as(u32, 40), anim.frames.items[0].gap_ms); + try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, anim.frames.items[0].data); + + // The displayed frame is still the root: renderData is the base. + try testing.expectEqualSlices(u8, &.{ 255, 0, 0, 255 }, img.renderData().bytes().?); + + // Frame bytes count against the storage total. + try testing.expectEqual(@as(usize, 8), storage.total_bytes); +} + +test "kittygfx animation: frame gap normalization on create" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + for ([_][]const u8{ + "a=f,i=1,f=24,s=1,v=1,z=100;AAD/", + "a=f,i=1,f=24,s=1,v=1,z=-5;AAD/", + }) |input| { + const cmd = try command.Parser.parseString(alloc, input); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqual(@as(u32, 100), anim.frames.items[0].gap_ms); + try testing.expectEqual(@as(u32, 0), anim.frames.items[1].gap_ms); +} + +test "kittygfx animation: background fill and offset composition" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // 2x1 RGB white image ("////////" is FFFFFF FFFFFF). + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=2,v=1,i=1;////////", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // New frame: 1x1 RGB white transmitted at x=1 over an opaque red + // background (Y=4278190335 is 0xff0000ff, R in the MSB). + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1,x=1,Y=4278190335;////", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqualSlices( + u8, + &.{ 255, 0, 0, 255, 255, 255, 255, 255 }, + anim.frames.items[0].data, + ); +} + +test "kittygfx animation: create from base frame with overwrite" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Frame 2 based on the root (c=1) with a semi-transparent blue + // pixel ("AAD/gA==" is 0000FF80) in overwrite mode: the source + // replaces the canvas including alpha. + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=32,s=1,v=1,c=1,X=1;AAD/gA==", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 128 }, anim.frames.items[0].data); +} + +test "kittygfx animation: alpha blend composes over base frame" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Blend a fully opaque blue pixel over the root: alpha blending + // an opaque source is equivalent to a copy. + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=32,s=1,v=1,c=1;AAD//w==", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Blend a fully transparent pixel over the root: the canvas is + // unchanged (still the red base). + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=32,s=1,v=1,c=1;AAD/AA==", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, anim.frames.items[0].data); + try testing.expectEqualSlices(u8, &.{ 255, 0, 0, 255 }, anim.frames.items[1].data); +} + +test "kittygfx animation: edit root frame bumps generation" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + const gen1 = storage.imagePtrByIdOrNumber(1, 0).?.generation; + + // r=1 edits the root frame in place. The root is the displayed + // frame so the image generation must change. + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1,r=1;AAD/", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + try testing.expectEqual(@as(u32, 1), resp.frame); + + const img = storage.imagePtrByIdOrNumber(1, 0).?; + try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, img.data.bytes().?); + try testing.expect(img.generation > gen1); +} + +test "kittygfx animation: edit frame gap without data change" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Create frame 2, then edit it with z=-1: gap becomes gapless. + // Editing with z=0 leaves the gap unchanged (no 40ms default). + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1,z=77;AAD/", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1,r=2;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqual(@as(u32, 77), anim.frames.items[0].gap_ms); + + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1,r=2,z=-1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + try testing.expectEqual(@as(u32, 0), anim.frames.items[0].gap_ms); +} + +test "kittygfx animation: frame errors" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + const cases = [_]struct { + input: []const u8, + message: []const u8, + }{ + // Unknown image. + .{ + .input = "a=f,i=9,f=24,s=1,v=1;AAD/", + .message = "ENOENT: image not found", + }, + // Frame bigger than the image. + .{ + .input = "a=f,i=1,f=24,s=2,v=1;////////", + .message = "EINVAL: frame dimensions exceed image", + }, + // Nonexistent base frame. + .{ + .input = "a=f,i=1,f=24,s=1,v=1,c=5;AAD/", + .message = "EINVAL: base frame not found", + }, + // Insufficient data for the declared rectangle. + .{ + .input = "a=f,i=1,f=24,s=1,v=1;AA==", + .message = "ENODATA: insufficient data", + }, + }; + for (cases) |case| { + const cmd = try command.Parser.parseString(alloc, case.input); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqualStrings(case.message, resp.message); + } + + // None of the failures may have created a frame. + const storage = &t.screens.active.kitty_images; + const img = storage.imagePtrByIdOrNumber(1, 0).?; + if (img.animation) |anim| { + try testing.expectEqual(@as(usize, 0), anim.frames.items.len); + } +} + +test "kittygfx animation: excess frame data is truncated" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Six bytes for a 1x1 RGB frame: Kitty tolerates the excess. + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1;AAD/////", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, anim.frames.items[0].data); +} + +test "kittygfx animation: chunked frame transmission" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // 2x1 RGB white image. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=2,v=1,i=1;////////", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Transmit a 2x1 frame in two chunks of one pixel each. The + // chunks repeat a=f per the protocol. No response until the + // final chunk. + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=2,v=1,z=60,m=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + try testing.expect(storage.loading != null); + { + const cmd = try command.Parser.parseString(alloc, "a=f,m=0;AAD/"); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + try testing.expectEqual(@as(u32, 1), resp.id); + try testing.expectEqual(@as(u32, 2), resp.frame); + } + try testing.expect(storage.loading == null); + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqual(@as(u32, 60), anim.frames.items[0].gap_ms); + try testing.expectEqualSlices( + u8, + &.{ 255, 0, 0, 255, 0, 0, 255, 255 }, + anim.frames.items[0].data, + ); +} + +test "kittygfx animation: chunked frame continuation without a=f" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=2,v=1,i=1;////////", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // The spec requires chunks to repeat a=f, but a bare continuation + // (which parses as a transmit action) must still continue the + // frame load rather than turn into an image transmission. + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=2,v=1,m=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + { + const cmd = try command.Parser.parseString(alloc, "m=0;AAD/"); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + try testing.expectEqual(@as(u32, 2), resp.frame); + } + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqual(@as(usize, 1), anim.frames.items.len); +} + +test "kittygfx animation: control command sets gap current state and loops" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1;AAD/", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + const img = storage.imagePtrByIdOrNumber(1, 0).?; + const gen1 = img.generation; + + // One command can set several things: the root frame's gap + // (r=1,z=50), the current frame (c=2), the state (s=3, running), + // and the loop count (v=3, meaning two loops). Successful a=a + // commands never respond. + const cmd = try command.Parser.parseString( + alloc, + "a=a,i=1,r=1,z=50,c=2,s=3,v=3", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + + const anim = img.animation.?; + try testing.expectEqual(@as(u32, 50), anim.root_gap_ms); + try testing.expectEqual(@as(u32, 1), anim.current_index); + try testing.expectEqual(animation.Animation.State.running, anim.state); + try testing.expectEqual(@as(u32, 2), anim.max_loops); + + // Changing the current frame changes the displayed content. + try testing.expect(img.generation > gen1); + try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, img.renderData().bytes().?); +} + +test "kittygfx animation: control command ignores invalid values" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Out-of-range frame numbers and s values are silently ignored. + const cmd = try command.Parser.parseString( + alloc, + "a=a,i=1,r=9,z=50,c=9,s=7", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqual(@as(u32, 0), anim.root_gap_ms); + try testing.expectEqual(@as(u32, 0), anim.current_index); + try testing.expectEqual(animation.Animation.State.stopped, anim.state); +} + +test "kittygfx animation: control command missing image responds ENOENT" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + const cmd = try command.Parser.parseString(alloc, "a=a,i=9,s=3"); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqualStrings("ENOENT: image not found", resp.message); +} + +test "kittygfx animation: compose frames" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // 2x1 RGB image: red then blue ("/wAAAAD/" is FF0000 0000FF). + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=2,v=1,i=1;/wAAAAD/", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + // Frame 2 starts as a transparent canvas with a white pixel at + // x=0 ("////" is FFFFFF). + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1;////", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Compose the root's pixel at (1,0) onto frame 2 at (0,0): + // r=1 is the source frame, c=2 the destination, X/Y the source + // offsets, x/y the destination offsets. + { + const cmd = try command.Parser.parseString( + alloc, + "a=c,i=1,r=1,c=2,w=1,h=1,X=1,x=0", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + try testing.expectEqual(@as(u32, 0), resp.frame); + } + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqualSlices( + u8, + &.{ 0, 0, 255, 255, 0, 0, 0, 0 }, + anim.frames.items[0].data, + ); +} + +test "kittygfx animation: compose current frame bumps generation" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // 2x1 RGB white image; the root frame is the displayed frame. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=2,v=1,i=1;////////", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + const img = storage.imagePtrByIdOrNumber(1, 0).?; + const gen1 = img.generation; + + // a=c works on images without animation state: both frames are + // the root. Copy pixel 0 onto pixel 1. + const cmd = try command.Parser.parseString( + alloc, + "a=c,i=1,r=1,c=1,w=1,h=1,x=1", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + try testing.expect(img.generation > gen1); +} + +test "kittygfx animation: compose errors" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + const cases = [_]struct { + input: []const u8, + message: []const u8, + }{ + .{ + .input = "a=c,i=9,r=1,c=1", + .message = "ENOENT: image not found", + }, + .{ + .input = "a=c,i=1,r=2,c=1", + .message = "ENOENT: source frame not found", + }, + .{ + .input = "a=c,i=1,r=1,c=2", + .message = "ENOENT: destination frame not found", + }, + // 1x1 image: any offset pushes the rect out of bounds. + .{ + .input = "a=c,i=1,r=1,c=1,x=1", + .message = "EINVAL: destination rectangle out of bounds", + }, + .{ + .input = "a=c,i=1,r=1,c=1,X=1", + .message = "EINVAL: source rectangle out of bounds", + }, + // Same frame, same (whole-image) rect: overlap. + .{ + .input = "a=c,i=1,r=1,c=1", + .message = "EINVAL: source and destination rectangles overlap", + }, + }; + for (cases) |case| { + const cmd = try command.Parser.parseString(alloc, case.input); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqualStrings(case.message, resp.message); + } +} + +test "kittygfx animation: delete frame promotes root and fixes current" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Frames 2 and 3: blue and white. + for ([_][]const u8{ + "a=f,i=1,f=24,s=1,v=1,z=10;AAD/", + "a=f,i=1,f=24,s=1,v=1,z=20;////", + }) |input| { + const cmd = try command.Parser.parseString(alloc, input); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + const total_before = storage.total_bytes; + + // Delete the root frame (r omitted selects it): frame 2 becomes + // the new root, including its gap. Deletes never respond. + { + const cmd = try command.Parser.parseString(alloc, "a=d,d=f,i=1"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + const img = storage.imagePtrByIdOrNumber(1, 0).?; + const anim = img.animation.?; + try testing.expectEqual(@as(u32, 2), anim.frameCount()); + try testing.expectEqual(@as(u32, 10), anim.root_gap_ms); + try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, img.data.bytes().?); + try testing.expectEqual(total_before - 4, storage.total_bytes); + + // Delete a frame number past the end: clamps to the last frame. + { + const cmd = try command.Parser.parseString(alloc, "a=d,d=f,i=1,r=9"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + try testing.expectEqual(@as(u32, 1), anim.frameCount()); + try testing.expectEqual(total_before - 8, storage.total_bytes); + + // Down to one frame: d=f is now a no-op and the image survives. + { + const cmd = try command.Parser.parseString(alloc, "a=d,d=f,i=1"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + try testing.expect(storage.imageById(1) != null); +} + +test "kittygfx animation: uppercase frame delete removes non-animated image" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // Transmit and place: unlike d=I, an uppercase frame delete on a + // non-animated image removes it even though it is placed. + { + const cmd = try command.Parser.parseString( + alloc, + "a=T,f=24,s=1,v=1,i=1,C=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + try testing.expectEqual(@as(usize, 1), storage.placements.count()); + + { + const cmd = try command.Parser.parseString(alloc, "a=d,d=F,i=1"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + try testing.expect(storage.imageById(1) == null); + try testing.expectEqual(@as(usize, 0), storage.placements.count()); + try testing.expectEqual(@as(usize, 0), storage.total_bytes); +} + +test "kittygfx animation: deleting current frame refreshes display" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + for ([_][]const u8{ + "a=f,i=1,f=24,s=1,v=1;AAD/", + "a=f,i=1,f=24,s=1,v=1;////", + "a=a,i=1,c=3", + }) |input| { + const cmd = try command.Parser.parseString(alloc, input); + defer cmd.deinit(alloc); + _ = execute(io, alloc, &t, &cmd); + } + + const img = storage.imagePtrByIdOrNumber(1, 0).?; + const anim = img.animation.?; + try testing.expectEqual(@as(u32, 2), anim.current_index); + const gen1 = img.generation; + + // Deleting the last frame (the current one) clamps the index and + // refreshes the display. + { + const cmd = try command.Parser.parseString(alloc, "a=d,d=f,i=1,r=3"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + try testing.expectEqual(@as(u32, 1), anim.current_index); + try testing.expect(img.generation > gen1); + try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, img.renderData().bytes().?); +} + +test "kittygfx animation: retransmitting base image resets animation" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1;AAD/", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + try testing.expectEqual(@as(usize, 8), storage.total_bytes); + + // Retransmit the base image: the animation is dropped and the + // frame bytes are credited back. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + const img = storage.imagePtrByIdOrNumber(1, 0).?; + try testing.expect(img.animation == null); + try testing.expectEqual(@as(usize, 3), storage.total_bytes); +} + +test "kittygfx animation: control negative gap makes frame gapless" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1;AAD/", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + const cmd = try command.Parser.parseString(alloc, "a=a,i=1,r=2,z=-1"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqual(@as(u32, 0), anim.frames.items[0].gap_ms); +} diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index 9cce19966..489b672e3 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -6,6 +6,7 @@ const ArenaAllocator = std.heap.ArenaAllocator; const posix = std.posix; const fastmem = @import("../../fastmem.zig"); +const animation = @import("graphics_animation.zig"); const command = @import("graphics_command.zig"); const PageList = @import("../PageList.zig"); const sys = @import("../sys.zig"); @@ -35,6 +36,12 @@ pub const LoadingImage = struct { /// so that we display the image after it is fully loaded. display: ?command.Display = null, + /// This is non-null when this load is an animation frame + /// transmission (a=f) rather than a new image. On completion the + /// data is composed into the target image's animation instead of + /// being stored as an image. + frame: ?FrameContext = null, + /// Quiet is the quiet settings for the initial load command. This is /// used if q isn't set on subsequent chunks. quiet: command.Command.Quiet, @@ -47,6 +54,20 @@ pub const LoadingImage = struct { /// temporary directory transmission is disabled). temporary_directory: ?[]const u8, + pub const FrameContext = struct { + /// The frame parameters from the initial a=f command. Chunked + /// continuations only contribute payload bytes; all parameters + /// come from the command that started the load, matching the + /// protocol's requirement that chunks repeat a=f. + cmd: command.AnimationFrameLoading, + + /// The generation of the target image when the load began. + /// A different generation at completion means the image was + /// replaced or evicted mid-transmission and the frame must be + /// discarded rather than composed onto the wrong image. + image_generation: u64, + }; + /// The limits of the Kitty Graphics protocol we should allow. /// /// This can be used to restrict the type of images and other @@ -501,11 +522,23 @@ pub const LoadingImage = struct { if (img.width == 0 or img.height == 0) return error.DimensionsRequired; if (img.width > max_dimension or img.height > max_dimension) return error.DimensionsTooLarge; - // Data length must be what we expect + // Data length must be what we expect. const bpp = command.Transmission.formatBpp(img.format); const expected_len = img.width * img.height * bpp; const actual_len = self.data.items.len; - if (actual_len != expected_len) { + if (self.frame != null) { + // Kitty allows animation frames to exceed their expected length + // and just truncates it. Not sure if thats expected but lets + // allow it too. + if (actual_len < expected_len) { + std.log.warn( + "insufficient frame data image id={} expected_len={} actual_len={}", + .{ img.id, expected_len, actual_len }, + ); + return error.InsufficientData; + } + self.data.items.len = expected_len; + } else if (actual_len != expected_len) { std.log.warn( "unexpected length image id={} width={} height={} bpp={} expected_len={} actual_len={}", .{ img.id, img.width, img.height, bpp, expected_len, actual_len }, @@ -653,9 +686,25 @@ pub const Image = struct { /// have changed, even if the dimensions and byte length are the /// same (e.g. a retransmission of the same ID). Stamps order by /// transmission time. Zero means "never stored". + /// + /// For animated images this also changes whenever the frame that + /// should be displayed changes (advance, edit, or delete of the + /// current frame), since consumers key texture caches off it. generation: u64 = 0, + /// Animation state, non-null once any animation command (a=f, + /// a=a) has attached animation state to this image. Owned by the + /// image; replaced/retransmitted images drop it, which implements + /// the protocol's "retransmission resets the animation" rule. + /// + /// This is only ever attached to images stored in an ImageStorage + /// and must only be mutated through the storage's own pointer + /// (Image values are copied around freely; copies share this + /// pointer and never own it). + animation: ?*animation.Animation = null, + pub const Error = error{ + InsufficientData, InvalidData, DecompressionFailed, DimensionsRequired, @@ -705,6 +754,54 @@ pub const Image = struct { pub fn deinit(self: *Image, alloc: Allocator) void { self.data.deinit(alloc); + if (self.animation) |anim| { + anim.deinit(alloc); + alloc.destroy(anim); + self.animation = null; + } + } + + /// The pixel data that should be displayed for this image. For an + /// animated image this is the current animation frame; otherwise + /// (and for the root frame) it is the image's own data. + pub fn renderData(self: *const Image) Data { + if (self.animation) |anim| { + if (anim.current_index > 0) { + return .{ .complete = anim.frames.items[anim.current_index - 1].data }; + } + } + + return self.data; + } + + /// The pixel data of the given 1-based animation frame number, or + /// null if the frame doesn't exist. Frame 1 (the root frame) + /// always exists as long as the image data is complete, even for + /// images without animation state. + /// + /// The returned slice is owned by the image (or its animation) + /// and remains valid until the image or frame is mutated. + pub fn frameData(self: *const Image, number: u32) ?[]const u8 { + switch (number) { + 0 => return null, + 1 => return self.data.bytes(), + else => { + const anim = self.animation orelse return null; + // Minus 2 because frame is 1-based and frame 1 is the + // image base data, so the animation frames start at frame 2. + const idx = number - 2; + if (idx >= anim.frames.items.len) return null; + return anim.frames.items[idx].data; + }, + } + } + + /// Total bytes of pixel data reserved against the storage limit + /// for this image: the base data plus any animation frames. + pub fn storageSize(self: *const Image) usize { + var total: usize = self.data.len(); + if (self.animation) |anim| total += anim.frameBytes(); + return total; } /// Mostly for logging diff --git a/src/terminal/kitty/graphics_pixel.zig b/src/terminal/kitty/graphics_pixel.zig new file mode 100644 index 000000000..1f4468739 --- /dev/null +++ b/src/terminal/kitty/graphics_pixel.zig @@ -0,0 +1,235 @@ +//! Pixel-buffer operations for the Kitty graphics protocol: format +//! conversion to RGBA, background fill, and rectangle composition. +//! These are the primitives behind animation frame loading (a=f) and +//! frame composition (a=c). See graphics_animation.zig for the +//! animation model built on top of them. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const wuffs = @import("wuffs"); +const command = @import("graphics_command.zig"); + +/// Convert pixel data in the given format to a freshly allocated RGBA +/// buffer. The caller owns the result; the input is not freed. +/// +/// The input length must be a multiple of the format's bytes per +/// pixel; image loading validates data length against the image +/// dimensions before storing it. +pub fn rgbaFromFormat( + alloc: Allocator, + format: command.Transmission.Format, + data: []const u8, +) Allocator.Error![]u8 { + const result = switch (format) { + .rgba => return try alloc.dupe(u8, data), + .rgb => wuffs.swizzle.rgbToRgba(alloc, data), + .gray => wuffs.swizzle.gToRgba(alloc, data), + .gray_alpha => wuffs.swizzle.gaToRgba(alloc, data), + + // PNG is decoded to RGBA during image loading. + .png => unreachable, + }; + + return result catch |err| switch (err) { + error.OutOfMemory => error.OutOfMemory, + // These are fixed, supported swizzles; wuffs cannot fail to + // prepare them and nothing else in the conversion errors. + error.WuffsError, error.Overflow => unreachable, + }; +} + +/// Fill an RGBA buffer with the given background color. +pub fn fillBackground( + data: []u8, + bg: command.AnimationFrameLoading.Background, +) void { + const raw: u32 = @bitCast(bg); + if (raw == 0) { + @memset(data, 0); + return; + } + var i: usize = 0; + while (i + 4 <= data.len) : (i += 4) { + data[i + 0] = bg.r; + data[i + 1] = bg.g; + data[i + 2] = bg.b; + data[i + 3] = bg.a; + } +} + +/// Compose the `src` rectangle (src_width x src_height RGBA pixels) +/// onto the `dst` canvas (dst_width x dst_height RGBA pixels) with +/// its top-left corner at (x, y). Portions of the rectangle outside +/// the canvas are silently clipped, matching Kitty's a=f behavior. +pub fn composeRect( + dst: []u8, + dst_width: u32, + dst_height: u32, + src: []const u8, + src_width: u32, + src_height: u32, + x: u32, + y: u32, + mode: command.CompositionMode, +) void { + if (x >= dst_width or y >= dst_height) return; + const width: usize = @min(src_width, dst_width - x); + const height: usize = @min(src_height, dst_height - y); + for (0..height) |row| { + const dst_off = ((y + row) * dst_width + x) * 4; + const src_off = row * @as(usize, src_width) * 4; + composeRow( + dst[dst_off..][0 .. width * 4], + src[src_off..][0 .. width * 4], + mode, + ); + } +} + +/// Compose a width x height rectangle between two full-size RGBA +/// canvases sharing the same `canvas_width` stride, reading from +/// (src_x, src_y) in `src` and writing at (dst_x, dst_y) in `dst`. +/// The caller must have validated that both rectangles are within +/// bounds; a=c reports out-of-bounds rectangles as errors rather +/// than clipping. +pub fn composeCanvasRect( + dst: []u8, + src: []const u8, + canvas_width: u32, + width: u32, + height: u32, + src_x: u32, + src_y: u32, + dst_x: u32, + dst_y: u32, + mode: command.CompositionMode, +) void { + for (0..height) |row| { + const dst_off = ((dst_y + row) * @as(usize, canvas_width) + dst_x) * 4; + const src_off = ((src_y + row) * @as(usize, canvas_width) + src_x) * 4; + composeRow( + dst[dst_off..][0 .. @as(usize, width) * 4], + src[src_off..][0 .. @as(usize, width) * 4], + mode, + ); + } +} + +fn composeRow(dst: []u8, src: []const u8, mode: command.CompositionMode) void { + switch (mode) { + .overwrite => @memcpy(dst, src), + .alpha_blend => wuffs.swizzle.rgbaSrcOver(dst, src), + } +} + +test "rgba conversion" { + const testing = std.testing; + const alloc = testing.allocator; + + { + const rgb = [_]u8{ 1, 2, 3, 4, 5, 6 }; + const result = try rgbaFromFormat(alloc, .rgb, &rgb); + defer alloc.free(result); + try testing.expectEqualSlices(u8, &.{ 1, 2, 3, 255, 4, 5, 6, 255 }, result); + } + + { + const gray = [_]u8{ 7, 8 }; + const result = try rgbaFromFormat(alloc, .gray, &gray); + defer alloc.free(result); + try testing.expectEqualSlices(u8, &.{ 7, 7, 7, 255, 8, 8, 8, 255 }, result); + } + + { + const ga = [_]u8{ 7, 100, 8, 200 }; + const result = try rgbaFromFormat(alloc, .gray_alpha, &ga); + defer alloc.free(result); + try testing.expectEqualSlices(u8, &.{ 7, 7, 7, 100, 8, 8, 8, 200 }, result); + } + + { + const rgba = [_]u8{ 1, 2, 3, 4 }; + const result = try rgbaFromFormat(alloc, .rgba, &rgba); + defer alloc.free(result); + try testing.expectEqualSlices(u8, &rgba, result); + } +} + +test "fill background" { + var buf: [8]u8 = undefined; + fillBackground(&buf, .{ .r = 1, .g = 2, .b = 3, .a = 4 }); + try std.testing.expectEqualSlices(u8, &.{ 1, 2, 3, 4, 1, 2, 3, 4 }, &buf); + + fillBackground(&buf, .{}); + try std.testing.expectEqualSlices(u8, &(.{0} ** 8), &buf); +} + +test "compose rect overwrite with clipping" { + // 2x2 canvas, compose a 2x1 rect at (1, 1): only the first pixel + // of the rect fits, the rest clips off the right edge. + var dst = [_]u8{0} ** 16; + const src = [_]u8{ 1, 2, 3, 4, 5, 6, 7, 8 }; + composeRect(&dst, 2, 2, &src, 2, 1, 1, 1, .overwrite); + + var expect = [_]u8{0} ** 16; + expect[12] = 1; + expect[13] = 2; + expect[14] = 3; + expect[15] = 4; + try std.testing.expectEqualSlices(u8, &expect, &dst); +} + +test "compose rect entirely out of bounds" { + var dst = [_]u8{9} ** 16; + const src = [_]u8{1} ** 4; + composeRect(&dst, 2, 2, &src, 1, 1, 2, 0, .overwrite); + composeRect(&dst, 2, 2, &src, 1, 1, 0, 2, .overwrite); + try std.testing.expectEqualSlices(u8, &(.{9} ** 16), &dst); +} + +test "alpha blend source-over semantics" { + const testing = std.testing; + + // Opaque source overwrites exactly. + { + var dst = [4]u8{ 10, 20, 30, 40 }; + composeRect(&dst, 1, 1, &.{ 100, 110, 120, 255 }, 1, 1, 0, 0, .alpha_blend); + try testing.expectEqualSlices(u8, &.{ 100, 110, 120, 255 }, &dst); + } + + // Fully transparent source leaves the destination untouched + // (like Kitty; see the module doc on rounding). + { + var dst = [4]u8{ 10, 20, 30, 40 }; + composeRect(&dst, 1, 1, &.{ 100, 110, 120, 0 }, 1, 1, 0, 0, .alpha_blend); + try testing.expectEqualSlices(u8, &.{ 10, 20, 30, 40 }, &dst); + } + + // 50% source over opaque destination. + { + var dst = [4]u8{ 0, 0, 0, 255 }; + composeRect(&dst, 1, 1, &.{ 255, 255, 255, 128 }, 1, 1, 0, 0, .alpha_blend); + try testing.expectEqual(@as(u8, 255), dst[3]); + try testing.expectEqual(@as(u8, 128), dst[0]); + } + + // Blending over a transparent destination yields the source + // exactly, matching Kitty: wuffs passes the source through + // untouched when the destination is transparent. + { + var dst = [4]u8{ 0, 0, 0, 0 }; + composeRect(&dst, 1, 1, &.{ 200, 100, 50, 128 }, 1, 1, 0, 0, .alpha_blend); + try testing.expectEqualSlices(u8, &.{ 200, 100, 50, 128 }, &dst); + } +} + +test "compose canvas rect" { + // 3x1 canvas: copy pixel at x=0 onto x=2. + var canvas = [_]u8{ 1, 2, 3, 4, 0, 0, 0, 0, 9, 9, 9, 9 }; + composeCanvasRect(&canvas, &canvas, 3, 1, 1, 0, 0, 2, 0, .overwrite); + try std.testing.expectEqualSlices( + u8, + &.{ 1, 2, 3, 4, 0, 0, 0, 0, 1, 2, 3, 4 }, + &canvas, + ); +} diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index 53382344c..897c8944a 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -6,6 +6,8 @@ const ArenaAllocator = std.heap.ArenaAllocator; const terminal = @import("../main.zig"); const point = @import("../point.zig"); const size = @import("../size.zig"); +const animation = @import("graphics_animation.zig"); +const pixel = @import("graphics_pixel.zig"); const command = @import("graphics_command.zig"); const PageList = @import("../PageList.zig"); const Screen = @import("../Screen.zig"); @@ -185,13 +187,14 @@ pub const ImageStorage = struct { /// Record a content mutation: marks the storage dirty and assigns a /// fresh generation stamp. Must be called by anything that changes - /// the set of images or placements (or image contents). + /// the set of images or placements (or image contents), including + /// the animation command handlers in graphics_exec.zig. /// /// Do NOT call this for geometry-only events (scrolling, resizing, /// screen switches); those must set only the dirty flag directly. /// Bumping the generation for geometry changes would break the /// contract that an unchanged generation means unchanged contents. - fn markMutated(self: *ImageStorage, io: std.Io) void { + pub fn markMutated(self: *ImageStorage, io: std.Io) void { self.dirty = true; self.generation = nextGeneration(io); } @@ -281,7 +284,7 @@ pub const ImageStorage = struct { // replacing pending snapshot metadata must be able to reuse the // reservation without evicting its own ID. const old_len = if (self.images.get(img.id)) |old| - old.data.len() + old.storageSize() else 0; assert(old_len <= self.total_bytes); @@ -315,7 +318,10 @@ pub const ImageStorage = struct { // Relative placements parented to the removed placements go too. _ = self.removeOrphans(s, null); - self.total_bytes -= gop.value_ptr.data.len(); + // Replacing an image drops its animation frames with it, + // implementing the protocol rule that retransmitting the + // base image resets the animation. + self.total_bytes -= gop.value_ptr.storageSize(); gop.value_ptr.deinit(alloc); } @@ -928,6 +934,235 @@ pub const ImageStorage = struct { return newest; } + /// Get a mutable pointer to a stored image, by ID or (newest by) + /// number, following the protocol's id/number addressing. Used by + /// the animation commands, which mutate images in place. The + /// pointer is invalidated by any operation that adds or removes + /// images. + pub fn imagePtrByIdOrNumber( + self: *const ImageStorage, + image_id: u32, + image_number: u32, + ) ?*Image { + if (image_id != 0) return self.images.getPtr(image_id); + + var newest: ?*Image = null; + var it = self.images.iterator(); + while (it.next()) |kv| { + if (kv.value_ptr.number != image_number) continue; + if (newest == null or + kv.value_ptr.generation > newest.?.generation) + { + newest = kv.value_ptr; + } + } + + return newest; + } + + /// Record that the displayed content of an image changed without + /// the image being re-added: marks the storage mutated and stamps + /// the image with the fresh generation so consumers (e.g. the + /// renderer's texture cache) replace what they hold. Used when an + /// animation changes which frame is current or edits the current + /// frame's pixels. + pub fn markImageContentChanged( + self: *ImageStorage, + io: std.Io, + img: *Image, + ) void { + self.markMutated(io); + img.generation = self.generation; + } + + /// Convert a stored image's base data to RGBA in place, adjusting + /// byte accounting. All animation composition happens in RGBA; + /// this is called before the first composition into an image. + /// + /// The pixels are unchanged visually but the stored representation + /// changed, so the image is stamped with a fresh generation. + pub fn convertImageToRgba( + self: *ImageStorage, + io: std.Io, + alloc: Allocator, + img: *Image, + ) Allocator.Error!void { + if (img.format == .rgba) return; + const old = img.data.bytes() orelse return; + + const rgba = try pixel.rgbaFromFormat(alloc, img.format, old); + self.total_bytes -= old.len; + self.total_bytes += rgba.len; + img.data.deinit(alloc); + img.data = .{ .complete = rgba }; + img.format = .rgba; + self.markImageContentChanged(io, img); + } + + /// Reserve `bytes` of storage for animation frame data belonging + /// to `image_id`, evicting other images if needed, mirroring how + /// image transmission reserves space. + /// + /// Errors if the space cannot be made available. On success the caller + /// owns the reservation and must either attach the frame data to the + /// image or call releaseAnimationBytes. + pub fn reserveAnimationBytes( + self: *ImageStorage, + io: std.Io, + alloc: Allocator, + s: *terminal.Screen, + image_id: u32, + bytes: usize, + ) Allocator.Error!void { + if (bytes > self.total_limit) return error.OutOfMemory; + + const total_bytes = self.total_bytes + bytes; + if (total_bytes > self.total_limit) { + const req_bytes = total_bytes - self.total_limit; + // Excess this large cannot be recovered by evicting other + // images (evictImageExcept also requires it). + if (req_bytes > self.total_limit) return error.OutOfMemory; + log.info("evicting images for animation frame, evicting={}", .{req_bytes}); + if (!self.evictImageExcept( + io, + alloc, + s, + req_bytes, + image_id, + )) { + log.warn("failed to evict enough images for animation frame", .{}); + return error.OutOfMemory; + } + } + + self.total_bytes += bytes; + } + + /// Release a reservation made by reserveAnimationBytes, or credit + /// bytes freed by deleting animation frame data. + pub fn releaseAnimationBytes(self: *ImageStorage, bytes: usize) void { + assert(bytes <= self.total_bytes); + self.total_bytes -= bytes; + } + + /// Advance every running animation to the frame that should be + /// displayed at `now_ms` and report when the next frame change is + /// due, as a delay in milliseconds relative to `now_ms`. Null + /// means no running animation needs a future tick. + /// + /// `now_ms` is a monotonic timestamp on a clock of the caller's + /// choosing. The same clock must be used for every call. The + /// caller is expected to be the renderer, ticking once per frame + /// build and scheduling a wakeup for the returned delay. + pub fn animationTick(self: *ImageStorage, io: std.Io, now_ms: u64) ?u64 { + var min_delay: ?u64 = null; + + var it = self.images.iterator(); + while (it.next()) |entry| { + const img: *Image = entry.value_ptr; + + // The gates below mirror Kitty's image_is_animatable. + + // No animation state was ever attached (plain image). + const anim = img.animation orelse continue; + + // Stopped is the initial state of every animation: frames + // then only change client-driven (a=a c=N), never by time. + if (anim.state == .stopped) continue; + + // Only the root frame exists; there is nothing to advance + // to yet even in the running state. + if (anim.frames.items.len == 0) continue; + + // Unplaced images don't animate. This is our simpler + // approximation of Kitty's "is actually drawn" visibility + // gate; it is what stops an image that was transmitted but + // never placed from waking the renderer forever. + if (img.metadata.placement_count == 0) continue; + + // The base pixel data hasn't arrived yet (e.g. an image + // restored from a snapshot); nothing can be displayed. + if (img.data.isPending()) continue; + + // A zero total duration means every frame is gapless and + // no frame can ever be displayed, so the animation can + // never advance. This also guards the gapless-skip loop + // below from never terminating. + if (anim.durationMs() == 0) continue; + + // A finite loop budget (a=a v=N) that ran out on an + // earlier tick froze playback on the last frame for good. + if (anim.max_loops > 0 and anim.current_loop >= anim.max_loops) continue; + + const shown_at: u64 = shown_at: { + // First tick since playback started (or since the + // current frame changed through another path, e.g. + // a=a c=N): the frame is considered shown as of now, + // and its gap starts counting from here. + const at = anim.frame_shown_at_ms orelse break :shown_at now_ms; + + // A timestamp from the future means the caller's clock + // restarted; re-anchor rather than stalling until the + // old timestamp comes around again. + if (at > now_ms) break :shown_at now_ms; + + break :shown_at at; + }; + anim.frame_shown_at_ms = shown_at; + + // The current frame is replaced once its gap has elapsed. + // We advance at most one displayed frame per tick with no + // catch-up, exactly like Kitty: if ticks lag behind the + // gaps, the animation slows down rather than skipping. + var next_at: u64 = shown_at +| anim.gapAt(anim.current_index); + if (now_ms >= next_at) advance: { + // Walk forward to the next displayable frame. This is + // a loop only because gapless (gap=0) frames are never + // displayed and are stepped over; the durationMs gate + // above guarantees a displayable frame exists. + const count: u32 = anim.frameCount(); + var idx = anim.current_index; + while (true) { + const next = (idx + 1) % count; + if (next == 0) { + // Wrapping past the last frame back to the + // root. A loading-state (a=a s=2) animation + // refuses the wrap: it parks on the last frame + // awaiting more frames from the client. + if (anim.state == .loading) break :advance; + + // Each wrap completes a loop; a finite budget + // that just ran out parks on the last frame. + anim.current_loop += 1; + if (anim.max_loops > 0 and + anim.current_loop >= anim.max_loops) break :advance; + } + idx = next; + if (anim.gapAt(idx) != 0) break; + } + + // Show the chosen frame: restart its gap timer and + // stamp a fresh generation so consumers (the renderer + // texture cache, the C API) pick up the new pixels. + anim.current_index = idx; + anim.frame_shown_at_ms = now_ms; + self.markImageContentChanged(io, img); + next_at = now_ms +| anim.gapAt(idx); + } + + // Schedule the next tick. A parked animation left next_at + // in the past and so never schedules one; it is woken by + // its trigger instead (a new frame arriving, or an a=a + // command changing the state). + if (next_at > now_ms) { + const delay = next_at - now_ms; + min_delay = if (min_delay) |m| @min(m, delay) else delay; + } + } + + return min_delay; + } + /// Clear placements intersecting the active screen, then reclaim every /// image with no remaining placement. Unlike protocol d=A, a terminal /// clear also reclaims images that were already unplaced. @@ -1148,9 +1383,12 @@ pub const ImageStorage = struct { } }, - // We don't support animation frames yet so they are successfully - // deleted! - .animation_frames => {}, + .animation_frames => |v| self.deleteAnimationFrame( + io, + alloc, + t.screens.active, + v, + ), } // Deleting placements orphans any relative placements parented @@ -1160,7 +1398,7 @@ pub const ImageStorage = struct { // per-branch deleteIfUnused calls above ran while the orphans // still counted as placements). const delete_unused: bool = switch (cmd) { - .all, .intersect_cursor, .animation_frames => |v| v, + .all, .intersect_cursor => |v| v, inline else => |v| v.delete, }; _ = self.removeOrphans( @@ -1239,12 +1477,97 @@ pub const ImageStorage = struct { if (delete_unused and matched) self.deleteIfUnused(alloc, image_id); } + /// Delete an animation frame (d=f/F). Deletes never produce + /// responses, so all failures are only logged. Kitty behaviors + /// implemented here: on an image without extra frames a lowercase + /// delete is a no-op while an uppercase delete removes the entire + /// image, placements included; the frame number is clamped to the + /// last frame and zero selects the root frame; deleting the root + /// frame promotes frame 2 to be the new root. + fn deleteAnimationFrame( + self: *ImageStorage, + io: std.Io, + alloc: Allocator, + s: *terminal.Screen, + v: command.Delete.Action.AnimationFrames, + ) void { + if (v.image_id == 0 and v.image_number == 0) { + log.warn("delete animation frames requires image id or number", .{}); + return; + } + const img = self.imagePtrByIdOrNumber( + v.image_id, + v.image_number, + ) orelse { + log.warn( + "delete animation frames for unknown image id={} number={}", + .{ v.image_id, v.image_number }, + ); + return; + }; + + const anim: *animation.Animation = anim: { + if (img.animation) |anim| { + if (anim.frames.items.len > 0) break :anim anim; + } + + // The image is not (or no longer) an animation. The + // uppercase delete removes the entire image, even when it + // still has placements. + if (!v.delete) return; + self.removePlacementsByImageId(s, img.id); + const entry = self.images.getEntry(img.id).?; + self.total_bytes -= entry.value_ptr.storageSize(); + entry.value_ptr.deinit(alloc); + self.images.removeByPtr(entry.key_ptr); + return; + }; + + // Clamp the frame number: zero selects the root frame and + // values past the end select the last frame. + const count: u32 = anim.frameCount(); + var number: u32 = @min(v.frame, count); + if (number == 0) number = 1; + + if (number == 1) { + // Deleting the root frame promotes frame 2 to root. The + // promoted frame's bytes stay reserved; only the old root + // data is freed. + self.releaseAnimationBytes(img.data.len()); + img.data.deinit(alloc); + const promoted = anim.frames.orderedRemove(0); + img.data = .{ .complete = promoted.data }; + anim.root_gap_ms = promoted.gap_ms; + } else { + const removed = anim.frames.orderedRemove(number - 2); + self.releaseAnimationBytes(removed.data.len); + alloc.free(removed.data); + } + + // Fix up the current frame. + const removed_idx: u32 = if (number == 1) 0 else number - 2; + const remaining: u32 = @intCast(anim.frames.items.len); + if (anim.current_index > remaining) { + anim.current_index = remaining; + anim.frame_shown_at_ms = null; + self.markImageContentChanged(io, img); + return; + } + if (removed_idx == anim.current_index) { + anim.frame_shown_at_ms = null; + self.markImageContentChanged(io, img); + } else { + if (removed_idx < anim.current_index) anim.current_index -= 1; + self.markMutated(io); + } + } + /// Delete an image if it is unused. fn deleteIfUnused(self: *ImageStorage, alloc: Allocator, image_id: u32) void { const entry = self.images.getEntry(image_id) orelse return; if (entry.value_ptr.metadata.placement_count > 0) return; - self.total_bytes -= entry.value_ptr.data.len(); + self.total_bytes -= entry.value_ptr.storageSize(); entry.value_ptr.deinit(alloc); self.images.removeByPtr(entry.key_ptr); } @@ -1364,7 +1687,7 @@ pub const ImageStorage = struct { } const entry = self.images.getEntry(c.id).?; - const image_len = entry.value_ptr.data.len(); + const image_len = entry.value_ptr.storageSize(); log.info("evicting image id={} bytes={}", .{ c.id, image_len }); evicted += image_len; @@ -4099,3 +4422,243 @@ test "storage: placeholderTarget lookup" { try testing.expectEqual(expected, s.placeholderTarget(1, 0).?.key); } } + +test "storage: animation tick advances and schedules" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // A running 1x1 RGBA image whose animation has one extra frame. + try s.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 1, + .height = 1, + .format = .rgba, + .data = .{ .complete = try alloc.dupe(u8, &.{ 255, 0, 0, 255 }) }, + }); + const img = s.images.getPtr(1).?; + const anim = try alloc.create(animation.Animation); + anim.* = .{ .state = .running }; + img.animation = anim; + try anim.frames.append(alloc, .{ + .data = try alloc.dupe(u8, &.{ 0, 0, 255, 255 }), + .gap_ms = 40, + }); + + // Without a placement the animation doesn't advance (our + // approximation of Kitty's visibility gate). + try testing.expect(s.animationTick(io, 0) == null); + try testing.expectEqual(@as(u32, 0), anim.current_index); + + try s.addPlacement(io, alloc, t.screens.active, 1, 0, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }, + }); + const gen1 = img.generation; + s.dirty = false; + + // First tick: the gapless root frame is due immediately and is + // skipped over to frame 2, which is due again in its 40ms gap. + try testing.expectEqual(@as(?u64, 40), s.animationTick(io, 0)); + try testing.expectEqual(@as(u32, 1), anim.current_index); + try testing.expect(img.generation > gen1); + try testing.expect(s.dirty); + + // Nothing due yet: no advance, and the delay counts down. + const gen2 = img.generation; + try testing.expectEqual(@as(?u64, 30), s.animationTick(io, 10)); + try testing.expectEqual(gen2, img.generation); + + // Wrapping is fine with an infinite loop budget: the gapless + // root is skipped and frame 2 is shown again. + try testing.expectEqual(@as(?u64, 40), s.animationTick(io, 40)); + try testing.expectEqual(@as(u32, 1), anim.current_index); + try testing.expectEqual(@as(u32, 1), anim.current_loop); +} + +test "storage: animation tick loading state parks on last frame" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // A placed 1x1 RGBA image in the loading state (a=a s=2) whose + // animation has one extra frame. + try s.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 1, + .height = 1, + .format = .rgba, + .data = .{ .complete = try alloc.dupe(u8, &.{ 255, 0, 0, 255 }) }, + }); + const anim = try alloc.create(animation.Animation); + anim.* = .{ .state = .loading }; + s.images.getPtr(1).?.animation = anim; + try anim.frames.append(alloc, .{ + .data = try alloc.dupe(u8, &.{ 0, 0, 255, 255 }), + .gap_ms = 40, + }); + try s.addPlacement(io, alloc, t.screens.active, 1, 0, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }, + }); + + // Reach the last frame, then park: no wakeup is scheduled while + // waiting for more frames and the loop counter stays untouched. + try testing.expectEqual(@as(?u64, 40), s.animationTick(io, 0)); + try testing.expectEqual(@as(u32, 1), anim.current_index); + try testing.expect(s.animationTick(io, 100) == null); + try testing.expectEqual(@as(u32, 1), anim.current_index); + try testing.expectEqual(@as(u32, 0), anim.current_loop); + + // A new frame arriving un-parks playback. + try anim.frames.append(alloc, .{ + .data = try alloc.dupe(u8, &.{ 0, 255, 0, 255 }), + .gap_ms = 25, + }); + try testing.expectEqual(@as(?u64, 25), s.animationTick(io, 150)); + try testing.expectEqual(@as(u32, 2), anim.current_index); +} + +test "storage: animation tick exhausts loop budget" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // A placed, running 1x1 RGBA image with a gapped root frame, one + // extra frame, and a one-loop budget (a=a v=2). + try s.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 1, + .height = 1, + .format = .rgba, + .data = .{ .complete = try alloc.dupe(u8, &.{ 255, 0, 0, 255 }) }, + }); + const anim = try alloc.create(animation.Animation); + anim.* = .{ + .state = .running, + .root_gap_ms = 10, + .max_loops = 1, + }; + s.images.getPtr(1).?.animation = anim; + try anim.frames.append(alloc, .{ + .data = try alloc.dupe(u8, &.{ 0, 0, 255, 255 }), + .gap_ms = 40, + }); + try s.addPlacement(io, alloc, t.screens.active, 1, 0, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }, + }); + + // Root shows for 10ms, frame 2 for 40ms, then the wrap exhausts + // the budget and playback freezes on the last frame for good. + try testing.expectEqual(@as(?u64, 10), s.animationTick(io, 0)); + try testing.expectEqual(@as(u32, 0), anim.current_index); + try testing.expectEqual(@as(?u64, 40), s.animationTick(io, 10)); + try testing.expectEqual(@as(u32, 1), anim.current_index); + try testing.expect(s.animationTick(io, 50) == null); + try testing.expectEqual(@as(u32, 1), anim.current_index); + try testing.expect(s.animationTick(io, 500) == null); +} + +test "storage: animation tick ignores ineligible animations" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // A placed 1x1 RGBA image with one extra frame, in the default + // stopped state. + try s.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 1, + .height = 1, + .format = .rgba, + .data = .{ .complete = try alloc.dupe(u8, &.{ 255, 0, 0, 255 }) }, + }); + const anim = try alloc.create(animation.Animation); + anim.* = .{}; + s.images.getPtr(1).?.animation = anim; + try anim.frames.append(alloc, .{ + .data = try alloc.dupe(u8, &.{ 0, 0, 255, 255 }), + .gap_ms = 40, + }); + try s.addPlacement(io, alloc, t.screens.active, 1, 0, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }, + }); + + // Stopped (the default) never advances. + try testing.expect(s.animationTick(io, 0) == null); + + // An all-gapless animation can never advance either. + anim.state = .running; + anim.frames.items[0].gap_ms = 0; + try testing.expect(s.animationTick(io, 0) == null); + try testing.expectEqual(@as(u32, 0), anim.current_index); +} + +test "storage: animation tick re-anchors a restarted clock" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // A placed, running 1x1 RGBA image displaying its extra frame, + // with a shown-at timestamp far ahead of the tick clock. + try s.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 1, + .height = 1, + .format = .rgba, + .data = .{ .complete = try alloc.dupe(u8, &.{ 255, 0, 0, 255 }) }, + }); + const anim = try alloc.create(animation.Animation); + anim.* = .{ + .state = .running, + .current_index = 1, + .frame_shown_at_ms = 1000, + }; + s.images.getPtr(1).?.animation = anim; + try anim.frames.append(alloc, .{ + .data = try alloc.dupe(u8, &.{ 0, 0, 255, 255 }), + .gap_ms = 40, + }); + try s.addPlacement(io, alloc, t.screens.active, 1, 0, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }, + }); + + // A timestamp in the future relative to now means the caller's + // clock restarted; the animation must not stall until the old + // timestamp comes around again. + try testing.expectEqual(@as(?u64, 40), s.animationTick(io, 5)); + try testing.expectEqual(@as(?u64, 5), anim.frame_shown_at_ms); +}