Merge branch 'ghostty-org:main' into main

This commit is contained in:
Mohammad AlShami
2026-08-21 21:45:59 +03:00
committed by Mohammad H. AlShami
27 changed files with 3333 additions and 394 deletions

View File

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

6
build.zig.zon.json generated
View File

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

8
build.zig.zon.nix generated
View File

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

4
build.zig.zon.txt generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <hsu@peterdavehello.org>, 2025.
# Yi-Jyun Pan <me@pan93.com>, 2025.
# Alang Hsu <alang.hsu@gmail.com>, 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 <me@pan93.com>\n"
"PO-Revision-Date: 2026-08-14 10:59+0800\n"
"Last-Translator: Alang Hsu <alang.hsu@gmail.com>\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。"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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