mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-09-15 02:12:01 +00:00
Merge branch 'ghostty-org:main' into main
This commit is contained in:
@@ -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
6
build.zig.zon.json
generated
@@ -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
8
build.zig.zon.nix
generated
@@ -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
4
build.zig.zon.txt
generated
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
368
po/zh_TW.po
368
po/zh_TW.po
@@ -2,14 +2,16 @@
|
||||
# Copyright (C) 2025 Mitchell Hashimoto
|
||||
# This file is distributed under the same license as the com.mitchellh.ghostty package.
|
||||
# Peter Dave Hello <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。"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
149
src/terminal/kitty/graphics_animation.zig
Normal file
149
src/terminal/kitty/graphics_animation.zig
Normal 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);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
235
src/terminal/kitty/graphics_pixel.zig
Normal file
235
src/terminal/kitty/graphics_pixel.zig
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user