diff --git a/.agents/skills/writing-commit-messages/SKILL.md b/.agents/skills/writing-commit-messages/SKILL.md index dedadbe5e..fef12cd30 100644 --- a/.agents/skills/writing-commit-messages/SKILL.md +++ b/.agents/skills/writing-commit-messages/SKILL.md @@ -47,7 +47,7 @@ Write commit messages that follow commit style guidelines for the project. and **how the new behavior works** at a high level. - Use plain prose, not bullet points. Wrap lines at ~72 characters. - Focus on the _why_ and _how_ rather than restating the diff. -- Keep the tone direct and technical without no filler phrases. +- Keep the tone direct and technical without filler phrases. - Don't exceed a handful of paragraphs; less is more. ## Workflow diff --git a/.github/DISCUSSION_TEMPLATE/issue-triage.yml b/.github/DISCUSSION_TEMPLATE/issue-triage.yml index 270bb86f5..a29e6f17d 100644 --- a/.github/DISCUSSION_TEMPLATE/issue-triage.yml +++ b/.github/DISCUSSION_TEMPLATE/issue-triage.yml @@ -156,6 +156,8 @@ body: options: - label: I have reviewed the FAQ and confirm that my issue is NOT among them. required: true + - label: I have searched the Ghostty documentation and confirm there doesn't exist a configuration option that solves my issue. + required: true - label: I have searched the Ghostty repository (both open and closed Discussions and Issues) and confirm this is not a duplicate of an existing issue or discussion. required: true - label: I have checked the "Preview" tab on all text fields to ensure that everything looks right, and have wrapped all configuration and code in code blocks with a group of three backticks (` ``` `) on separate lines. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 9c4455c4a..000000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,5 +0,0 @@ -blank_issues_enabled: false -contact_links: - - name: Features, Bug Reports, Questions - url: https://github.com/ghostty-org/ghostty/discussions/new/choose - about: Our preferred starting point if you have any questions or suggestions about configuration, features or behavior. diff --git a/.github/ISSUE_TEMPLATE/preapproved.md b/.github/ISSUE_TEMPLATE/preapproved.md deleted file mode 100644 index 485e727e6..000000000 --- a/.github/ISSUE_TEMPLATE/preapproved.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: Pre-Discussed and Approved Topics -about: |- - Only for topics already discussed and approved in the GitHub Discussions section. ---- - -**DO NOT OPEN A NEW ISSUE. PLEASE USE THE DISCUSSIONS SECTION.** - -**I DIDN'T READ THE ABOVE LINE. PLEASE CLOSE THIS ISSUE.** diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 4f407e3a3..b8b62d1dc 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -23,8 +23,10 @@ 00jciv00 04cb 0xdvc +12ya -4rh1t3ct0r7 52dyd +a-lang aalhendi aaron-ang abdurrahmanski @@ -38,6 +40,7 @@ akimiojr alaasdk alanmoyano alaviss +alex19ep alexbathome alexfeijoo44 alexjuca @@ -88,6 +91,7 @@ damyanbogoev danneu danulqua dariogriffo +dave92082 davidsanchez222 deblasis dervedro @@ -116,17 +120,21 @@ exlee -f1813483-netizen fallintoplace faukah +figelwump filip7 flou fornwall francescarpi fru1tworld +gadgetman6 gagbo ghokun gmile gordonbondon +gotenksin gpanders guilhermetk +-guysoft LLM communication (likely automated) h3nock hakonhagland halosatrio @@ -142,7 +150,7 @@ illiakrauchanka j0hnm4r5 jacobsandlund jake-stewart -jamesarch +-jamesarch Bad AI etiquette jamylak jarred-sumner jcollie @@ -179,6 +187,7 @@ lepips liby linustalacko lonsagisawa +lotheac louisunlimited luisnquin lynicis @@ -242,6 +251,7 @@ pluiedev pouwerkerk poweruser64 prakhar54-byte +prihlop priyans-hu puzza007 qappell @@ -270,9 +280,11 @@ sanjj1 secrus seruman seyoungjeong +shorsher silveirapf simonbcn slsrepo +steven-tk sunshine-syz svmhdvn tasselx @@ -289,6 +301,7 @@ turbolent tweedbeetle uhojin unphased +unsaltedscholar uzaaft vancluever vaughanandrews @@ -304,6 +317,7 @@ yak3d yamshta ydah -zaviro +zenangst zenyr zeshi09 zubb diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 4b6817685..dd1a23c03 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -66,7 +66,7 @@ jobs: - name: Detect changes since last tip id: changes if: github.event_name == 'workflow_run' - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 with: token: "" base: tip diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ff60754c3..ec8b8844e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter_every with: token: "" @@ -40,7 +40,7 @@ jobs: code: - '**' - '!.github/VOUCHED.td' - - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter_any with: token: "" @@ -86,6 +86,7 @@ jobs: - build-bench - build-dist - build-dist-lib-vt + - build-example-zig-vt-macos-no-sdk - build-examples-zig - build-examples-cmake - build-examples-cmake-windows @@ -239,6 +240,38 @@ jobs: cd example/${{ matrix.dir }} nix develop -c zig build + build-example-zig-vt-macos-no-sdk: + name: Example zig-vt (macOS, no SDK) + runs-on: namespace-profile-ghostty-macos-tahoe + needs: test + env: + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1 + with: + cache: | + xcode + path: | + /Users/runner/zig + + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 + - uses: DeterminateSystems/nix-installer-action@main + with: + determinate: true + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Build Example without SDK + working-directory: example/zig-vt + run: nix develop -c env DEVELOPER_DIR=/nonexistent zig build + build-examples-cmake: strategy: fail-fast: false @@ -1131,14 +1164,6 @@ jobs: COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES - # Build the iOS target without code signing just to verify it works. - - name: Build Ghostty iOS - run: | - cd macos - xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" \ - COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ - COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES - build-macos-xcode-27: runs-on: namespace-profile-ghostty-macos-tahoe needs: test diff --git a/HACKING.md b/HACKING.md index 042687be6..ed9c84591 100644 --- a/HACKING.md +++ b/HACKING.md @@ -50,7 +50,7 @@ dependencies: ### macOS Building the Ghostty macOS app requires that Xcode, the macOS SDK, -the iOS SDK, and Metal Toolchain are all installed. +and Metal Toolchain are all installed. A common issue is that the incorrect version of Xcode is either installed or selected. Use the `xcode-select` command to diff --git a/build.zig b/build.zig index c371119f5..acf19ac91 100644 --- a/build.zig +++ b/build.zig @@ -101,8 +101,10 @@ pub fn build(b: *std.Build) !void { if (config.emit_webdata) webdata.install(); // Ghostty bench tools - const bench = try buildpkg.GhosttyBench.init(b, &deps); - if (config.emit_bench) bench.install(); + if (config.emit_bench) { + const bench = try buildpkg.GhosttyBench.init(b, &deps); + bench.install(); + } // Ghostty dist tarball const dist = try buildpkg.GhosttyDist.init(b, &config); diff --git a/build.zig.zon b/build.zig.zon index 3629c10cb..f97fddc23 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -125,8 +125,8 @@ .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .android_ndk = .{ .path = "./pkg/android-ndk" }, .iterm2_themes = .{ - .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260720-153658-97e244c.tgz", - .hash = "N-V-__8AAGxKBAAjYkT1jYUiOZDXQuj0u34ndIjAWxiR1KX9", + .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260803-155300-875a82f.tgz", + .hash = "N-V-__8AAPpcBAD4_75xLBbLiYqdojOwQP74eoWmpL3jPrBl", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index b31016fd6..9c6e316bf 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -59,10 +59,10 @@ "url": "https://deps.files.ghostty.org/N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI.tar.gz", "hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs=" }, - "N-V-__8AAGxKBAAjYkT1jYUiOZDXQuj0u34ndIjAWxiR1KX9": { + "N-V-__8AAPpcBAD4_75xLBbLiYqdojOwQP74eoWmpL3jPrBl": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260720-153658-97e244c.tgz", - "hash": "sha256-cynQ4ulY7oQE5RamVQvQczTtxhEzSnP4TVBHfapFnww=" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260803-155300-875a82f.tgz", + "hash": "sha256-/qxLa9S8bSg2NAbJzwEtZhGvOxaPTAVFNWnBCs/MFBs=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 6d03043e5..01bc76f67 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -206,11 +206,11 @@ in }; } { - name = "N-V-__8AAGxKBAAjYkT1jYUiOZDXQuj0u34ndIjAWxiR1KX9"; + name = "N-V-__8AAPpcBAD4_75xLBbLiYqdojOwQP74eoWmpL3jPrBl"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/ghostty-themes-release-20260720-153658-97e244c.tgz"; - hash = "sha256-cynQ4ulY7oQE5RamVQvQczTtxhEzSnP4TVBHfapFnww="; + url = "https://deps.files.ghostty.org/ghostty-themes-release-20260803-155300-875a82f.tgz"; + hash = "sha256-/qxLa9S8bSg2NAbJzwEtZhGvOxaPTAVFNWnBCs/MFBs="; unpack = false; }; } diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 357e48126..9c726c1fc 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -9,7 +9,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918 https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz -https://deps.files.ghostty.org/ghostty-themes-release-20260720-153658-97e244c.tgz +https://deps.files.ghostty.org/ghostty-themes-release-20260803-155300-875a82f.tgz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz diff --git a/example/c-vt-effects/src/main.c b/example/c-vt-effects/src/main.c index c40f8fa88..bc5ece06d 100644 --- a/example/c-vt-effects/src/main.c +++ b/example/c-vt-effects/src/main.c @@ -70,6 +70,32 @@ GhosttyClipboardWriteResult on_clipboard_write( } //! [effects-clipboard-write] +//! [effects-unknown-sequence] +void on_unknown_sequence( + GhosttyTerminal terminal, + void* userdata, + const GhosttyTerminalUnknownSequence* sequence) { + (void)terminal; + (void)userdata; + + switch (sequence->tag) { + case GHOSTTY_TERMINAL_UNKNOWN_SEQUENCE_APC: { + const GhosttyTerminalUnknownStringSequence* apc = &sequence->value.apc; + printf(" unknown APC (truncated=%s, content=%zu bytes): ", + apc->truncated ? "yes" : "no", + apc->content.len); + if (apc->content.len > 0) { + fwrite(apc->content.ptr, 1, apc->content.len, stdout); + } + printf("\n"); + break; + } + default: + break; + } +} +//! [effects-unknown-sequence] + //! [effects-register] int main() { // Create a terminal @@ -92,6 +118,14 @@ int main() { (const void *)on_title_changed); ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE, (const void *)on_clipboard_write); + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE, + (const void *)on_unknown_sequence); + + // Unknown sequence capture is independently bounded and disabled by + // default. This limit will apply to every supported unknown sequence type. + size_t unknown_max_bytes = 256; + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_UNKNOWN_MAX_BYTES, + &unknown_max_bytes); // Feed VT data that triggers effects: @@ -120,7 +154,13 @@ int main() { ghostty_terminal_vt_write(terminal, (const uint8_t*)clipboard_seq, strlen(clipboard_seq)); - // 5. Another bell to show the counter increments + // 5. Unsupported APC sequence + printf("Sending unknown APC:\n"); + const char* unknown_apc = "\x1B_private-command;payload\x1B\\"; + ghostty_terminal_vt_write(terminal, (const uint8_t*)unknown_apc, + strlen(unknown_apc)); + + // 6. Another bell to show the counter increments printf("Sending another BEL:\n"); ghostty_terminal_vt_write(terminal, &bel, 1); diff --git a/example/c-vt-snapshot/README.md b/example/c-vt-snapshot/README.md new file mode 100644 index 000000000..f9e9c01f6 --- /dev/null +++ b/example/c-vt-snapshot/README.md @@ -0,0 +1,15 @@ +# Example: Terminal Snapshots in C + +This example creates a terminal with continuation tracking, encodes its full +state, and restores the snapshot using both the one-shot and incremental C +decoder APIs. The incremental path uses a synchronous `GhosttyReader` callback +and reports each restored history page. The standalone project links the static +libghostty-vt artifact so it can run consistently on every supported host. + +## Usage + +Run the example: + +```shell-session +zig build run +``` diff --git a/example/c-vt-snapshot/build.zig b/example/c-vt-snapshot/build.zig new file mode 100644 index 000000000..50c5c5f5a --- /dev/null +++ b/example/c-vt-snapshot/build.zig @@ -0,0 +1,33 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + exe_mod.addCMacro("GHOSTTY_STATIC", ""); + + if (b.lazyDependency("ghostty", .{})) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt-static")); + } + + const exe = b.addExecutable(.{ + .name = "c_vt_snapshot", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-snapshot/build.zig.zon b/example/c-vt-snapshot/build.zig.zon new file mode 100644 index 000000000..3514a1166 --- /dev/null +++ b/example/c-vt-snapshot/build.zig.zon @@ -0,0 +1,14 @@ +.{ + .name = .c_vt_snapshot, + .version = "0.0.0", + .fingerprint = 0xff13ccc637fd5383, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + .ghostty = .{ .path = "../../" }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-snapshot/src/main.c b/example/c-vt-snapshot/src/main.c new file mode 100644 index 000000000..3849bff91 --- /dev/null +++ b/example/c-vt-snapshot/src/main.c @@ -0,0 +1,162 @@ +#include +#include +#include +#include +#include +#include + +//! [snapshot-buffer-reader] +typedef struct { + const uint8_t *data; + size_t len; + size_t offset; +} BufferReader; + +// GhosttyReader callbacks are synchronous. A successful zero-byte read is +// permanent EOF; returning false would report an I/O error. +static bool buffer_read(void *userdata, + uint8_t *buffer, + size_t capacity, + size_t *out_read) { + BufferReader *reader = userdata; + size_t remaining = reader->len - reader->offset; + size_t count = remaining < capacity ? remaining : capacity; + + // Deliberately return short reads to demonstrate that the decoder retries. + if (count > 64) count = 64; + memcpy(buffer, reader->data + reader->offset, count); + reader->offset += count; + *out_read = count; + return true; +} +//! [snapshot-buffer-reader] + +int main(void) { + GhosttyResult result; + + //! [snapshot-encode] + GhosttyTerminal source = NULL; + // A wide, shallow screen fills backing pages quickly enough to leave older + // PAGE records after READY for the incremental decoder to demonstrate. + result = ghostty_terminal_new(NULL, &source, 215, 2); + assert(result == GHOSTTY_SUCCESS); + + // Snapshot encoding requires continuation tracking to be enabled before + // feeding input. The limit bounds unfinished VT sequence retention. + const size_t continuation_limit = 1024; + result = ghostty_terminal_set( + source, + GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES, + &continuation_limit); + assert(result == GHOSTTY_SUCCESS); + + // Keep enough scrollback to demonstrate incremental history restoration. + result = ghostty_terminal_set( + source, GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_BYTES, NULL); + assert(result == GHOSTTY_SUCCESS); + + const char *line = "snapshot history line\r\n"; + for (size_t i = 0; i < 1000; i++) { + ghostty_terminal_vt_write( + source, (const uint8_t *)line, strlen(line)); + } + + // Leave an SGR sequence unfinished so its continuation is snapshotted too. + const char *unfinished = "\x1b[31"; + ghostty_terminal_vt_write( + source, (const uint8_t *)unfinished, strlen(unfinished)); + + uint8_t *snapshot = NULL; + size_t snapshot_len = 0; + result = ghostty_snapshot_encode_alloc( + source, NULL, &snapshot, &snapshot_len); + assert(result == GHOSTTY_SUCCESS); + printf("encoded %zu snapshot bytes\n", snapshot_len); + //! [snapshot-encode] + + //! [snapshot-decode] + GhosttySnapshotDecoder full_decoder = NULL; + result = ghostty_snapshot_decoder_new_buf( + NULL, &full_decoder, snapshot, snapshot_len); + assert(result == GHOSTTY_SUCCESS); + + GhosttyTerminal full_terminal = NULL; + result = ghostty_snapshot_decoder_decode(full_decoder, &full_terminal); + assert(result == GHOSTTY_SUCCESS); + + ghostty_snapshot_decoder_free(full_decoder); + ghostty_terminal_free(full_terminal); + //! [snapshot-decode] + + //! [snapshot-incremental] + BufferReader reader_state = { + .data = snapshot, + .len = snapshot_len, + .offset = 0, + }; + GhosttyReader reader = { + .read = buffer_read, + .userdata = &reader_state, + }; + + GhosttySnapshotDecoder incremental_decoder = NULL; + result = ghostty_snapshot_decoder_new( + NULL, &incremental_decoder, reader); + assert(result == GHOSTTY_SUCCESS); + + // READY returns a validated, renderable terminal before old history. + GhosttyTerminal incremental_terminal = NULL; + result = ghostty_snapshot_decoder_ready( + incremental_decoder, &incremental_terminal); + assert(result == GHOSTTY_SUCCESS); + + uint64_t history_rows = 0; + result = ghostty_snapshot_decoder_get( + incremental_decoder, + GHOSTTY_SNAPSHOT_DECODER_DATA_HISTORY_ROWS_PRIMARY, + &history_rows); + assert(result == GHOSTTY_SUCCESS); + printf("snapshot advertises %llu primary history rows\n", + (unsigned long long)history_rows); + + size_t page_count = 0; + while ((result = ghostty_snapshot_decoder_next(incremental_decoder)) == + GHOSTTY_SUCCESS) { + GhosttyTerminalScreen screen; + size_t rows = 0; + uint32_t remaining = 0; + const GhosttySnapshotDecoderData keys[] = { + GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_SCREEN, + GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_ROWS, + GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_REMAINING, + }; + void *values[] = {&screen, &rows, &remaining}; + size_t written = 0; + result = ghostty_snapshot_decoder_get_multi( + incremental_decoder, + sizeof(keys) / sizeof(keys[0]), + keys, + values, + &written); + assert(result == GHOSTTY_SUCCESS); + assert(written == sizeof(keys) / sizeof(keys[0])); + + printf("restored %zu rows to screen %d (%u pages remain)\n", + rows, (int)screen, remaining); + page_count++; + } + + // NO_VALUE means FINISH validated successfully and is idempotent. + assert(result == GHOSTTY_NO_VALUE); + assert(page_count > 0); + assert(ghostty_snapshot_decoder_next(incremental_decoder) == + GHOSTTY_NO_VALUE); + + ghostty_snapshot_decoder_free(incremental_decoder); + ghostty_terminal_free(incremental_terminal); + //! [snapshot-incremental] + + ghostty_free(NULL, snapshot, snapshot_len); + ghostty_terminal_free(source); + return 0; +} diff --git a/flatpak/dependencies.yml b/flatpak/dependencies.yml index 87512a547..c1a9450e1 100644 --- a/flatpak/dependencies.yml +++ b/flatpak/dependencies.yml @@ -13,12 +13,12 @@ modules: - chmod a+x /app/zig/zig sources: - type: archive - sha256: 02aa270f183da276e5b5920b1dac44a63f1a49e55050ebde3aecc9eb82f93239 - url: https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz + sha256: 70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00 + url: https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz only-arches: [x86_64] - type: archive - sha256: 958ed7d1e00d0ea76590d27666efbf7a932281b3d7ba0c6b01b0ff26498f667f - url: https://ziglang.org/download/0.15.2/zig-aarch64-linux-0.15.2.tar.xz + sha256: ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17 + url: https://ziglang.org/download/0.16.0/zig-aarch64-linux-0.16.0.tar.xz only-arches: [aarch64] - name: bzip2-redirect diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 02fd91b02..d03df3d72 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -73,9 +73,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260720-153658-97e244c.tgz", - "dest": "vendor/p/N-V-__8AAGxKBAAjYkT1jYUiOZDXQuj0u34ndIjAWxiR1KX9", - "sha256": "7329d0e2e958ee8404e516a6550bd07334edc611334a73f84d50477daa459f0c" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260803-155300-875a82f.tgz", + "dest": "vendor/p/N-V-__8AAPpcBAD4_75xLBbLiYqdojOwQP74eoWmpL3jPrBl", + "sha256": "feac4b6bd4bc6d28363406c9cf012d6611af3b168f4c05453569c10acfcc141b" }, { "type": "archive", diff --git a/include/ghostty.h b/include/ghostty.h index 72bbb57a8..5523654c4 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -1,10 +1,14 @@ -// Ghostty embedding API. The documentation for the embedding API is -// only within the Zig source files that define the implementations. This -// isn't meant to be a general purpose embedding API (yet) so there hasn't -// been documentation or example work beyond that. +// Ghostty's internal embedder API, a.k.a. "libghostty-internal". // -// The only consumer of this API is the macOS app, but the API is built to -// be more general purpose. +// The only consumer of this API is the macOS app, and while it is fairly +// comprehensive, it is tailored to the needs of the macOS app and not designed +// for external use, hence why most functions are undocumented and some are +// macOS-specific (e.g. ones dealing with the Metal graphics API). +// +// External embedders should instead use `libghostty-vt` or other related +// packages, which are extensively documented and designed from the ground up +// to be used in other software. Header files for which can be found in +// `include/ghostty/`. #ifndef GHOSTTY_H #define GHOSTTY_H @@ -645,6 +649,12 @@ typedef enum { GHOSTTY_INSPECTOR_HIDE, } ghostty_action_inspector_e; +// apprt.action.ExportTerminalIO.C +typedef struct { + const char* contents; + size_t len; +} ghostty_action_export_terminal_io_s; + // apprt.action.QuitTimer typedef enum { GHOSTTY_QUIT_TIMER_START, @@ -672,6 +682,7 @@ typedef struct { typedef enum { GHOSTTY_PROMPT_TITLE_SURFACE, GHOSTTY_PROMPT_TITLE_TAB, + GHOSTTY_PROMPT_TITLE_WINDOW, } ghostty_action_prompt_title_e; // apprt.action.Pwd.C @@ -679,6 +690,14 @@ typedef struct { const char* pwd; } ghostty_action_pwd_s; +// apprt.action.OpenConfig +typedef enum { + // Open the config in the OS default editor. + GHOSTTY_ACTION_OPEN_CONFIG_OS_OPEN, + // Open the config in a new window using $EDITOR or $VISUAL + GHOSTTY_ACTION_OPEN_CONFIG_NEW_WINDOW, +} ghostty_action_open_config_e; + // terminal.MouseShape typedef enum { GHOSTTY_MOUSE_SHAPE_DEFAULT, @@ -812,6 +831,7 @@ typedef enum { GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN, GHOSTTY_ACTION_OPEN_URL_KIND_TEXT, GHOSTTY_ACTION_OPEN_URL_KIND_HTML, + GHOSTTY_ACTION_OPEN_URL_KIND_OSC8, } ghostty_action_open_url_kind_e; // apprt.action.OpenUrl.C @@ -914,9 +934,11 @@ typedef enum { GHOSTTY_ACTION_INSPECTOR, GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, GHOSTTY_ACTION_RENDER_INSPECTOR, + GHOSTTY_ACTION_EXPORT_TERMINAL_IO, GHOSTTY_ACTION_DESKTOP_NOTIFICATION, GHOSTTY_ACTION_SET_TITLE, GHOSTTY_ACTION_SET_TAB_TITLE, + GHOSTTY_ACTION_SET_WINDOW_TITLE, GHOSTTY_ACTION_PROMPT_TITLE, GHOSTTY_ACTION_PWD, GHOSTTY_ACTION_MOUSE_SHAPE, @@ -949,6 +971,7 @@ typedef enum { GHOSTTY_ACTION_SEARCH_SELECTED, GHOSTTY_ACTION_READONLY, GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD, + GHOSTTY_ACTION_MOVE_TAB_TO_NEW_WINDOW, } ghostty_action_tag_e; typedef union { @@ -964,6 +987,7 @@ typedef union { ghostty_action_cell_size_s cell_size; ghostty_action_scrollbar_s scrollbar; ghostty_action_inspector_e inspector; + ghostty_action_export_terminal_io_s export_terminal_io; ghostty_action_desktop_notification_s desktop_notification; ghostty_action_set_title_s set_title; ghostty_action_set_title_s set_tab_title; @@ -990,6 +1014,7 @@ typedef union { ghostty_action_search_total_s search_total; ghostty_action_search_selected_s search_selected; ghostty_action_readonly_e readonly; + ghostty_action_open_config_e open_config; } ghostty_action_u; typedef struct { @@ -1055,6 +1080,7 @@ typedef union { // apprt.ipc.Action.Key typedef enum { GHOSTTY_IPC_ACTION_NEW_WINDOW, + GHOSTTY_IPC_ACTION_NEW_TAB, GHOSTTY_IPC_ACTION_TOGGLE_QUICK_TERMINAL, } ghostty_ipc_action_tag_e; diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 5606f1690..7519a3f25 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -31,12 +31,14 @@ * - @ref terminal "Terminal" - Complete terminal emulator state and rendering * - @ref render "Render State" - Incremental render state updates for custom renderers * - @ref formatter "Formatter" - Format terminal content as plain text, VT sequences, or HTML + * - @ref snapshot "Terminal Snapshot" - Encode and incrementally restore terminal state * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences * - @ref paste "Paste Utilities" - Validate paste data safety * - @ref unicode "Unicode Utilities" - Codepoint properties for text layout * - @ref build_info "Build Info" - Query compile-time build configuration * - @ref allocator "Memory Management" - Memory management and custom allocators + * - @ref io "Byte-stream I/O" - Reusable synchronous reader and writer callbacks * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions * * Encoding related APIs: @@ -140,6 +142,7 @@ extern "C" { #include #include #include +#include #include #include #include @@ -153,6 +156,7 @@ extern "C" { #include #include #include +#include #include #include diff --git a/include/ghostty/vt/io.h b/include/ghostty/vt/io.h new file mode 100644 index 000000000..2d8e67029 --- /dev/null +++ b/include/ghostty/vt/io.h @@ -0,0 +1,107 @@ +/** + * @file io.h + * + * Generic IO callbacks for libghostty-vt. + */ + +#ifndef GHOSTTY_VT_IO_H +#define GHOSTTY_VT_IO_H + +#include +#include +#include + +/** @defgroup io I/O + * + * Synchronous callback interfaces used by APIs that consume or produce byte + * streams. The callback and userdata pointers must remain valid for the full + * lifetime documented by the API receiving a GhosttyReader or GhosttyWriter. + * + * @{ + */ + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Read bytes from a source. + * + * The callback must set @p out_read to a value no greater than @p capacity + * when returning true. A positive value reports progress; it may be less than + * capacity and does not indicate end-of-file. A zero value is definitive + * end-of-file. It must not be used to report temporary input starvation or a + * would-block condition. + * + * Returning false reports a fatal read error and the value of @p out_read is + * ignored. The library does not inspect or modify errno. + * + * All pointer arguments are borrowed and valid only for the duration of the + * callback. The callback is invoked synchronously on the calling thread. + * + * @param userdata Opaque userdata from GhosttyReader + * @param buffer Destination for read bytes; always non-NULL + * @param capacity Writable capacity of @p buffer; always greater than zero + * @param[out] out_read Number of bytes read when returning true; non-NULL + * @return true for a successful read or end-of-file, false for a fatal error + */ +typedef bool (*GhosttyReaderFn)( + void* userdata, + uint8_t* buffer, + size_t capacity, + size_t* out_read); + +/** + * Write bytes to a destination. + * + * Returning true means all @p len bytes were accepted. Returning false + * reports a fatal write error. A callback wrapping an interface that permits + * partial writes must retry internally until the full slice is accepted or + * an error occurs. + * + * On failure, the destination may already contain a prefix of the bytes. The + * calling operation fails and must not be resumed from that partial output. + * The library does not inspect or modify errno. + * + * @p data is borrowed and valid only for the duration of the callback. The + * callback is invoked synchronously on the calling thread. Successful return + * means the bytes were handed to the destination; it does not imply that the + * destination was flushed or made durable. + * + * @param userdata Opaque userdata from GhosttyWriter + * @param data Source bytes; always non-NULL + * @param len Number of source bytes; always greater than zero + * @return true if the complete slice was accepted, false on fatal error + */ +typedef bool (*GhosttyWriterFn)( + void* userdata, + const uint8_t* data, + size_t len); + +/** + * A byte source callback and its opaque context. + * + * The struct is passed by value. @p read must be non-NULL. + */ +typedef struct { + GhosttyReaderFn read; + void* userdata; +} GhosttyReader; + +/** + * A byte destination callback and its opaque context. + * + * The struct is passed by value. @p write must be non-NULL. + */ +typedef struct { + GhosttyWriterFn write; + void* userdata; +} GhosttyWriter; + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_IO_H */ diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h index dda8b836e..22f6ac864 100644 --- a/include/ghostty/vt/kitty_graphics.h +++ b/include/ghostty/vt/kitty_graphics.h @@ -390,7 +390,8 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Borrowed pointer to the raw pixel data. Valid as long as the - * underlying terminal is not mutated. + * underlying terminal is not mutated. Returns GHOSTTY_NO_VALUE when + * the image metadata is resident but its pixel payload is pending. * * The data is always fully decoded, uncompressed pixels in the * format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads @@ -404,7 +405,9 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Length of the raw pixel data in bytes. Always equal to - * width * height * bytes-per-pixel for the reported format. + * width * height * bytes-per-pixel for the reported format. For a + * pending image, this is the expected length reserved against the + * storage limit even though DATA_PTR is not available yet. * * Output type: size_t * */ @@ -421,7 +424,10 @@ typedef enum GHOSTTY_ENUM_TYPED { * Stamps are unique and monotonically increasing process-wide and * are drawn from the same sequence as * GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION. Never zero for a stored - * image, so zero can be used as an "empty" sentinel by callers. + * image, so zero can be used as an "empty" sentinel by callers. Pending + * payload completion preserves this value to retain image age; consumers + * detect that completion through GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION + * and retry DATA_PTR. * * Output type: uint64_t * */ diff --git a/include/ghostty/vt/snapshot.h b/include/ghostty/vt/snapshot.h new file mode 100644 index 000000000..fb09d2415 --- /dev/null +++ b/include/ghostty/vt/snapshot.h @@ -0,0 +1,525 @@ +/** + * @file snapshot.h + * + * Encode and restore complete terminal snapshots. + */ + +#ifndef GHOSTTY_VT_SNAPSHOT_H +#define GHOSTTY_VT_SNAPSHOT_H + +#include +#include + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup snapshot Terminal Snapshot + * + * Encode and restore the complete state of a terminal via a binary format. + * + * A snapshot is an ordered, CRC-protected record stream. Its READY marker + * follows enough state to render and resume the terminal, including any + * unfinished VT parser input. Older scrollback pages follow READY and the + * FINISH marker terminates the complete snapshot. + * + * End-of-file before an operation's required READY or FINISH marker is + * malformed, truncated snapshot data and returns GHOSTTY_INVALID_VALUE. + * GHOSTTY_IO_ERROR is reserved for a reader callback that returns false. + * + * ## Examples + * + * The complete working example is available in `example/c-vt-snapshot`. + * + * ### Encode a terminal and its unfinished VT continuation + * @snippet c-vt-snapshot/src/main.c snapshot-encode + * + * ### Restore a complete snapshot in one call + * @snippet c-vt-snapshot/src/main.c snapshot-decode + * + * ### Adapt a byte source to GhosttyReader + * @snippet c-vt-snapshot/src/main.c snapshot-buffer-reader + * + * ### Restore READY first, then incrementally prepend history + * @snippet c-vt-snapshot/src/main.c snapshot-incremental + * + * ## Format + * + * Every integer is unsigned and little-endian. The stream begins with this + * fixed ten-byte envelope: + * + * @code{.unparsed} + * byte 0 8 10 + * +---------------+--------+ + * | "GHOSTSNP" | version| + * | 8-byte magic | u16 | + * +---------------+--------+ + * @endcode + * + * The envelope is followed by independently checksummed records. A record's + * CRC32C covers its encoded tag and payload length followed by its payload; it + * does not cover the CRC field itself. + * + * @code{.unparsed} + * byte 0 2 6 10 10 + payload_len + * +-------+-------------+-----------+----------------+ + * | tag | payload_len | CRC32C | payload | + * | u16 | u32 | u32 | payload_len B | + * +-------+-------------+-----------+----------------+ + * \____________________/ \______________/ + * CRC prefix CRC suffix + * @endcode + * + * Record groups occur in this strict order. SCREEN and HISTORY groups contain + * one entry for each screen declared by TERMINAL. Each manifest is followed + * by the number of PAGE records it declares. Active SCREEN pages make the + * terminal renderable; HISTORY pages are older scrollback ordered newest to + * oldest so an incremental decoder can prepend them as they arrive. + * + * @code{.unparsed} + * +---------------- TERMINAL ----------------+ + * | terminal-wide state and screen count | + * +----------------- SCREEN -----------------+ repeated per screen + * | active-screen manifest | + * +------------------ PAGE ------------------+ repeated per manifest + * | active screen rows | + * +------------- CONTINUATION ---------------+ + * | unfinished VT/UTF-8 input, or ground | + * +------------------ READY -----------------+ + * | empty renderable-state marker | ready() returns here + * +----------------- HISTORY ----------------+ repeated per screen + * | scrollback manifest | + * +------------------ PAGE ------------------+ next() consumes one page + * | older screen rows | + * +------------------ FINISH ----------------+ + * | empty end-of-snapshot marker | next() returns NO_VALUE + * +------------------------------------------+ + * | trailing transport bytes (not consumed) | + * +------------------------------------------+ + * @endcode + * + * READY separates the renderable prefix through CONTINUATION from history. + * FINISH terminates the record sequence. Both are empty records protected by + * CRC32C, like every other record. Declared record counts, tags, and strict + * decoding enforce the stream's ordering and completeness. + * + * Snapshot format version 1 is a work in progress and does not yet carry a + * binary-compatibility guarantee. + * + * @see Snapshot format and Zig codec documentation + * + * @{ + */ + +/** + * Configurable snapshot decoder options. + * + * Options may only be changed before decoding starts. Calling + * ghostty_snapshot_decoder_set() after ghostty_snapshot_decoder_ready() or + * ghostty_snapshot_decoder_decode() returns GHOSTTY_INVALID_VALUE. + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** + * Largest non-ground continuation the decoder will accept. + * + * A value of zero accepts only snapshots whose VT parser is in the ground + * state. The decoder default matches the largest built-in APC protocol + * buffer limit, currently 65 MiB. + * + * This is an input validation limit only. It does not configure continuation + * tracking on a terminal returned by the decoder. + * + * Input type: size_t * + */ + GHOSTTY_SNAPSHOT_DECODER_OPT_MAX_CONTINUATION_BYTES = 0, + + GHOSTTY_SNAPSHOT_DECODER_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySnapshotDecoderOption; + +/** + * Queryable snapshot decoder data. + * + * Each variant documents the output pointer type expected by + * ghostty_snapshot_decoder_get(). + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid data type. Never results in data extraction. */ + GHOSTTY_SNAPSHOT_DECODER_DATA_INVALID = 0, + + /** + * Current maximum accepted continuation size. + * + * This value is available in every non-failed decoder state. + * + * Output type: size_t * + */ + GHOSTTY_SNAPSHOT_DECODER_DATA_MAX_CONTINUATION_BYTES = 1, + + /** + * Number of snapshot source bytes consumed so far. + * + * At FINISH this identifies the first byte after the snapshot. Trailing + * bytes are not consumed. This value is unavailable after a decoding error, + * because the decoder can no longer guarantee its source position. + * + * Output type: size_t * + */ + GHOSTTY_SNAPSHOT_DECODER_DATA_SOURCE_OFFSET = 2, + + /** + * Advisory complete logical history extent for the primary screen. + * + * The value counts rows before the active area, including any resident + * overlap carried before READY. It becomes available after READY validates. + * + * Output type: uint64_t * + */ + GHOSTTY_SNAPSHOT_DECODER_DATA_HISTORY_ROWS_PRIMARY = 3, + + /** + * Advisory complete logical history extent for the alternate screen. + * + * The value has the same semantics and lifetime as + * GHOSTTY_SNAPSHOT_DECODER_DATA_HISTORY_ROWS_PRIMARY. Querying it returns + * GHOSTTY_NO_VALUE when the snapshot does not declare an alternate screen. + * + * Output type: uint64_t * + */ + GHOSTTY_SNAPSHOT_DECODER_DATA_HISTORY_ROWS_ALTERNATE = 4, + + /** + * Screen associated with the most recently decoded history page. + * + * This value is available only after ghostty_snapshot_decoder_next() + * returns GHOSTTY_SUCCESS. A later call to next replaces it or clears it + * when FINISH is reached or an error occurs. + * + * Output type: GhosttyTerminalScreen * + */ + GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_SCREEN = 5, + + /** + * Rows prepended by the most recently decoded history page. + * + * Zero means the page was consumed and validated but could not be + * applied to the live terminal. + * + * Output type: size_t * + */ + GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_ROWS = 6, + + /** + * Page records remaining in the same screen's HISTORY sequence. + * + * This is not a count of all pages remaining in the snapshot. + * + * Output type: uint32_t * + */ + GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_REMAINING = 7, + + GHOSTTY_SNAPSHOT_DECODER_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySnapshotDecoderData; + +/** + * Encode a complete terminal snapshot to a writer. + * + * The terminal's persistent VT stream supplies the continuation bytes needed + * to reconstruct unfinished parser state. The caller must prevent concurrent + * writes or other terminal mutation for the duration of this call. The writer + * callback must not call terminal APIs with the same terminal handle. + * A terminal can be encoded with tracking disabled when its VT parser and + * UTF-8 decoder are both at ground. If either is unfinished, tracking must + * have been enabled before the input that produced that state was written; + * otherwise this returns GHOSTTY_INVALID_VALUE. + * + * Encoding begins at the writer's current position. If an error occurs, the + * writer may contain a partial snapshot without a valid FINISH marker. + * Calls to the writer are synchronous; this function does not flush or make + * the caller's destination durable. + * + * @param terminal Terminal to encode (must not be NULL) + * @param writer Destination writer whose write callback must not be NULL + * @return GHOSTTY_SUCCESS on success, GHOSTTY_IO_ERROR if the writer rejects + * output, GHOSTTY_LIMIT_EXCEEDED if output accounting overflows, or + * another error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_encode(GhosttyTerminal terminal, + GhosttyWriter writer); + +/** + * Encode a complete terminal snapshot to a caller-provided buffer. + * + * Pass NULL for buf with buf_len zero to query the required size. If the + * buffer is too small, this returns GHOSTTY_OUT_OF_SPACE and stores the + * required capacity in out_written. A non-NULL undersized buffer may contain + * a partial snapshot prefix. On success, out_written receives the number of + * bytes encoded. + * + * A terminal can be encoded with tracking disabled when its VT parser and + * UTF-8 decoder are both at ground. If either is unfinished, tracking must + * have been enabled before the input that produced that state was written; + * otherwise this returns GHOSTTY_INVALID_VALUE. + * + * @param terminal Terminal to encode (must not be NULL) + * @param buf Destination buffer, or NULL when buf_len is zero + * @param buf_len Destination buffer capacity in bytes + * @param[out] out_written Bytes written, or required capacity on + * GHOSTTY_OUT_OF_SPACE (must not be NULL) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_encode_buf( + GhosttyTerminal terminal, + uint8_t* buf, + size_t buf_len, + size_t* out_written); + +/** + * Encode a complete terminal snapshot to an allocated buffer. + * + * The returned buffer is allocated with allocator, or the default allocator + * when allocator is NULL. The caller must release it with ghostty_free(), + * passing the same allocator used here. + * + * A terminal can be encoded with tracking disabled when its VT parser and + * UTF-8 decoder are both at ground. If either is unfinished, tracking must + * have been enabled before the input that produced that state was written; + * otherwise this returns GHOSTTY_INVALID_VALUE. + * + * @param terminal Terminal to encode (must not be NULL) + * @param allocator Allocator for the output, or NULL for the default allocator + * @param[out] out_ptr Allocated snapshot bytes (must not be NULL) + * @param[out] out_len Number of allocated snapshot bytes (must not be NULL) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_encode_alloc( + GhosttyTerminal terminal, + const GhosttyAllocator* allocator, + uint8_t** out_ptr, + size_t* out_len); + +/** + * Create a snapshot decoder that reads from a caller-provided reader. + * + * The decoder stores a copy of reader. Its read callback must not be NULL, and + * both the callback and its caller-owned context must remain valid until + * FINISH is reached or the decoder is freed. Reads are synchronous and occur + * only during ready, next, or decode calls. A zero-byte successful read is + * permanent end-of-file, not temporary starvation; nonblocking sources must + * wait outside the decoder or block in their callback. The read callback must + * not call APIs, including ghostty_snapshot_decoder_free(), on the decoder + * that owns it. Returning false reports GHOSTTY_IO_ERROR; returning true with + * zero bytes before a required marker reports truncated snapshot data as + * GHOSTTY_INVALID_VALUE. + * + * @param allocator Allocator for decoder and decoded terminal state, or NULL + * for the default allocator + * @param decoder Pointer to receive the decoder handle (must not be NULL) + * @param reader Snapshot source reader + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_new( + const GhosttyAllocator* allocator, + GhosttySnapshotDecoder* decoder, + GhosttyReader reader); + +/** + * Create a snapshot decoder over a borrowed byte buffer. + * + * The bytes are not copied. ptr must remain valid and immutable until FINISH + * is reached or the decoder is freed. Bytes after FINISH are not consumed; + * query GHOSTTY_SNAPSHOT_DECODER_DATA_SOURCE_OFFSET to locate them. + * + * @param allocator Allocator for decoder and decoded terminal state, or NULL + * for the default allocator + * @param decoder Pointer to receive the decoder handle (must not be NULL) + * @param ptr Snapshot source bytes + * @param len Number of source bytes + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_new_buf( + const GhosttyAllocator* allocator, + GhosttySnapshotDecoder* decoder, + const uint8_t* ptr, + size_t len); + +/** + * Free a snapshot decoder. + * + * This does not release the caller's ownership of a terminal returned by + * ready or decode. Abandoning an incremental decode leaves that terminal + * usable with whatever history had already been restored. + * + * @param decoder Decoder to free (may be NULL) + * + * @ingroup snapshot + */ +GHOSTTY_API void ghostty_snapshot_decoder_free(GhosttySnapshotDecoder decoder); + +/** + * Set a snapshot decoder option. + * + * The value pointer must have the type documented by option. Options may only + * be changed before decoding starts. + * + * @param decoder Decoder handle (must not be NULL) + * @param option Option to change + * @param value Pointer to the option value (must not be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if decoding has + * started or an argument is invalid, or another error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_set( + GhosttySnapshotDecoder decoder, + GhosttySnapshotDecoderOption option, + const void* value); + +/** + * Decode and validate the renderable snapshot prefix through READY. + * + * On success, terminal receives a caller-owned terminal with its persistent + * VT stream already restored from the snapshot continuation. The terminal is + * immediately usable for rendering and live input. Older scrollback remains + * to be restored with ghostty_snapshot_decoder_next(). + * + * The restored parser state may be unfinished, but terminal continuation + * tracking is disabled; GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES returns + * zero. The decoder's continuation option is an input limit, not terminal + * runtime policy. + * + * The caller must keep the returned terminal alive until FINISH validates or + * the decoder is freed. The decoder borrows this terminal handle while it + * restores history; ghostty_snapshot_decoder_next() uses it automatically. + * + * This operation may only be called once and only before decoding starts. + * terminal is set to NULL on every error. A decoding, I/O, or allocation + * error after input consumption begins poisons the decoder, after which it + * must be freed. An invalid argument or lifecycle error detected before the + * operation consumes input does not poison it. + * + * @param decoder Decoder handle (must not be NULL) + * @param[out] terminal Pointer to receive the terminal (must not be NULL) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_ready( + GhosttySnapshotDecoder decoder, + GhosttyTerminal* terminal); + +/** + * Decode one history page into the terminal returned by READY. + * + * Each GHOSTTY_SUCCESS consumes and validates one PAGE record. Query the + * GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_* values before calling next again. + * GHOSTTY_NO_VALUE means FINISH was validated; repeated calls after FINISH + * also return GHOSTTY_NO_VALUE. + * + * The terminal may be rendered, resized, and fed live PTY input between calls. + * If a history page can no longer be applied safely, it is still consumed and + * validated and progress reports zero rows. The decoder applies history + * to the caller-owned terminal produced by its READY operation. + * + * A decoding error invalidates the decoder's source position. The terminal + * remains caller-owned and usable with its already-restored history, but only + * ghostty_snapshot_decoder_free() may subsequently be called on the decoder. + * + * @param decoder Decoder handle (must not be NULL) + * @return GHOSTTY_SUCCESS for one page, GHOSTTY_NO_VALUE after FINISH, or an + * error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_next( + GhosttySnapshotDecoder decoder); + +/** + * Decode and validate one complete snapshot. + * + * This is the one-shot form of READY followed by all history pages through + * FINISH. It may only be called before decoding starts. Bytes following FINISH + * are left unread. On success terminal receives a caller-owned terminal with + * its persistent VT stream restored. Continuation tracking on the returned + * terminal is disabled and GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES + * returns zero. terminal is set to NULL on every error. + * A decoding, I/O, or allocation error after input consumption begins poisons + * the decoder, after which it must be freed. An invalid argument or + * lifecycle error detected before the operation consumes input does not + * poison it. + * + * @param decoder Decoder handle (must not be NULL) + * @param[out] terminal Pointer to receive the terminal (must not be NULL) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_decode( + GhosttySnapshotDecoder decoder, + GhosttyTerminal* terminal); + +/** + * Get typed data from a snapshot decoder. + * + * The output pointer must have the type documented by data. A phase-dependent + * value that is not currently available returns GHOSTTY_NO_VALUE. + * + * @param decoder Decoder handle (must not be NULL) + * @param data Data kind to query + * @param[out] out Pointer to receive the value (must not be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the requested data + * is unavailable, or another error code on failure + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_get( + GhosttySnapshotDecoder decoder, + GhosttySnapshotDecoderData data, + void* out); + +/** + * Get multiple snapshot decoder data fields in a single call. + * + * Each keys element selects a data kind and the corresponding values element + * points to storage of the documented output type. Processing stops at the + * first error. On success out_written is set to count; on error it is set to + * the number of values written before the failing key. Invalid array arguments + * report zero values written. + * + * @param decoder Decoder handle (must not be NULL) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers corresponding to keys + * @param[out] out_written Number of successfully written values (may be NULL) + * @return GHOSTTY_SUCCESS if every query succeeds, or the first error + * + * @ingroup snapshot + */ +GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_get_multi( + GhosttySnapshotDecoder decoder, + size_t count, + const GhosttySnapshotDecoderData* keys, + void** values, + size_t* out_written); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_SNAPSHOT_H */ diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 9480d37a3..57d0e5d03 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -53,17 +54,17 @@ extern "C" { * * ## Effects * - * By default, the terminal sequence processing with ghostty_terminal_vt_write() - * only process sequences that directly affect terminal state and + * By default, terminal sequence processing with the VT write functions only + * processes sequences that directly affect terminal state and * ignores sequences that have side effect behavior or require responses. * These sequences include things like bell characters, title changes, device * attributes queries, and more. To handle these sequences, the embedder * must configure "effects." * * Effects are callbacks that the terminal invokes in response to VT - * sequences processed during ghostty_terminal_vt_write(). They let the - * embedding application react to terminal-initiated events such as bell - * characters, title changes, device status report responses, and more. + * sequences processed during VT writes. They let the embedding application + * react to terminal-initiated events such as bell characters, title changes, + * device status report responses, and more. * * Each effect is registered with ghostty_terminal_set() using the * corresponding `GhosttyTerminalOption` identifier. A `NULL` value @@ -74,9 +75,10 @@ extern "C" { * back to their own application state without global variables. * You cannot specify different userdata for different callbacks. * - * All callbacks are invoked synchronously during - * ghostty_terminal_vt_write(). Callbacks **must not** call - * ghostty_terminal_vt_write() on the same terminal (no reentrancy). + * All callbacks are invoked synchronously during VT writes. Callbacks + * **must not** call ghostty_terminal_vt_write() or + * ghostty_terminal_vt_write_until_ground() on the same terminal + * (no reentrancy). * And callbacks must be very careful to not block for too long or perform * expensive operations, since they are blocking further IO processing. * @@ -96,6 +98,7 @@ extern "C" { * | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 | * | `GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION`| `GhosttyTerminalDesktopNotificationFn` | Desktop notification via OSC 9 / OSC 777 | * | `GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT` | `GhosttyTerminalProgressReportFn` | Progress report via OSC 9;4 | + * | `GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE` | `GhosttyTerminalUnknownSequenceFn` | Unsupported sequence identifier | * * ### Defining a write_pty callback * @snippet c-vt-effects/src/main.c effects-write-pty @@ -109,6 +112,9 @@ extern "C" { * ### Defining a clipboard_write callback * @snippet c-vt-effects/src/main.c effects-clipboard-write * + * ### Defining an unknown_sequence callback + * @snippet c-vt-effects/src/main.c effects-unknown-sequence + * * ### Registering effects and processing VT data * @snippet c-vt-effects/src/main.c effects-register * @@ -330,6 +336,88 @@ typedef struct { typedef void (*GhosttyTerminalBellFn)(GhosttyTerminal terminal, void* userdata); +/** + * Unsupported terminal sequence tags. + * + * Only APC sequences are currently reported. Additional sequence types may + * be added without changing the callback shape. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Application Program Command (APC). */ + GHOSTTY_TERMINAL_UNKNOWN_SEQUENCE_APC = 0, + GHOSTTY_TERMINAL_UNKNOWN_SEQUENCE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalUnknownSequenceTag; + +/** + * An unsupported string terminal sequence. + * + * The content is borrowed and valid only for the callback duration. It + * contains the bytes between the sequence introducer and terminator, may + * contain arbitrary binary data, and is not null-terminated. + * + * @ingroup terminal + */ +typedef struct { + /** Whether content was shortened by the byte limit or allocation failure. */ + bool truncated; + + /** Retained sequence content. */ + GhosttyString content; +} GhosttyTerminalUnknownStringSequence; + +/** + * Unsupported terminal sequence value. + * + * @ingroup terminal + */ +typedef union { + /** Application Program Command (APC). */ + GhosttyTerminalUnknownStringSequence apc; + + /** + * Padding for ABI compatibility. Do not use. + * + * 128 bytes leaves room for future structured sequence payloads, such as + * CSI with borrowed parameter, separator, and intermediate arrays, without + * changing the tagged union's ABI. + */ + uint64_t _padding[16]; +} GhosttyTerminalUnknownSequenceValue; + +/** + * An unsupported terminal sequence. + * + * @ingroup terminal + */ +typedef struct { + GhosttyTerminalUnknownSequenceTag tag; + GhosttyTerminalUnknownSequenceValue value; +} GhosttyTerminalUnknownSequence; + +/** + * Callback function type for unsupported terminal sequences. + * + * Called synchronously for normally terminated sequences whose identifier is + * not supported by the active terminal handler. Aborted sequences, malformed + * recognized commands, and explicitly disabled known protocols are ignored. + * + * Capture must also be enabled with a nonzero + * GHOSTTY_TERMINAL_OPT_UNKNOWN_MAX_BYTES value. Installing this callback alone + * does not retain sequence content or allocate memory. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param sequence Borrowed unsupported sequence + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalUnknownSequenceFn)( + GhosttyTerminal terminal, + void* userdata, + const GhosttyTerminalUnknownSequence* sequence); + /** * Clipboard destination for a clipboard write. * @@ -694,6 +782,24 @@ typedef void (*GhosttyTerminalWritePtyFn)(GhosttyTerminal terminal, typedef GhosttyString (*GhosttyTerminalXtversionFn)(GhosttyTerminal terminal, void* userdata); +/** + * A terminal mode and boolean value used for mode configuration and queries. + * + * For GHOSTTY_TERMINAL_DATA_MODE, initialize `mode` before calling + * ghostty_terminal_get(). On success, `value` contains the current mode value. + * + * This struct has a frozen layout and will not gain fields in future versions. + * + * @ingroup terminal + */ +typedef struct { + /** Mode to configure or query. */ + GhosttyMode mode; + + /** Value to set, or the current value returned by a query. */ + bool value; +} GhosttyTerminalModeConfig; + /** * Terminal option identifiers. * @@ -1027,6 +1133,100 @@ typedef enum GHOSTTY_ENUM_TYPED { * Input type: GhosttyTerminalProgressReportFn */ GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT = 30, + + /** + * Set the maximum number of replay-safe VT continuation bytes retained. + * + * Continuation bytes reconstruct an escape sequence or UTF-8 codepoint + * which was unfinished at the end of the most recent + * VT write call. They are used automatically by terminal snapshots and may + * also be exported directly with the continuation APIs. + * + * Tracking is disabled by default. A nonzero value enables tracking and + * sets its byte limit. Passing NULL or a pointer to zero disables tracking. + * Lowering the limit below an already-retained + * continuation, or enabling tracking while the parser is already + * unfinished, makes the current continuation unavailable because earlier + * bytes cannot be reconstructed. Tracking recovers automatically after a + * later write reaches the ground state or contains a fresh replay start. + * + * Input type: size_t* + */ + GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES = 31, + + /** + * Enable window title reports in response to CSI 21 t. + * + * This is disabled by default because a running program can set a title and + * query it back into the pty input stream, potentially injecting commands + * that execute after user interaction. Passing NULL or a pointer to false + * disables title reporting. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_TITLE_REPORT = 32, + + /** + * Set the reset default for a terminal mode. + * + * This unconditionally updates both the current value and the value restored + * by a full terminal reset (RIS). + * + * Some recognized modes represent transitions or mirror additional terminal + * state and cannot safely be configured as reset defaults. Those modes return + * GHOSTTY_INVALID_VALUE. A NULL value pointer also returns + * GHOSTTY_INVALID_VALUE. + * + * Input type: GhosttyTerminalModeConfig* + */ + GHOSTTY_TERMINAL_OPT_MODE_DEFAULT = 33, + + /** + * Set the current value of a terminal mode. + * + * This does not change the value restored by a full terminal reset (RIS). + * A NULL value pointer or unknown mode returns GHOSTTY_INVALID_VALUE. + * + * Input type: GhosttyTerminalModeConfig* + */ + GHOSTTY_TERMINAL_OPT_MODE = 34, + + /** + * Callback invoked for unsupported terminal sequence identifiers. Set to + * NULL to ignore unsupported sequences. Capture must also be enabled with + * GHOSTTY_TERMINAL_OPT_UNKNOWN_MAX_BYTES. + * + * Input type: GhosttyTerminalUnknownSequenceFn + */ + GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE = 35, + + /** + * Set the maximum content bytes retained for each unsupported terminal + * sequence. A NULL value pointer or zero disables capture and prevents + * unknown-sequence callbacks. + * + * When this limit is hit, the unknown sequence callback will still + * be invoked but `truncated` will be set to true. + * + * Input type: size_t* + */ + GHOSTTY_TERMINAL_OPT_UNKNOWN_MAX_BYTES = 36, + + /** + * Set the name of the terminfo entry this terminal runs as, reported + * in response to an XTGETTCAP query for "TN" (e.g. "xterm-256color"). + * + * The string data is copied into the terminal. A NULL value pointer + * clears the name (equivalent to setting an empty string). A name + * longer than 128 bytes returns GHOSTTY_INVALID_VALUE. + * + * If this is unset then we don't report anything for an XTGETTCAP + * TN query, because we don't know what the embedding terminal around + * libghostty is advertising itself as. + * + * Input type: GhosttyString* + */ + GHOSTTY_TERMINAL_OPT_TERMINFO_NAME = 37, GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalOption; @@ -1138,9 +1338,9 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * The terminal title as set by escape sequences (e.g. OSC 0/2). * - * Returns a borrowed string. The pointer is valid until the next call - * to ghostty_terminal_vt_write() or ghostty_terminal_reset(). An empty - * string (len=0) is returned when no title has been set. + * Returns a borrowed string. The pointer is valid until the next mutating + * terminal call. An empty string (len=0) is returned when no title has been + * set. * * Output type: GhosttyString * */ @@ -1150,9 +1350,9 @@ typedef enum GHOSTTY_ENUM_TYPED { * The terminal's current working directory as set by escape sequences * (e.g. OSC 7). * - * Returns a borrowed string. The pointer is valid until the next call - * to ghostty_terminal_vt_write() or ghostty_terminal_reset(). An empty - * string (len=0) is returned when no pwd has been set. + * Returns a borrowed string. The pointer is valid until the next mutating + * terminal call. An empty string (len=0) is returned when no pwd has been + * set. * * Output type: GhosttyString * */ @@ -1376,6 +1576,54 @@ typedef enum GHOSTTY_ENUM_TYPED { * Output type: size_t * */ GHOSTTY_TERMINAL_DATA_SCROLLBACK_MAX_LINES = 35, + + /** + * The configured maximum retained VT continuation size in bytes. + * + * A value of zero means continuation tracking is disabled. This reports the + * configured limit even when a current unfinished continuation is + * temporarily unavailable. + * + * Output type: size_t * + */ + GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES = 36, + + /** + * Get the current value of a terminal mode. + * + * The caller must initialize the `mode` field. On success, the `value` field + * is updated with the current value. A NULL pointer or unknown mode returns + * GHOSTTY_INVALID_VALUE. + * + * Input/output type: GhosttyTerminalModeConfig * + */ + GHOSTTY_TERMINAL_DATA_MODE = 37, + + /** + * Whether VT processing is at ground. + * + * Ground is when the stream isn't in the middle of any type of sequence: + * UTF-8, ESC, CSI, OSC, etc. It is the stateless point of the stream. + * + * This is useful to know because it is a point at which you can + * safely insert out-of-band VT sequences. For example, while reading + * from a pty if you want to make your own changes, you can wait until + * the pty input reaches ground, then write yours. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_VT_GROUND = 38, + + /** + * Whether the cursor is currently at a semantic shell prompt or input area. + * + * This depends on semantic prompt markers such as OSC 133. Returns false + * when semantic prompt information is unavailable or the alternate screen + * is active. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT = 39, GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalData; @@ -1461,9 +1709,10 @@ GHOSTTY_API GhosttyResult ghostty_terminal_resize(GhosttyTerminal terminal, * The behavior of a NULL value is specific to each option and is * documented by the corresponding GhosttyTerminalOption value. * - * Callbacks are invoked synchronously during ghostty_terminal_vt_write(). - * Callbacks must not call ghostty_terminal_vt_write() on the same - * terminal (no reentrancy). + * Callbacks are invoked synchronously during VT writes. Callbacks must not + * call ghostty_terminal_vt_write() or + * ghostty_terminal_vt_write_until_ground() on the same terminal + * (no reentrancy). * * @param terminal The terminal handle (may be NULL, in which case this is a no-op) * @param option The option to set @@ -1501,6 +1750,129 @@ GHOSTTY_API void ghostty_terminal_vt_write(GhosttyTerminal terminal, const uint8_t* data, size_t len); +/** + * Write VT-encoded data, but only the shortest prefix needed to reach ground. + * + * Ground is when the stream isn't in the middle of any type of sequence: + * UTF-8, ESC, CSI, OSC, etc. It is the stateless point of the stream. + * + * This is useful to know because it is a point at which you can + * safely insert out-of-band VT sequences. For example, while reading + * from a pty if you want to make your own changes, you can wait until + * the pty input reaches ground, then write yours. + * + * If the stream is already at ground then this consumes nothing and returns + * GHOSTTY_SUCCESS. On success, out_consumed is the number of bytes consumed + * before reaching ground, including the byte that reaches it. + * GHOSTTY_NO_VALUE means the full slice was consumed without reaching ground. + * + * @param terminal The terminal handle (must not be NULL) + * @param data Pointer to the data to write, or NULL when len is zero + * @param len Length of the data in bytes + * @param[out] out_consumed Number of bytes consumed (must not be NULL) + * @return GHOSTTY_SUCCESS if ground was reached, GHOSTTY_NO_VALUE if all input + * was consumed without reaching ground, or GHOSTTY_INVALID_VALUE if + * an argument is invalid + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_vt_write_until_ground( + GhosttyTerminal terminal, + const uint8_t* data, + size_t len, + size_t* out_consumed); + +/** + * Write the terminal's replay-safe VT continuation to a callback writer. + * + * The continuation is the exact byte suffix needed to reconstruct unfinished + * VT parser or UTF-8 decoder state in an equivalent terminal. It is empty + * when the stream is at ground. The callback is invoked synchronously and + * may be called more than once. It must not call terminal APIs with the same + * terminal handle. + * + * Continuation tracking must have been enabled by setting + * GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the + * input that produced the continuation was written. + * + * The caller must serialize this operation with both VT write functions and + * all other access to the same terminal. + * + * @param terminal Terminal to read from (must not be NULL) + * @param writer Destination writer whose write callback must not be NULL + * @return GHOSTTY_SUCCESS on success, GHOSTTY_IO_ERROR if the callback rejects + * a write, GHOSTTY_LIMIT_EXCEEDED if output accounting overflows, or + * GHOSTTY_INVALID_VALUE if an argument is invalid, tracking is + * disabled, or the current continuation is unavailable + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_continuation_write( + GhosttyTerminal terminal, + GhosttyWriter writer); + +/** + * Copy the terminal's replay-safe VT continuation into a caller buffer. + * + * Pass NULL for buf with buf_len zero to query the required size. A size query + * returns GHOSTTY_OUT_OF_SPACE and stores the required size in out_written, + * including zero when the stream is at ground. If a non-NULL buffer is too + * small, the function has the same result and reports the full required size. + * Continuation tracking must have been enabled by setting + * GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the + * input that produced the continuation was written. + * + * The caller must serialize this operation with all other access to the same + * terminal. + * + * @param terminal Terminal to read from (must not be NULL) + * @param buf Destination buffer, or NULL when buf_len is zero + * @param buf_len Destination buffer capacity in bytes + * @param[out] out_written Bytes written, or required size on + * GHOSTTY_OUT_OF_SPACE (must not be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE for a size query or + * insufficient buffer, or GHOSTTY_INVALID_VALUE if an argument is + * invalid, tracking is disabled, or the current continuation is + * unavailable + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_continuation_buf( + GhosttyTerminal terminal, + uint8_t* buf, + size_t buf_len, + size_t* out_written); + +/** + * Return an allocated copy of the terminal's replay-safe VT continuation. + * + * The returned bytes are allocated with allocator, or the default allocator + * when allocator is NULL. The caller must release them with ghostty_free(), + * passing the same allocator and returned length. An empty continuation is a + * successful zero-length allocation. + * Continuation tracking must have been enabled by setting + * GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the + * input that produced the continuation was written. + * + * The caller must serialize this operation with all other access to the same + * terminal. + * + * @param terminal Terminal to read from (must not be NULL) + * @param allocator Allocator for the output, or NULL for the default allocator + * @param[out] out_ptr Allocated continuation bytes (must not be NULL) + * @param[out] out_len Number of continuation bytes (must not be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation + * failure, or GHOSTTY_INVALID_VALUE if an argument is invalid, + * tracking is disabled, or the current continuation is unavailable + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_continuation_alloc( + GhosttyTerminal terminal, + const GhosttyAllocator* allocator, + uint8_t** out_ptr, + size_t* out_len); + /** * Scroll the terminal viewport. * @@ -1574,41 +1946,6 @@ GHOSTTY_API GhosttyResult ghostty_terminal_compress( GhosttyTerminalCompressionMode mode, GhosttyTerminalCompressionResult* out_result); -/** - * Get the current value of a terminal mode. - * - * Returns the value of the mode identified by the given mode. - * - * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) - * @param mode The mode identifying the mode to query - * @param[out] out_value On success, set to true if the mode is set, false - * if it is reset - * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal - * is NULL or the mode does not correspond to a known mode - * - * @ingroup terminal - */ -GHOSTTY_API GhosttyResult ghostty_terminal_mode_get(GhosttyTerminal terminal, - GhosttyMode mode, - bool* out_value); - -/** - * Set the value of a terminal mode. - * - * Sets the mode identified by the given mode to the specified value. - * - * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) - * @param mode The mode identifying the mode to set - * @param value true to set the mode, false to reset it - * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal - * is NULL or the mode does not correspond to a known mode - * - * @ingroup terminal - */ -GHOSTTY_API GhosttyResult ghostty_terminal_mode_set(GhosttyTerminal terminal, - GhosttyMode mode, - bool value); - /** * Get data from a terminal instance. * diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index 214d28229..672faa67c 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -82,6 +82,10 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_OUT_OF_SPACE = -3, /** The requested value has no value */ GHOSTTY_NO_VALUE = -4, + /** Operation failed while reading from or writing to external I/O */ + GHOSTTY_IO_ERROR = -5, + /** Operation failed because encoded input exceeded a configured limit */ + GHOSTTY_LIMIT_EXCEEDED = -6, GHOSTTY_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyResult; @@ -94,6 +98,13 @@ typedef enum GHOSTTY_ENUM_TYPED { */ typedef struct GhosttyTerminalImpl* GhosttyTerminal; +/** + * Opaque handle to an incremental terminal snapshot decoder. + * + * @ingroup snapshot + */ +typedef struct GhosttySnapshotDecoderImpl* GhosttySnapshotDecoder; + /** * Opaque handle to a tracked grid reference. * diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 6d883ded8..96cbaca0a 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -15,10 +15,7 @@ 8F3A9B4C2FA6B88000A18D13 /* Ghostty.sdef in Resources */ = {isa = PBXBuildFile; fileRef = 8F3A9B4B2FA6B88000A18D13 /* Ghostty.sdef */; }; 9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; }; A51BFC272B30F1B800E92F16 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A51BFC262B30F1B800E92F16 /* Sparkle */; }; - A53D0C8E2B53B0EA00305CE6 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; - A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A546F1142D7B68D7003B11A0 /* locale in Resources */ = {isa = PBXBuildFile; fileRef = A546F1132D7B68D7003B11A0 /* locale */; }; - A553F4132E06EB1600257779 /* Ghostty.icon in Resources */ = {isa = PBXBuildFile; fileRef = A553F4122E06EB1600257779 /* Ghostty.icon */; }; A553F4142E06EB1600257779 /* Ghostty.icon in Resources */ = {isa = PBXBuildFile; fileRef = A553F4122E06EB1600257779 /* Ghostty.icon */; }; A56B880B2A840447007A0E29 /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A56B880A2A840447007A0E29 /* Carbon.framework */; }; A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; @@ -89,7 +86,6 @@ A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; - A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Ghostty-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; FC5218F92D10FFC7004C93E0 /* zsh */ = {isa = PBXFileReference; lastKnownFileType = folder; name = zsh; path = "../zig-out/share/zsh"; sourceTree = ""; }; FC9ABA9B2D0F538D0020D4C8 /* bash-completion */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "bash-completion"; path = "../zig-out/share/bash-completion"; sourceTree = ""; }; @@ -106,164 +102,16 @@ "Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift", Ghostty/Ghostty.ConfigTypes.swift, Ghostty/GhosttyPackageMeta.swift, - Helpers/CrossKit.swift, "Helpers/Extensions/NSImage+Extension.swift", "Helpers/Extensions/OSColor+Extension.swift", "Helpers/Extensions/OSPasteboard+Extension.swift", ); target = 8193244C2F24E6C000A9ED8F /* DockTilePlugin */; }; - 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - App/macOS/AppDelegate.swift, - "App/macOS/AppDelegate+Ghostty.swift", - App/macOS/main.swift, - App/macOS/MainMenu.xib, - Features/About/About.xib, - Features/About/AboutController.swift, - Features/About/AboutView.swift, - Features/About/AboutViewModel.swift, - Features/About/CyclingIconView.swift, - "Features/App Intents/CloseTerminalIntent.swift", - "Features/App Intents/CommandPaletteIntent.swift", - "Features/App Intents/Entities/CommandEntity.swift", - "Features/App Intents/Entities/TerminalEntity.swift", - "Features/App Intents/FocusTerminalIntent.swift", - "Features/App Intents/GetTerminalDetailsIntent.swift", - "Features/App Intents/GhosttyIntentError.swift", - "Features/App Intents/InputIntent.swift", - "Features/App Intents/IntentPermission.swift", - "Features/App Intents/KeybindIntent.swift", - "Features/App Intents/NewTerminalIntent.swift", - "Features/App Intents/QuickTerminalIntent.swift", - "Features/AppleScript/AppDelegate+AppleScript.swift", - "Features/AppleScript/Ghostty.Input.Mods+AppleScript.swift", - Features/AppleScript/ScriptInputTextCommand.swift, - Features/AppleScript/ScriptKeyEventCommand.swift, - Features/AppleScript/ScriptMouseButtonCommand.swift, - Features/AppleScript/ScriptMousePosCommand.swift, - Features/AppleScript/ScriptMouseScrollCommand.swift, - Features/AppleScript/ScriptRecord.swift, - Features/AppleScript/ScriptSurfaceConfiguration.swift, - Features/AppleScript/ScriptTab.swift, - Features/AppleScript/ScriptTerminal.swift, - Features/AppleScript/ScriptWindow.swift, - Features/ClipboardConfirmation/ClipboardConfirmation.xib, - Features/ClipboardConfirmation/ClipboardConfirmationController.swift, - Features/ClipboardConfirmation/ClipboardConfirmationView.swift, - "Features/Command Palette/CommandPalette.swift", - "Features/Command Palette/TerminalCommandPalette.swift", - "Features/Custom App Icon/AppIcon.swift", - "Features/Custom App Icon/ColorizedGhosttyIcon.swift", - "Features/Custom App Icon/ColorizedGhosttyIconImage.swift", - "Features/Custom App Icon/ColorizedGhosttyIconView.swift", - "Features/Custom App Icon/DockTilePlugin.swift", - "Features/Custom App Icon/Extensions/Notification+AppIcon.swift", - "Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift", - "Features/Global Keybinds/GlobalEventTap.swift", - Features/QuickTerminal/QuickTerminal.xib, - Features/QuickTerminal/QuickTerminalController.swift, - Features/QuickTerminal/QuickTerminalPosition.swift, - Features/QuickTerminal/QuickTerminalRestorableState.swift, - Features/QuickTerminal/QuickTerminalScreen.swift, - Features/QuickTerminal/QuickTerminalScreenStateCache.swift, - Features/QuickTerminal/QuickTerminalSize.swift, - Features/QuickTerminal/QuickTerminalSpaceBehavior.swift, - Features/QuickTerminal/QuickTerminalWindow.swift, - "Features/Secure Input/SecureInput.swift", - "Features/Secure Input/SecureInputOverlay.swift", - Features/Services/ServiceProvider.swift, - Features/Settings/ConfigurationErrors.xib, - Features/Settings/ConfigurationErrorsController.swift, - Features/Settings/ConfigurationErrorsView.swift, - Features/Settings/SettingsView.swift, - Features/Splits/SplitTree.swift, - Features/Splits/SplitView.Divider.swift, - Features/Splits/SplitView.swift, - Features/Splits/TerminalSplitTreeView.swift, - Features/Terminal/BaseTerminalController.swift, - Features/Terminal/ErrorView.swift, - Features/Terminal/TerminalController.swift, - Features/Terminal/TerminalRestorable.swift, - "Features/Terminal/TerminalRestorableState+InteralState.swift", - Features/Terminal/TerminalTabColor.swift, - Features/Terminal/TerminalView.swift, - Features/Terminal/TerminalViewContainer.swift, - "Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift", - "Features/Terminal/Window Styles/Terminal.xib", - "Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib", - "Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.xib", - "Features/Terminal/Window Styles/TerminalTabsTitlebarVentura.xib", - "Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib", - "Features/Terminal/Window Styles/TerminalWindow.swift", - "Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift", - "Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift", - "Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift", - Features/Update/UpdateBadge.swift, - Features/Update/UpdateController.swift, - Features/Update/UpdateDelegate.swift, - Features/Update/UpdateDriver.swift, - Features/Update/UpdatePill.swift, - Features/Update/UpdatePopoverView.swift, - Features/Update/UpdateSimulator.swift, - Features/Update/UpdateViewModel.swift, - "Ghostty/FullscreenMode+Extension.swift", - Ghostty/Ghostty.Error.swift, - Ghostty/Ghostty.Event.swift, - Ghostty/Ghostty.Input.swift, - Ghostty/Ghostty.MenuShortcutManager.swift, - Ghostty/Ghostty.Surface.swift, - "Ghostty/NSEvent+Extension.swift", - "Ghostty/Surface View/InspectorView.swift", - "Ghostty/Surface View/SurfaceDragSource.swift", - "Ghostty/Surface View/SurfaceGrabHandle.swift", - "Ghostty/Surface View/SurfaceScrollView.swift", - "Ghostty/Surface View/SurfaceView_AppKit.swift", - Helpers/AppInfo.swift, - Helpers/CodableBridge.swift, - Helpers/Cursor.swift, - Helpers/ExpiringUndoManager.swift, - "Helpers/Extensions/Double+Extension.swift", - "Helpers/Extensions/EventModifiers+Extension.swift", - "Helpers/Extensions/FileHandle+Extension.swift", - "Helpers/Extensions/KeyboardShortcut+Extension.swift", - "Helpers/Extensions/NSAppearance+Extension.swift", - "Helpers/Extensions/NSApplication+Extension.swift", - "Helpers/Extensions/NSColor+Extension.swift", - "Helpers/Extensions/NSImage+Extension.swift", - "Helpers/Extensions/NSMenu+Extension.swift", - "Helpers/Extensions/NSMenuItem+Extension.swift", - "Helpers/Extensions/NSPasteboard+Extension.swift", - "Helpers/Extensions/NSScreen+Extension.swift", - "Helpers/Extensions/NSView+Extension.swift", - "Helpers/Extensions/NSWindow+Extension.swift", - "Helpers/Extensions/NSWorkspace+Extension.swift", - "Helpers/Extensions/Transferable+Extension.swift", - "Helpers/Extensions/UndoManager+Extension.swift", - "Helpers/Extensions/View+Extension.swift", - Helpers/Fullscreen.swift, - Helpers/HostingWindow.swift, - Helpers/KeyboardLayout.swift, - Helpers/LastWindowPosition.swift, - Helpers/MetalView.swift, - Helpers/NonDraggableHostingView.swift, - Helpers/ObjCExceptionCatcher.m, - Helpers/PermissionRequest.swift, - Helpers/Private/CGS.swift, - Helpers/Private/Dock.swift, - Helpers/TabGroupCloseCoordinator.swift, - Helpers/TabTitleEditor.swift, - Helpers/VibrantLayer.m, - ); - target = A5D4499C2B53AE7B000F5B83 /* Ghostty-iOS */; - }; 81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - App/iOS/iOSApp.swift, "Features/Custom App Icon/DockTilePlugin.swift", - "Ghostty/Surface View/SurfaceView_UIKit.swift", ); target = A5B30530299BEAAA0047F10C /* Ghostty */; }; @@ -271,7 +119,7 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 810ACCA02E9D3302004F8F92 /* GhosttyUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = GhosttyUITests; sourceTree = ""; }; - 81F82BC72E82815D001EDFA7 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 8193245D2F24E80800A9ED8F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = ""; }; + 81F82BC72E82815D001EDFA7 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 8193245D2F24E80800A9ED8F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = ""; }; A54F45F42E1F047A0046BD5C /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -307,14 +155,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - A5D4499A2B53AE7B000F5B83 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - A53D0C8E2B53B0EA00305CE6 /* GhosttyKit.xcframework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -358,7 +198,6 @@ isa = PBXGroup; children = ( A5B30531299BEAAA0047F10C /* Ghostty.app */, - A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */, A54F45F32E1F047A0046BD5C /* GhosttyTests.xctest */, 810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */, 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */, @@ -469,26 +308,6 @@ productReference = A5B30531299BEAAA0047F10C /* Ghostty.app */; productType = "com.apple.product-type.application"; }; - A5D4499C2B53AE7B000F5B83 /* Ghostty-iOS */ = { - isa = PBXNativeTarget; - buildConfigurationList = A5D449AB2B53AE7B000F5B83 /* Build configuration list for PBXNativeTarget "Ghostty-iOS" */; - buildPhases = ( - A5D449992B53AE7B000F5B83 /* Sources */, - A5D4499A2B53AE7B000F5B83 /* Frameworks */, - A5D4499B2B53AE7B000F5B83 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - 81F82BC72E82815D001EDFA7 /* Sources */, - ); - name = "Ghostty-iOS"; - productName = "Ghostty-iOS"; - productReference = A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */; - productType = "com.apple.product-type.application"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -514,9 +333,6 @@ CreatedOnToolsVersion = 14.2; LastSwiftMigration = 1510; }; - A5D4499C2B53AE7B000F5B83 = { - CreatedOnToolsVersion = 15.2; - }; }; }; buildConfigurationList = A5B3052C299BEAAA0047F10C /* Build configuration list for PBXProject "Ghostty" */; @@ -536,7 +352,6 @@ projectRoot = ""; targets = ( A5B30530299BEAAA0047F10C /* Ghostty */, - A5D4499C2B53AE7B000F5B83 /* Ghostty-iOS */, 8193244C2F24E6C000A9ED8F /* DockTilePlugin */, A54F45F22E1F047A0046BD5C /* GhosttyTests */, 810ACC9E2E9D3301004F8F92 /* GhosttyUITests */, @@ -587,15 +402,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - A5D4499B2B53AE7B000F5B83 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */, - A553F4132E06EB1600257779 /* Ghostty.icon in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -650,13 +456,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - A5D449992B53AE7B000F5B83 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -783,7 +582,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OBJC_BRIDGING_HEADER = "Sources/App/macOS/ghostty-bridging-header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "Sources/App/ghostty-bridging-header.h"; SWIFT_VERSION = 5.0; }; name = ReleaseLocal; @@ -1180,7 +979,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty.debug; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OBJC_BRIDGING_HEADER = "Sources/App/macOS/ghostty-bridging-header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "Sources/App/ghostty-bridging-header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; }; @@ -1235,128 +1034,11 @@ PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OBJC_BRIDGING_HEADER = "Sources/App/macOS/ghostty-bridging-header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "Sources/App/ghostty-bridging-header.h"; SWIFT_VERSION = 5.0; }; name = Release; }; - A5D449A82B53AE7B000F5B83 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; - ENABLE_PREVIEWS = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Ghostty; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.1; - "OTHER_LDFLAGS[arch=*]" = "-lstdc++"; - PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-ios"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - A5D449A92B53AE7B000F5B83 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; - ENABLE_PREVIEWS = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Ghostty; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.1; - "OTHER_LDFLAGS[arch=*]" = "-lstdc++"; - PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-ios"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - A5D449AA2B53AE7B000F5B83 /* ReleaseLocal */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; - ENABLE_PREVIEWS = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Ghostty; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 0.1; - "OTHER_LDFLAGS[arch=*]" = "-lstdc++"; - PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-ios"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = ReleaseLocal; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -1410,16 +1092,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = ReleaseLocal; }; - A5D449AB2B53AE7B000F5B83 /* Build configuration list for PBXNativeTarget "Ghostty-iOS" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - A5D449A82B53AE7B000F5B83 /* Debug */, - A5D449A92B53AE7B000F5B83 /* Release */, - A5D449AA2B53AE7B000F5B83 /* ReleaseLocal */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = ReleaseLocal; - }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/macos/Sources/App/macOS/AppDelegate+Ghostty.swift b/macos/Sources/App/AppDelegate+Ghostty.swift similarity index 100% rename from macos/Sources/App/macOS/AppDelegate+Ghostty.swift rename to macos/Sources/App/AppDelegate+Ghostty.swift diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/AppDelegate.swift similarity index 98% rename from macos/Sources/App/macOS/AppDelegate.swift rename to macos/Sources/App/AppDelegate.swift index 5003e3d32..127f3a014 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/AppDelegate.swift @@ -92,6 +92,15 @@ class AppDelegate: NSObject, /// seconds since the process was launched. private var applicationLaunchTime: TimeInterval = 0 + /// AppKit treats positional command-line arguments as documents to open. This + /// filter consumes the corresponding open-file events for arguments following + /// `-e`. It is initialized lazily because most launches never open a file. + private lazy var commandLineOpenFileFilter = CommandLineOpenFileFilter( + arguments: CommandLine.arguments, + workingDirectory: FileManager.default.currentDirectoryPath, + fileExists: { FileManager.default.fileExists(atPath: $0) } + ) + /// This is the current configuration from the Ghostty configuration that we need. private var derivedConfig: DerivedConfig = DerivedConfig() @@ -376,18 +385,7 @@ class AppDelegate: NSObject, // If we've already accepted to install an update, then we don't need to // confirm quit. The user is already expecting the update to happen. - if updateController.isInstalling { - return .terminateNow - } - - // This probably isn't fully safe. The isEmpty check above is aspirational, it doesn't - // quite work with SwiftUI because windows are retained on close. So instead we check - // if there are any that are visible. I'm guessing this breaks under certain scenarios. - // - // NOTE(mitchellh): I don't think we need this check at all anymore. I'm keeping it - // here because I don't want to remove it in a patch release cycle but we should - // target removing it soon. - if (windows.allSatisfy { !$0.isVisible }) { + if updateController.shouldTerminateWithoutWarning { return .terminateNow } @@ -446,6 +444,14 @@ class AppDelegate: NSObject, } func application(_ sender: NSApplication, openFile filename: String) -> Bool { + // `-e` makes existing path arguments part of the child command, but + // AppKit also reports those paths as documents to open. Only consume + // matching command arguments so unrelated Finder or Dock requests work. + if commandLineOpenFileFilter.shouldIgnore(filename) { + Self.logger.debug("ignoring command argument open-file event path=\(filename, privacy: .public)") + return true + } + // Ghostty will validate as well but we can avoid creating an entirely new // surface by doing our own validation here. We can also show a useful error // this way. diff --git a/macos/Sources/App/CommandLineOpenFileFilter.swift b/macos/Sources/App/CommandLineOpenFileFilter.swift new file mode 100644 index 000000000..89e7eb79d --- /dev/null +++ b/macos/Sources/App/CommandLineOpenFileFilter.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Filters the open-file events AppKit creates from command arguments following +/// `-e`. Each matching event is consumed once so later requests to open the same +/// file are handled normally. +final class CommandLineOpenFileFilter { + private let workingDirectory: URL + private var filesToIgnore: Set + + init( + arguments: [String], + workingDirectory: String, + fileExists: (String) -> Bool + ) { + let workingDirectory = URL( + filePath: workingDirectory, + directoryHint: .isDirectory + ).absoluteURL.standardizedFileURL + self.workingDirectory = workingDirectory + + guard let commandIndex = arguments.firstIndex(of: "-e") else { + self.filesToIgnore = [] + return + } + + // Ghostty treats every argument following `-e` as part of the child + // command. Existing paths in that suffix are the arguments AppKit can + // independently turn into open-file events. + self.filesToIgnore = Set(arguments[arguments.index(after: commandIndex)...] + .compactMap { argument in + // Command arguments can be relative, while AppKit normally + // reports absolute paths for the corresponding open event. Use + // file URLs internally so both forms have the same identity. + let url = Self.fileURL(argument, relativeTo: workingDirectory) + + // Ignore only paths that exist during launch. A non-path + // argument cannot produce the duplicate event and retaining it + // could suppress a legitimate open if that path appears later. + return fileExists(url.path) ? url : nil + }) + } + + func shouldIgnore(_ filename: String) -> Bool { + let url = Self.fileURL(filename, relativeTo: workingDirectory) + + // Consume each match once. Later requests to open the same file may + // come from Finder, the Dock, or another invocation and must proceed. + return filesToIgnore.remove(url) != nil + } + + private static func fileURL(_ path: String, relativeTo workingDirectory: URL) -> URL { + let expanded = (path as NSString).expandingTildeInPath + return URL( + filePath: expanded, + directoryHint: .notDirectory, + relativeTo: workingDirectory + ).absoluteURL.standardizedFileURL + } +} diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/MainMenu.xib similarity index 100% rename from macos/Sources/App/macOS/MainMenu.xib rename to macos/Sources/App/MainMenu.xib diff --git a/macos/Sources/App/macOS/ghostty-bridging-header.h b/macos/Sources/App/ghostty-bridging-header.h similarity index 100% rename from macos/Sources/App/macOS/ghostty-bridging-header.h rename to macos/Sources/App/ghostty-bridging-header.h diff --git a/macos/Sources/App/iOS/iOSApp.swift b/macos/Sources/App/iOS/iOSApp.swift deleted file mode 100644 index a1aafcc7d..000000000 --- a/macos/Sources/App/iOS/iOSApp.swift +++ /dev/null @@ -1,50 +0,0 @@ -import SwiftUI -import GhosttyKit - -@main -struct Ghostty_iOSApp: App { - @StateObject private var ghostty_app: Ghostty.App - - init() { - if ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv) != GHOSTTY_SUCCESS { - preconditionFailure("Initialize ghostty backend failed") - } - _ghostty_app = StateObject(wrappedValue: Ghostty.App()) - } - - var body: some Scene { - WindowGroup { - iOS_GhosttyTerminal() - .environmentObject(ghostty_app) - } - } -} - -struct iOS_GhosttyTerminal: View { - @EnvironmentObject private var ghostty_app: Ghostty.App - - var body: some View { - ZStack { - // Make sure that our background color extends to all parts of the screen - Color(ghostty_app.config.backgroundColor).ignoresSafeArea() - - Ghostty.Terminal() - } - } -} - -struct iOS_GhosttyInitView: View { - @EnvironmentObject private var ghostty_app: Ghostty.App - - var body: some View { - VStack { - Image("AppIconImage") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxHeight: 96) - Text("Ghostty") - Text("State: \(ghostty_app.readiness.rawValue)") - } - .padding() - } -} diff --git a/macos/Sources/App/macOS/main.swift b/macos/Sources/App/main.swift similarity index 100% rename from macos/Sources/App/macOS/main.swift rename to macos/Sources/App/main.swift diff --git a/macos/Sources/Features/About/AboutView.swift b/macos/Sources/Features/About/AboutView.swift index af6f64504..114379e2a 100644 --- a/macos/Sources/Features/About/AboutView.swift +++ b/macos/Sources/Features/About/AboutView.swift @@ -45,7 +45,6 @@ struct AboutView: View { private var copyright: String? { Bundle.main.infoDictionary?["NSHumanReadableCopyright"] as? String } - #if os(macOS) // This creates a background style similar to the Apple "About My Mac" Window private struct VisualEffectBackground: NSViewRepresentable { let material: NSVisualEffectView.Material @@ -72,7 +71,6 @@ struct AboutView: View { return visualEffect } } - #endif var body: some View { VStack(alignment: .center) { @@ -141,9 +139,7 @@ struct AboutView: View { .padding(.top, 8) .padding(32) .frame(minWidth: 256) - #if os(macOS) .background(VisualEffectBackground(material: .underWindowBackground).ignoresSafeArea()) - #endif } private struct PropertyRow: View { diff --git a/macos/Sources/Features/App Intents/CloseTerminalIntent.swift b/macos/Sources/Features/App Intents/CloseTerminalIntent.swift index c3cca2514..e8a666827 100644 --- a/macos/Sources/Features/App Intents/CloseTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/CloseTerminalIntent.swift @@ -27,7 +27,7 @@ struct CloseTerminalIntent: AppIntent { throw GhosttyIntentError.surfaceNotFound } - guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { + guard let controller = BaseTerminalController.controller(owning: surfaceView) else { return .result() } diff --git a/macos/Sources/Features/App Intents/FocusTerminalIntent.swift b/macos/Sources/Features/App Intents/FocusTerminalIntent.swift index 21dd71b15..3dbd40457 100644 --- a/macos/Sources/Features/App Intents/FocusTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/FocusTerminalIntent.swift @@ -27,7 +27,7 @@ struct FocusTerminalIntent: AppIntent { throw GhosttyIntentError.surfaceNotFound } - guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { + guard let controller = BaseTerminalController.controller(owning: surfaceView) else { return .result() } diff --git a/macos/Sources/Features/AppleScript/ScriptTerminal.swift b/macos/Sources/Features/AppleScript/ScriptTerminal.swift index 202eb662b..8c6b1b6df 100644 --- a/macos/Sources/Features/AppleScript/ScriptTerminal.swift +++ b/macos/Sources/Features/AppleScript/ScriptTerminal.swift @@ -112,7 +112,7 @@ final class ScriptTerminal: NSObject { baseConfig = nil } - guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { + guard let controller = BaseTerminalController.controller(owning: surfaceView) else { command.scriptErrorNumber = errAEEventFailed command.scriptErrorString = "Terminal is not in a splittable window." return nil @@ -142,7 +142,7 @@ final class ScriptTerminal: NSObject { return nil } - guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { + guard let controller = BaseTerminalController.controller(owning: surfaceView) else { command.scriptErrorNumber = errAEEventFailed command.scriptErrorString = "Terminal is not in a window." return nil @@ -163,7 +163,7 @@ final class ScriptTerminal: NSObject { return nil } - guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { + guard let controller = BaseTerminalController.controller(owning: surfaceView) else { command.scriptErrorNumber = errAEEventFailed command.scriptErrorString = "Terminal is not in a window." return nil diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift index 37b20afb0..7c7b9bd3c 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift @@ -1,7 +1,6 @@ import Foundation import Cocoa import SwiftUI -import GhosttyKit /// This initializes a clipboard confirmation warning window. The window itself /// WILL NOT show automatically and the caller must show the window via @@ -9,17 +8,14 @@ import GhosttyKit class ClipboardConfirmationController: NSWindowController { override var windowNibName: NSNib.Name? { "ClipboardConfirmation" } - let surface: ghostty_surface_t - let contents: String - let request: Ghostty.ClipboardRequest - let state: UnsafeMutableRawPointer? + private(set) var confirmation: Ghostty.ClipboardConfirmationRequest weak private var delegate: ClipboardConfirmationViewDelegate? - init(surface: ghostty_surface_t, contents: String, request: Ghostty.ClipboardRequest, state: UnsafeMutableRawPointer?, delegate: ClipboardConfirmationViewDelegate) { - self.surface = surface - self.contents = contents - self.request = request - self.state = state + init( + confirmation: Ghostty.ClipboardConfirmationRequest, + delegate: ClipboardConfirmationViewDelegate + ) { + self.confirmation = confirmation self.delegate = delegate super.init(window: nil) } @@ -28,12 +24,27 @@ class ClipboardConfirmationController: NSWindowController { fatalError("init(coder:) is not supported for this view") } + /// Replace the request represented by the visible sheet without changing + /// the sheet's focus state. The previous request is cancelled by its + /// SurfaceView before this method is called. + func replaceConfirmation(with confirmation: Ghostty.ClipboardConfirmationRequest) { + guard self.confirmation !== confirmation else { return } + self.confirmation = confirmation + + guard isWindowLoaded, let window else { return } + configure(window) + } + // MARK: - NSWindowController override func windowDidLoad() { guard let window = window else { return } - switch request { + configure(window) + } + + private func configure(_ window: NSWindow) { + switch confirmation.kind { case .paste: window.title = "Warning: Potentially Unsafe Paste" case .osc_52_read, .osc_52_write: @@ -41,8 +52,8 @@ class ClipboardConfirmationController: NSWindowController { } window.contentView = NSHostingView(rootView: ClipboardConfirmationView( - contents: contents, - request: request, + contents: confirmation.contents, + request: confirmation.kind, delegate: delegate )) } diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift index 17ab4aa24..c2fe5fd48 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift @@ -2,7 +2,7 @@ import SwiftUI /// This delegate is notified of the completion result of the clipboard confirmation dialog. protocol ClipboardConfirmationViewDelegate: AnyObject { - func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, _ request: Ghostty.ClipboardRequest) + func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action) } /// The SwiftUI view for showing a clipboard confirmation dialog. @@ -87,10 +87,10 @@ struct ClipboardConfirmationView: View { } private func onCancel() { - delegate?.clipboardConfirmationComplete(.cancel, request) + delegate?.clipboardConfirmationComplete(.cancel) } private func onPaste() { - delegate?.clipboardConfirmationComplete(.confirm, request) + delegate?.clipboardConfirmationComplete(.confirm) } } diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index de440fdc3..5a1b5cb36 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -70,24 +70,13 @@ struct CommandPaletteView: View { } // The options that we should show, taking into account any filtering from - // the query. Options with matching leadingColor are ranked higher. + // the query. Matched options are ranked in the following order: + // leadingColor > title > subtitle > description. var filteredOptions: [CommandOption] { if query.isEmpty { return options } else { - // Filter by title/subtitle match OR color match - let filtered = options.filter { - $0.title.matchedIndices(for: query) != nil || - ($0.subtitle?.matchedIndices(for: query) != nil) || - colorMatchScore(for: $0.leadingColor, query: query) > 0 - } - - // Sort by color match score (higher scores first), then maintain original order - return filtered.sorted { a, b in - let scoreA = colorMatchScore(for: a.leadingColor, query: query) - let scoreB = colorMatchScore(for: b.leadingColor, query: query) - return scoreA > scoreB - } + return options.filteredAndSorted(query: query) } } @@ -101,7 +90,7 @@ struct CommandPaletteView: View { } var body: some View { - let scheme: ColorScheme = if OSColor(backgroundColor).isLightColor { + let scheme: ColorScheme = if NSColor(backgroundColor).isLightColor { .light } else { .dark @@ -160,7 +149,7 @@ struct CommandPaletteView: View { hoveredOptionID: $hoveredOptionID) { option in isPresented = false option.action() - } + } } .frame(maxWidth: 500) .background( @@ -191,31 +180,6 @@ struct CommandPaletteView: View { } } - /// Returns a score (0.0 to 1.0) indicating how well a color matches a search query color name. - /// Returns 0 if no color name in the query matches, or if the color is nil. - private func colorMatchScore(for color: Color?, query: String) -> Double { - guard let color = color else { return 0 } - - let queryLower = query.lowercased() - let nsColor = NSColor(color) - - var bestScore: Double = 0 - for name in NSColor.colorNames { - guard queryLower.contains(name), - let systemColor = NSColor(named: name) else { continue } - - let distance = nsColor.distance(to: systemColor) - // Max distance in weighted RGB space is ~3.0, so normalize and invert - // Use a threshold to determine "close enough" matches - let maxDistance: Double = 1.5 - if distance < maxDistance { - let score = 1.0 - (distance / maxDistance) - bestScore = max(bestScore, score) - } - } - - return bestScore - } } /// The text field for building the query for the command palette. @@ -401,7 +365,7 @@ private struct CommandRow: View { VStack(alignment: .leading, spacing: 2) { highlightedTitle - if let subtitle = option.subtitle { + if let subtitle = option.subtitle ?? option.description { highlightedSubtitle(subtitle) .font(.caption) .foregroundStyle(.secondary) @@ -492,3 +456,74 @@ extension String { return queryIndex == query.endIndex ? matched : nil } } + +// MARK: - Match score + +extension Collection where Element == CommandOption { + /// Filters to the options matching `query` and ranks them best-first while + /// maintaining original order: a closer leading color match always wins, + /// then a title match beats a subtitle match beats a description match. + func filteredAndSorted(query: String) -> [Element] { + compactMap { CommandOptionMatch(option: $0, query: query) } + .sorted { + ($0.colorScore, $0.textScore) > ($1.colorScore, $1.textScore) + } + .map(\.option) + } +} + +/// A scored match of a command option against a palette query. +struct CommandOptionMatch { + let option: CommandOption + /// How closely the option's leading color matches a color name in the + /// query, from 0 (no match) to 1 (exact). + let colorScore: Double + /// Which text field matched, ranked: title (3), subtitle (2), + /// description (1), none (0). + let textScore: Int + + /// Returns nil if the option doesn't match the query at all. + init?(option: CommandOption, query: String) { + let colorScore = Self.colorMatchScore(for: option.leadingColor, query: query) + let textScore: Int = if option.title.matchedIndices(for: query) != nil { + 3 + } else if option.subtitle?.matchedIndices(for: query) != nil { + 2 + } else if option.description?.matchedIndices(for: query) != nil { + 1 + } else { + 0 + } + + guard colorScore > 0 || textScore > 0 else { return nil } + self.option = option + self.colorScore = colorScore + self.textScore = textScore + } + + /// Returns a score (0.0 to 1.0) indicating how well a color matches a search query color name. + /// Returns 0 if no color name in the query matches, or if the color is nil. + static func colorMatchScore(for color: Color?, query: String) -> Double { + guard let color = color else { return 0 } + + let queryLower = query.lowercased() + let nsColor = NSColor(color) + + var bestScore: Double = 0 + for name in NSColor.colorNames { + guard queryLower.contains(name), + let systemColor = NSColor(named: name) else { continue } + + let distance = nsColor.distance(to: systemColor) + // Max distance in weighted RGB space is ~3.0, so normalize and invert + // Use a threshold to determine "close enough" matches + let maxDistance: Double = 1.5 + if distance < maxDistance { + let score = 1.0 - (distance / maxDistance) + bestScore = max(bestScore, score) + } + } + + return bestScore + } +} diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 09e369d4a..4ad690c19 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -84,35 +84,39 @@ struct TerminalCommandPaletteView: View { private var updateOptions: [CommandOption] { var options: [CommandOption] = [] - guard let updateViewModel, updateViewModel.state.isInstallable else { + guard let updateViewModel else { return options } - // We override the update available one only because we want to properly - // convey it'll go all the way through. - let title: String - if case .updateAvailable = updateViewModel.state { - title = "Update Ghostty and Restart" - } else { - title = updateViewModel.text + if updateViewModel.state.isInstallable { + // We override the update available one only because we want to properly + // convey it'll go all the way through. + let title: String + if case .updateAvailable = updateViewModel.state { + title = "Update Ghostty and Restart" + } else { + title = updateViewModel.text + } + + options.append(CommandOption( + title: title, + description: updateViewModel.description, + leadingIcon: updateViewModel.iconName ?? "shippingbox.fill", + badge: updateViewModel.badge, + emphasis: true + ) { + (NSApp.delegate as? AppDelegate)?.updateController.viewModel.state.confirm() + }) } - options.append(CommandOption( - title: title, - description: updateViewModel.description, - leadingIcon: updateViewModel.iconName ?? "shippingbox.fill", - badge: updateViewModel.badge, - emphasis: true - ) { - (NSApp.delegate as? AppDelegate)?.updateController.installUpdate() - }) - - options.append(CommandOption( - title: "Cancel or Skip Update", - description: "Dismiss the current update process" - ) { - updateViewModel.state.cancel() - }) + if updateViewModel.state.isCancellable { + options.append(CommandOption( + title: "Cancel or Skip Update", + description: "Dismiss the current update process" + ) { + updateViewModel.state.cancel() + }) + } return options } diff --git a/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIconImage.swift b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIconImage.swift index d7bae0439..980da6b79 100644 --- a/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIconImage.swift +++ b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIconImage.swift @@ -3,7 +3,6 @@ import SwiftUI extension View { /// Returns the ghostty icon to use for views. func ghosttyIconImage() -> Image { - #if os(macOS) // Grab the icon from the running application. This is the best way // I've found so far to get the proper icon for our current icon // tinting and so on with macOS Tahoe @@ -15,7 +14,6 @@ extension View { if let nsImage = NSApp.applicationIconImage { return Image(nsImage: nsImage) } - #endif // Fall back to a static representation return Image("AppIconImage") diff --git a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift index 9bb63776a..12554d212 100644 --- a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift +++ b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift @@ -1,4 +1,5 @@ import Cocoa +import ApplicationServices import CoreGraphics import Carbon import OSLog @@ -18,8 +19,7 @@ class GlobalEventTap { // created. fileprivate var eventTap: CFMachPort? - // This is the timer used to retry enabling the global event tap if we - // don't have permissions. + // Polls Accessibility permission before enabling the global event tap. private var enableTimer: Timer? // Private init so it can't be constructed outside of our singleton @@ -29,29 +29,36 @@ class GlobalEventTap { disable() } - // Enable the global event tap. This is safe to call if it is already enabled. - // If enabling fails due to permissions, this will start a timer to retry since - // accessibility permissions take affect immediately. + // Enable the global event tap. This is safe to call if it is already enabled or + // waiting for Accessibility permission. func enable() { - if eventTap != nil { - // Already enabled + // If we already have a tap or we're already checking on a timer, do nothing. + guard eventTap == nil, enableTimer == nil else { return } + + // Creating a CGEventTap without Accessibility permission leaks a Mach port + // inside CoreGraphics on each failed attempt. Request permission once and + // poll the non-leaking trust check instead of retrying tap creation. + if AXIsProcessTrusted() { + _ = tryEnable() return } - // If we are already trying to enable, then stop the timer and restart it. - if let enableTimer { - enableTimer.invalidate() - } + // Ask macOS to prompt for Accessibility access. Approval happens + // asynchronously, so ignore the current result and poll below. + Self.logger.info("No accessibility permission detected, prompting...") + let options = [ + kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true, + ] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) - // Try to enable the event tap immediately. If this succeeds then we're done! - if tryEnable() { - return - } + // Check in a timer + enableTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + guard let self, AXIsProcessTrusted() else { return } - // Failed, probably due to permissions. The permissions dialog should've - // popped up. We retry on a timer since once the permissions are granted - // then they take affect immediately. - enableTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in + // Stop polling before attempting creation. If creation fails for a + // reason other than permissions, we must not retry it indefinitely. + self.enableTimer?.invalidate() + self.enableTimer = nil _ = self.tryEnable() } } @@ -88,10 +95,7 @@ class GlobalEventTap { callback: cgEventFlagsChangedHandler(proxy:type:cgEvent:userInfo:), userInfo: nil ) else { - // Return false if creation failed. This is usually because we don't have - // Accessibility permissions but can probably be other reasons I don't - // know about. - Self.logger.debug("creating global event tap failed, missing permissions?") + Self.logger.warning("creating global event tap failed despite Accessibility permission") return false } diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 0bdc391a3..351756df4 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -639,6 +639,16 @@ class QuickTerminalController: BaseTerminalController { terminalViewContainer?.ghosttyConfigDidChange(ghostty.config, preferredBackgroundColor: nil) } + override func confirmCloseAsync(messageText: String, informativeText: String, confirmButtonTitle: String = "Close") async -> NSApplication.ModalResponse? { + + let waitTime = visible ? 0 : 0.25 + animateIn() + + try? await Task.sleep(for: .seconds(waitTime)) + + return await super.confirmCloseAsync(messageText: messageText, informativeText: informativeText, confirmButtonTitle: confirmButtonTitle) + } + private func showNoNewTabAlert() { guard let window else { return } let alert = NSAlert() diff --git a/macos/Sources/Features/Settings/ConfigurationErrorsController.swift b/macos/Sources/Features/Settings/ConfigurationErrorsController.swift index 9956f7873..1dca58a1c 100644 --- a/macos/Sources/Features/Settings/ConfigurationErrorsController.swift +++ b/macos/Sources/Features/Settings/ConfigurationErrorsController.swift @@ -13,7 +13,15 @@ class ConfigurationErrorsController: NSWindowController, NSWindowDelegate, Confi @Published var errors: [String] = [] { didSet { if errors.count == 0 { - self.window?.performClose(nil) + // Only close the window if it was ever loaded: accessing + // `window` on an NSWindowController loads the nib (and our + // SwiftUI content view), which takes tens of milliseconds. + // This happens on every app launch via the initial config + // apply, when there are usually no errors and the window + // was never loaded. + if isWindowLoaded { + self.window?.performClose(nil) + } } } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 269fbca49..79488106e 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -32,6 +32,11 @@ class BaseTerminalController: NSWindowController, TerminalViewModel, ClipboardConfirmationViewDelegate, FullscreenDelegate { + /// Weak surface-to-controller ownership independent of AppKit's transient + /// view and window attachment state. + private static let surfaceControllers = + NSMapTable.weakToWeakObjects() + /// The app instance that this terminal view will represent. let ghostty: Ghostty.App @@ -42,7 +47,10 @@ class BaseTerminalController: NSWindowController, /// The tree of splits within this terminal window. @Published var surfaceTree: SplitTree = .init() { - didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } + didSet { + Self.updateSurfaceControllers(self, from: oldValue, to: surfaceTree) + surfaceTreeDidChange(from: oldValue, to: surfaceTree) + } } /// This can be set to show/hide the command palette. @@ -89,6 +97,9 @@ class BaseTerminalController: NSWindowController, /// Cancellable for aggregating bell state across all surfaces in this controller. private var bellStateCancellable: AnyCancellable? + /// Cancellable for clipboard confirmation requests from surfaces in this controller. + private var clipboardConfirmationCancellable: AnyCancellable? + /// An override title for the tab/window set by the user via prompt_tab_title. /// When set, this takes precedence over the computed title from the terminal. var titleOverride: String? { @@ -140,17 +151,14 @@ class BaseTerminalController: NSWindowController, // Initialize our initial surface. guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } self.surfaceTree = tree ?? .init(view: Ghostty.SurfaceView(ghostty_app, baseConfig: base)) + Self.updateSurfaceControllers(self, from: .init(), to: surfaceTree) // Setup our bell state for the window setupBellNotificationPublisher() + setupClipboardConfirmationPublisher() // Setup our notifications for behaviors let center = NotificationCenter.default - center.addObserver( - self, - selector: #selector(onConfirmClipboardRequest), - name: Ghostty.Notification.confirmClipboard, - object: nil) center.addObserver( self, selector: #selector(didChangeScreenParametersNotification), @@ -231,6 +239,44 @@ class BaseTerminalController: NSWindowController, // MARK: Methods + /// Finds the controller whose split tree owns the given surface. + /// + /// A surface's `window` can briefly be nil or point at its previous window + /// while AppKit is attaching or moving a native tab. Callers performing + /// lifecycle operations must use tree ownership rather than that transient + /// view relationship. + static func controller(owning surface: Ghostty.SurfaceView) -> BaseTerminalController? { + if let controller = surfaceControllers.object(forKey: surface), + controller.surfaceTree.contains(surface) { + return controller + } + + if let controller = surface.window?.windowController as? BaseTerminalController, + controller.surfaceTree.contains(surface) { + return controller + } + + return NSApp.windows + .compactMap { $0.windowController as? BaseTerminalController } + .first { $0.surfaceTree.contains(surface) } + } + + private static func updateSurfaceControllers( + _ controller: BaseTerminalController, + from oldTree: SplitTree, + to newTree: SplitTree + ) { + for surface in oldTree where !newTree.contains(surface) { + if surfaceControllers.object(forKey: surface) === controller { + surfaceControllers.removeObject(forKey: surface) + } + } + + for surface in newTree { + surfaceControllers.setObject(controller, forKey: surface) + } + } + /// Create a new split. @discardableResult func newSplit( @@ -288,6 +334,10 @@ class BaseTerminalController: NSWindowController, /// /// Subclasses should call super first. func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { + for surfaceView in from where !to.contains(surfaceView) { + cancelPendingClipboardConfirmation(for: surfaceView) + } + // If our surface tree becomes empty then we have no focused surface. if to.isEmpty { focusedSurface = nil @@ -298,6 +348,7 @@ class BaseTerminalController: NSWindowController, /// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about /// what surface is focused. This must be called whenever a surface OR window changes focus. func syncFocusToSurfaceTree() { + var newlyFocused: Ghostty.SurfaceView? for surfaceView in surfaceTree { // Our focus state requires that this window is key and our currently // focused surface is the surface in this view. @@ -305,6 +356,11 @@ class BaseTerminalController: NSWindowController, surfaceView == focusedSurface && surfaceView.isFirstResponder surfaceView.focusDidChange(focused) + if focused { newlyFocused = surfaceView } + } + + if let newlyFocused { + presentPendingClipboardConfirmation(for: newlyFocused) } } @@ -1094,69 +1150,6 @@ class BaseTerminalController: NSWindowController, syncAppearance() } - // MARK: Clipboard Confirmation - - @objc private func onConfirmClipboardRequest(notification: SwiftUI.Notification) { - guard let target = notification.object as? Ghostty.SurfaceView else { return } - guard target == self.focusedSurface else { return } - guard let surface = target.surface else { return } - - // We need a window - guard let window = self.window else { return } - - // Check whether we use non-native fullscreen - guard let str = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStrKey] as? String else { return } - guard let state = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStateKey] as? UnsafeMutableRawPointer? else { return } - guard let request = notification.userInfo?[Ghostty.Notification.ConfirmClipboardRequestKey] as? Ghostty.ClipboardRequest else { return } - - // If we already have a clipboard confirmation view up, we ignore this request. - // This shouldn't be possible... - guard self.clipboardConfirmation == nil else { - Ghostty.App.completeClipboardRequest(surface, data: "", state: state, confirmed: true) - return - } - - // Show our paste confirmation - self.clipboardConfirmation = ClipboardConfirmationController( - surface: surface, - contents: str, - request: request, - state: state, - delegate: self - ) - window.beginSheet(self.clipboardConfirmation!.window!) - } - - func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, _ request: Ghostty.ClipboardRequest) { - // End our clipboard confirmation no matter what - guard let cc = self.clipboardConfirmation else { return } - self.clipboardConfirmation = nil - - // Close the sheet - if let ccWindow = cc.window { - window?.endSheet(ccWindow) - } - - switch request { - case let .osc_52_write(pasteboard): - guard case .confirm = action else { break } - let pb = pasteboard ?? NSPasteboard.general - pb.declareTypes([.string], owner: nil) - pb.setString(cc.contents, forType: .string) - case .osc_52_read, .paste: - let str: String - switch action { - case .cancel: - str = "" - - case .confirm: - str = cc.contents - } - - Ghostty.App.completeClipboardRequest(cc.surface, data: str, state: cc.state, confirmed: true) - } - } - // MARK: NSWindowController override func windowDidLoad() { @@ -1203,7 +1196,7 @@ class BaseTerminalController: NSWindowController, /// Check whether window should be closed without showing an alert func windowCanBeClosedWithoutConfirmation() -> Bool { // We must have a window. Is it even possible not to? - guard let window = self.window else { return true } + guard window != nil else { return true } // If we have no surfaces, close. if surfaceTree.isEmpty { return true } @@ -1238,6 +1231,10 @@ class BaseTerminalController: NSWindowController, func windowWillClose(_ notification: Notification) { guard let window else { return } + for surfaceView in surfaceTree { + cancelPendingClipboardConfirmation(for: surfaceView) + } + // Emit a final bell-state transition so any observers can clear state // without separately tracking NSWindow lifecycle events. if bell { @@ -1542,6 +1539,153 @@ extension BaseTerminalController: NSMenuItemValidation { } } +// MARK: Clipboard Confirmation + +extension BaseTerminalController { + /// Presents clipboard confirmations published by surfaces in this controller. + private func setupClipboardConfirmationPublisher() { + clipboardConfirmationCancellable = $surfaceTree + // Rebuild the merged publisher whenever the split tree changes. + .map { tree in + Publishers.MergeMany(tree.map { surface in + // Carry the stable value-type ID rather than capturing the + // surface in the operator chain. The subscription therefore + // cannot extend the SurfaceView's lifetime. + let id = surface.id + return surface.$pendingClipboardConfirmation + .map { (id, $0) } + .eraseToAnyPublisher() + }) + .eraseToAnyPublisher() + } + // Cancelling the old MergeMany releases every subscription for + // surfaces removed from the current tree. + .switchToLatest() + // Published emits synchronously from the libghostty callback. Hop + // to the main queue both for AppKit and so completing a request + // cannot invalidate callback state while that callback is active. + .receive(on: DispatchQueue.main) + // The cancellable is controller-owned, so capture it weakly here to + // avoid controller -> cancellable -> sink -> controller. + .sink { [weak self] id, request in + guard let self, + let surface = surfaceTree.first(where: { $0.id == id }) else { return } + onConfirmClipboardRequest(request, for: surface) + } + } + + private func onConfirmClipboardRequest( + _ request: Ghostty.ClipboardConfirmationRequest?, + for target: Ghostty.SurfaceView + ) { + guard let request else { + guard target.pendingClipboardConfirmation == nil, + let confirmation = clipboardConfirmation, + confirmation.confirmation.surface === target else { return } + dismissClipboardConfirmation(confirmation) + return + } + + // Ignore values queued before a newer request replaced them. + guard target.pendingClipboardConfirmation === request else { return } + + // SurfaceView.didSet has already cancelled the request that this one + // replaced. If that request owns the visible sheet, update the sheet + // in place. Dismissing it would briefly return focus to the terminal, + // which can produce another request and repeat the cycle. Requests + // from other surfaces cannot replace a window-modal sheet. + if let confirmation = clipboardConfirmation { + if confirmation.confirmation === request { return } + guard confirmation.confirmation.surface === target else { + target.pendingClipboardConfirmation = nil + return + } + confirmation.replaceConfirmation(with: request) + return + } + + // A clipboard confirmation can originate from a surface that isn't + // focused. Presenting its sheet immediately would bring that surface's + // window or tab forward and steal focus. Signal that it needs attention, + // retain the request on the surface, and present it only after the + // surface gains focus. + if !target.focused { + if !target.bell { + NotificationCenter.default.post( + name: .ghosttyBellDidRing, + object: target) + } + return + } + + // Preserve the prior behavior for confirmation types other than an + // OSC 52 read: only the controller's selected surface may present it. + guard target == focusedSurface else { + target.pendingClipboardConfirmation = nil + return + } + _ = presentClipboardConfirmation(request) + } + + private func presentClipboardConfirmation( + _ request: Ghostty.ClipboardConfirmationRequest + ) -> Bool { + guard clipboardConfirmation == nil, let window else { return false } + + clipboardConfirmation = ClipboardConfirmationController( + confirmation: request, + delegate: self + ) + window.beginSheet(clipboardConfirmation!.window!) + return true + } + + private func dismissClipboardConfirmation( + _ confirmation: ClipboardConfirmationController + ) { + guard clipboardConfirmation === confirmation else { return } + clipboardConfirmation = nil + if let confirmationWindow = confirmation.window { + window?.endSheet(confirmationWindow) + } + } + + private func presentPendingClipboardConfirmation(for target: Ghostty.SurfaceView) { + guard target.focused, + target == focusedSurface, + let request = target.pendingClipboardConfirmation else { return } + onConfirmClipboardRequest(request, for: target) + } + + private func cancelPendingClipboardConfirmation(for target: Ghostty.SurfaceView) { + if let confirmation = clipboardConfirmation, + confirmation.confirmation.surface === target { + dismissClipboardConfirmation(confirmation) + } + target.pendingClipboardConfirmation = nil + } + + func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action) { + // End our clipboard confirmation no matter what + guard let cc = self.clipboardConfirmation else { return } + dismissClipboardConfirmation(cc) + + switch action { + case .cancel: + cc.confirmation.cancel() + case .confirm: + cc.confirmation.complete() + } + + // Clear only if this is still the surface's current request. Completing + // the request may synchronously cause a newer request to replace it. + if let target = cc.confirmation.surface, + target.pendingClipboardConfirmation === cc.confirmation { + target.pendingClipboardConfirmation = nil + } + } +} + // MARK: Combine Methods extension BaseTerminalController { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index d19323dde..587510f09 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -344,14 +344,15 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr confirmUndo: Bool = true, inheritBackgroundOpacity: Bool? = nil ) -> TerminalController { + // Calculate the target frame based on the tree's view bounds + // before moving into the new window + let treeSize: CGSize? = tree.root?.viewBounds() + let c = TerminalController.init(ghostty, withSurfaceTree: tree) if let inheritBackgroundOpacity { c.isBackgroundOpaque = inheritBackgroundOpacity } - // Calculate the target frame based on the tree's view bounds - let treeSize: CGSize? = tree.root?.viewBounds() - c.scheduleInitialPresentation { c.showWindow(self) if let window = c.window { @@ -480,8 +481,10 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr Self.applyCascade(to: window, hasFixedPos: hasFixedPos) } - controller.showWindow(self) - window.makeKeyAndOrderFront(self) + // showWindow makes regular windows key and ordered front. AppKit can + // throw while selecting a tab if its fullscreen stack is inconsistent, + // so this must cross the Objective-C exception bridge. + controller.showWindowSafely(self) // We also activate our app so that it becomes front. This may be // necessary for the dock menu. @@ -667,12 +670,20 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // More than 1 window means we have tabs and we're closing a tab if window?.tabGroup?.windows.count ?? 0 > 1 { - closeTab(nil) + if withConfirmation { + closeTab(nil) + } else { + closeTabImmediately() + } return } // 1 window, closing the window - closeWindow(nil) + if withConfirmation { + closeWindow(nil) + } else { + closeWindowImmediately() + } } func closeTabImmediately(registerRedo: Bool = true) { @@ -1108,7 +1119,16 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // We don't run this logic in fullscreen because in fullscreen this will end up // removing the window and putting it into its own dedicated fullscreen, which is not // the expected or desired behavior of anyone I've found. - if !window.styleMask.contains(.fullScreen) { + // + // We also only run this when the system tabbing preference is "always", + // which is the only scenario AppKit will have auto-tabbed a fresh window + // at this point: the tab bar "+" button goes through newWindowForTab + // which we route through our own tab logic. This check matters because + // accessing `window.tabGroup` materializes the window's tab group + // machinery, which takes ~15-20ms and is otherwise not needed during + // window creation. + if NSWindow.userTabbingPreference == .always, + !window.styleMask.contains(.fullScreen) { // If we have more than 1 window in our tab group we know we're a new window. // Since Ghostty manages tabbing manually this will never be more than one // at this point in the AppKit lifecycle (we add to the group after this). @@ -1149,6 +1169,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } super.showWindow(sender) + + syncAppearance() } // Shows the "+" button in the tab bar, responds to that click. diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index 905e88229..bb59d9a93 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -147,8 +147,8 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { } // Update our window light/darkness based on our updated background color - let themeChanged = isLightTheme != OSColor(surfaceConfig.backgroundColor).isLightColor - isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor + let themeChanged = isLightTheme != NSColor(surfaceConfig.backgroundColor).isLightColor + isLightTheme = NSColor(surfaceConfig.backgroundColor).isLightColor // Update our titlebar color if let preferredBackgroundColor { diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index c0e506c34..0f5858cf3 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -9,6 +9,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { private var lastSurfaceConfig: Ghostty.SurfaceView.DerivedConfig? /// KVO observation for tab group window changes. + private weak var observedTabGroup: NSWindowTabGroup? private var tabGroupWindowsObservation: NSKeyValueObservation? private var tabBarVisibleObservation: NSKeyValueObservation? @@ -129,9 +130,27 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // MARK: Tab Group Observation private func setupKVO() { - // See the docs for the respective setup functions for why. - setupTabGroupObservation() - setupTabBarVisibleObservation() + // This can run from one of the observation callbacks below. Replacing + // an observation before its callback returns leaves the window retained + // by AppKit, so always rebind on the next main-queue turn. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + // Recheck because the tab group and observation state may have changed + // while this work was waiting on the main queue. + let currentTabGroup = self.tabGroup + let observationsValid = currentTabGroup == nil || ( + self.tabGroupWindowsObservation != nil && + self.tabBarVisibleObservation != nil + ) + + // Keep the existing observations when they already match. + guard self.observedTabGroup !== currentTabGroup || !observationsValid else { return } + + self.observedTabGroup = currentTabGroup + self.setupTabGroupObservation() + self.setupTabBarVisibleObservation() + } } /// Monitors the tabGroup windows value for any changes and resyncs the appearance on change. diff --git a/macos/Sources/Features/Update/UpdateController.swift b/macos/Sources/Features/Update/UpdateController.swift index 1ca218c8b..e88a4e3b9 100644 --- a/macos/Sources/Features/Update/UpdateController.swift +++ b/macos/Sources/Features/Update/UpdateController.swift @@ -1,6 +1,7 @@ import Sparkle import Cocoa import Combine +import SwiftUI /// Standard controller for managing Sparkle updates in Ghostty. /// @@ -10,15 +11,14 @@ import Combine class UpdateController { private(set) var updater: SPUUpdater private let userDriver: UpdateDriver - private var installCancellable: AnyCancellable? var viewModel: UpdateViewModel { userDriver.viewModel } - /// True if we're installing an update. - var isInstalling: Bool { - installCancellable != nil + /// True if we're installing an update triggered manually. + var shouldTerminateWithoutWarning: Bool { + viewModel.state.shouldTerminateWithoutWarning } /// Initialize a new update controller. @@ -35,10 +35,6 @@ class UpdateController { ) } - deinit { - installCancellable?.cancel() - } - /// Start the updater. /// /// This must be called before the updater can check for updates. If starting fails, @@ -60,46 +56,41 @@ class UpdateController { } } - /// Force install the current update. As long as we're in some "update available" state this will - /// trigger all the steps necessary to complete the update. - func installUpdate() { - // Must be in an installable state - guard viewModel.state.isInstallable else { return } - - // If we're already force installing then do nothing. - guard installCancellable == nil else { return } - - // Setup a combine listener to listen for state changes and to always - // confirm them. If we go to a non-installable state, cancel the listener. - // The sink runs immediately with the current state, so we don't need to - // manually confirm the first state. - installCancellable = viewModel.$state.sink { [weak self] state in - guard let self else { return } - - // If we move to a non-installable state (error, idle, etc.) then we - // stop force installing. - guard state.isInstallable else { - self.installCancellable = nil - return - } - - // Continue the `yes` chain! - state.confirm() - } - } - /// Check for updates. /// /// This is typically connected to a menu item action. - @objc func checkForUpdates() { + func checkForUpdates() { // If we're already idle, then just check for updates immediately. if viewModel.state == .idle { updater.checkForUpdates() return } + if case let .installing(installing) = viewModel.state { + // If the update is already installed, we can't actually + // cancel it, and SPUUpdater.checkForUpdates will simply fail, + // so we just show an alert to remind the user to restart. + let alert = NSAlert() + alert.alertStyle = .informational + let accessoryView = NSHostingView( + rootView: InstallingAccessoryView(installing: installing) + .frame(width: 228, alignment: .leading) + ) + accessoryView.frame = .init(origin: .zero, size: accessoryView.fittingSize) + alert.accessoryView = accessoryView + alert.addButton(withTitle: "Restart Now") + alert.addButton(withTitle: "Restart Later") + .keyEquivalent = .init([KeyboardShortcut(.escape).key.character]) + switch alert.runModal() { + case .alertFirstButtonReturn: + viewModel.state.confirm() + default: + break + } + return + } + // If we're not idle then we need to cancel any prior state. - installCancellable?.cancel() viewModel.state.cancel() // The above will take time to settle, so we delay the check for some time. @@ -109,15 +100,47 @@ class UpdateController { self?.updater.checkForUpdates() } } +} - /// Validate the check for updates menu item. - /// - /// - Parameter item: The menu item to validate - /// - Returns: Whether the menu item should be enabled - func validateMenuItem(_ item: NSMenuItem) -> Bool { - if item.action == #selector(checkForUpdates) { - return updater.canCheckForUpdates +private struct InstallingAccessoryView: View { + let installing: UpdateState.Installing + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Restart Required") + .font(.system(size: 13, weight: .semibold)) + + Text("The update is ready. Please restart the application to complete the installation.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if let item = installing.appcastItem, let releaseNotesURL = installing.releaseNotes?.url { + VStack(alignment: .leading, spacing: 4) { + Link(destination: releaseNotesURL) { + HStack(spacing: 6) { + Text("Version:") + .foregroundColor(.secondary) + .frame(width: 60, alignment: .trailing) + Text(item.displayVersionString) + } + .font(.system(size: 11)) + } + + if let date = item.date { + HStack(spacing: 6) { + Text("Released:") + .foregroundColor(.secondary) + .frame(width: 60, alignment: .trailing) + Text(date.formatted(date: .abbreviated, time: .omitted)) + } + .font(.system(size: 11)) + } + } + .textSelection(.enabled) + } + } } - return true } } diff --git a/macos/Sources/Features/Update/UpdateDelegate.swift b/macos/Sources/Features/Update/UpdateDelegate.swift index 2459b8dfd..fbd436d92 100644 --- a/macos/Sources/Features/Update/UpdateDelegate.swift +++ b/macos/Sources/Features/Update/UpdateDelegate.swift @@ -23,12 +23,12 @@ extension UpdateDriver: SPUUpdaterDelegate { /// delegate method on the responsible driver instead. func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool { viewModel.state = .installing(.init( - isAutoUpdate: true, - retryTerminatingApplication: immediateInstallHandler, - dismiss: { [weak viewModel] in - viewModel?.state = .idle - } + appcastItem: item, + retryTerminatingApplication: immediateInstallHandler )) + AppDelegate.logger.info("Version: \(item.displayVersionString) installed silently, waiting for relaunch...") + // Even when hasUnobtrusiveTarget is false, we don't show the alert immediately. + // We wait until the user manually checks for updates or relaunches. return true } } diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index b5f580f1b..57c8d56b5 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -95,14 +95,12 @@ class UpdateDriver: NSObject, SPUUserDriver { delegate.checkForUpdates(self) } }, - dismiss: { [weak viewModel] in - viewModel?.state = .idle + dismiss: { + acknowledgement() })) if !hasUnobtrusiveTarget { standard.showUpdaterError(error, acknowledgement: acknowledgement) - } else { - acknowledgement() } } @@ -173,10 +171,8 @@ class UpdateDriver: NSObject, SPUUserDriver { func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) { viewModel.state = .installing(.init( + appcastItem: nil, retryTerminatingApplication: retryTerminatingApplication, - dismiss: { [weak viewModel] in - viewModel?.state = .idle - } )) if !hasUnobtrusiveTarget { diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift index b14cde1ac..21af76595 100644 --- a/macos/Sources/Features/Update/UpdatePill.swift +++ b/macos/Sources/Features/Update/UpdatePill.swift @@ -15,7 +15,7 @@ struct UpdatePill: View { private let textFont = NSFont.systemFont(ofSize: 11, weight: .medium) var body: some View { - if !model.state.isIdle { + if !model.state.isHidden { pillButton .popover(isPresented: $showPopover, arrowEdge: .bottom) { UpdatePopoverView(model: model) @@ -24,10 +24,10 @@ struct UpdatePill: View { .onChange(of: model.state) { newState in resetTask?.cancel() if case .notFound(let notFound) = newState { - resetTask = Task { [weak model] in + resetTask = Task { try? await Task.sleep(for: .seconds(5)) - guard !Task.isCancelled, case .notFound? = model?.state else { return } - model?.state = .idle + guard !Task.isCancelled, case .notFound = model.state else { return } + model.state = .idle notFound.acknowledgement() } } else { diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index aa4e822f3..b16cbeaea 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -35,11 +35,8 @@ struct UpdatePopoverView: View { case .extracting(let extracting): ExtractingView(extracting: extracting) - case .installing(let installing): - // This is only required when `installing.isAutoUpdate == true`, - // but we keep it anyway, just in case something unexpected - // happens during installing - InstallingView(installing: installing, dismiss: dismiss) + case .installing: + EmptyView() case .notFound(let notFound): NotFoundView(notFound: notFound, dismiss: dismiss) @@ -274,45 +271,6 @@ private struct ExtractingView: View { } } -private struct InstallingView: View { - let installing: UpdateState.Installing - let dismiss: DismissAction - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 8) { - Text("Restart Required") - .font(.system(size: 13, weight: .semibold)) - - Text("The update is ready. Please restart the application to complete the installation.") - .font(.system(size: 11)) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - HStack { - Button("Restart Later") { - installing.dismiss() - dismiss() - } - .keyboardShortcut(.cancelAction) - .controlSize(.small) - - Spacer() - - Button("Restart Now") { - installing.retryTerminatingApplication() - dismiss() - } - .keyboardShortcut(.defaultAction) - .buttonStyle(.borderedProminent) - .controlSize(.small) - } - } - .padding(16) - } -} - private struct NotFoundView: View { let notFound: UpdateState.NotFound let dismiss: DismissAction diff --git a/macos/Sources/Features/Update/UpdateSimulator.swift b/macos/Sources/Features/Update/UpdateSimulator.swift index c893993e0..c9339c0e7 100644 --- a/macos/Sources/Features/Update/UpdateSimulator.swift +++ b/macos/Sources/Features/Update/UpdateSimulator.swift @@ -274,28 +274,19 @@ enum UpdateSimulator { } } - private func simulateInstalling(_ viewModel: UpdateViewModel) { + private func simulateInstalling(_ viewModel: UpdateViewModel, appcastItem: SUAppcastItem? = nil) { viewModel.state = .installing(.init( + appcastItem: appcastItem, retryTerminatingApplication: { print("Restart button clicked in simulator - resetting to idle") viewModel.state = .idle }, - dismiss: { - viewModel.state = .idle - } )) } private func simulateAutoUpdate(_ viewModel: UpdateViewModel) { - viewModel.state = .installing(.init( - isAutoUpdate: true, - retryTerminatingApplication: { - print("Restart button clicked in simulator - resetting to idle") - viewModel.state = .idle - }, - dismiss: { - viewModel.state = .idle - } - )) + let item = SUAppcastItem.empty() + item.setValue("x.x.x", forKey: "_displayVersionString") + simulateInstalling(viewModel, appcastItem: item) } } diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index 8e66f4a16..f458ce6d6 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -30,8 +30,8 @@ class UpdateViewModel: ObservableObject { return "Downloading…" case .extracting(let extracting): return String(format: "Preparing: %.0f%%", extracting.progress * 100) - case .installing(let install): - return install.isAutoUpdate ? "Restart to Complete Update" : "Installing…" + case let .installing(install): + return install.appcastItem != nil ? "Restart to Complete Update" : "Installing…" case .notFound: return "No Updates Available" case .error(let err): @@ -93,7 +93,11 @@ class UpdateViewModel: ObservableObject { case .extracting: return "Extracting and preparing the update" case let .installing(install): - return install.isAutoUpdate ? "Restart to Complete Update" : "Installing update and preparing to restart" + if let item = install.appcastItem { + return "The update is ready. Version: \(item.displayVersionString)" + } else { + return "Installing update and preparing to restart" + } case .notFound: return "You are running the latest version" case .error: @@ -185,8 +189,22 @@ enum UpdateState: Equatable { case extracting(Extracting) case installing(Installing) - var isIdle: Bool { + /// True if we're installing an update triggered manually. + var shouldTerminateWithoutWarning: Bool { + if case .installing(let installing) = self { + return installing.appcastItem == nil + } else { + return false + } + } + + var isHidden: Bool { if case .idle = self { return true } + if case .installing(let installing) = self { + // Hide the update pill when installing is triggered by auto update. + // There will be an alert when users check the updates themselves. + return installing.appcastItem != nil + } return false } @@ -205,30 +223,51 @@ enum UpdateState: Equatable { } } + var isCancellable: Bool { + cancelAction != nil + } + func cancel() { + cancelAction?() + } + + private var cancelAction: (() -> Void)? { switch self { + case .idle: + nil + case .permissionRequest: + nil case .checking(let checking): - checking.cancel() + checking.cancel case .updateAvailable(let available): - available.reply(.dismiss) + { available.reply(.dismiss) } case .downloading(let downloading): - downloading.cancel() + downloading.cancel case .notFound(let notFound): - notFound.acknowledgement() + notFound.acknowledgement case .error(let err): - err.dismiss() - default: - break + err.dismiss + case .extracting: + nil + case .installing: + nil } } /// Confirms or accepts the current update state. /// - For available updates: begins installation /// - For ready-to-install: proceeds with installation - func confirm() { + /// - For installing: suppress termination warnings and restart + mutating func confirm() { switch self { case .updateAvailable(let available): available.reply(.install) + case .installing(let installing): + // Remove appcastItem so we can restart without any other alerts. + var suppressTerminationWarnings = installing + suppressTerminationWarnings.appcastItem = nil + self = .installing(suppressTerminationWarnings) + installing.retryTerminatingApplication() default: break } @@ -252,8 +291,8 @@ enum UpdateState: Equatable { return lDown.progress == rDown.progress && lDown.expectedLength == rDown.expectedLength case (.extracting(let lExt), .extracting(let rExt)): return lExt.progress == rExt.progress - case (.installing(let lInstall), .installing(let rInstall)): - return lInstall.isAutoUpdate == rInstall.isAutoUpdate + case (.installing(let lhs), .installing(let rhs)): + return lhs.appcastItem?.displayVersionString == rhs.appcastItem?.displayVersionString default: return false } @@ -364,9 +403,14 @@ enum UpdateState: Equatable { } struct Installing { - /// True if this state is triggered by ``Ghostty/UpdateDriver/updater(_:willInstallUpdateOnQuit:immediateInstallationBlock:)`` - var isAutoUpdate = false + /// Non-nil if this state is triggered by auto update + var appcastItem: SUAppcastItem? let retryTerminatingApplication: () -> Void - let dismiss: () -> Void + + var releaseNotes: ReleaseNotes? { + guard let appcastItem else { return nil } + let currentCommit = Bundle.main.infoDictionary?["GhosttyCommit"] as? String + return ReleaseNotes(displayVersionString: appcastItem.displayVersionString, currentCommit: currentCommit) + } } } diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index f3842fc56..f1df63133 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -46,6 +46,7 @@ extension Ghostty.Action { case unknown case text case html + case osc8 init(_ c: ghostty_action_open_url_kind_e) { switch c { @@ -53,6 +54,8 @@ extension Ghostty.Action { self = .text case GHOSTTY_ACTION_OPEN_URL_KIND_HTML: self = .html + case GHOSTTY_ACTION_OPEN_URL_KIND_OSC8: + self = .osc8 default: self = .unknown } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 583357077..495dd8232 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -1,18 +1,16 @@ import SwiftUI +import UniformTypeIdentifiers import UserNotifications import GhosttyKit +import AppKit protocol GhosttyAppDelegate: AnyObject { - #if os(macOS) /// Called when a callback needs access to a specific surface. This should return nil /// when the surface is no longer valid. func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? - #endif } extension Ghostty { - // IMPORTANT: THIS IS NOT DONE. - // This is a refactor/redo of Ghostty.AppState so that it supports both macOS and iOS class App: ObservableObject { enum Readiness: String { case loading, error, ready @@ -76,8 +74,6 @@ extension Ghostty { return } self.app = app - -#if os(macOS) // Set our initial focus state ghostty_app_set_focus(app, NSApp.isActive) @@ -97,18 +93,13 @@ extension Ghostty { selector: #selector(applicationDidResignActive(notification:)), name: NSApplication.didResignActiveNotification, object: nil) -#endif - self.readiness = .ready } deinit { // This will force the didSet callbacks to run which free. self.app = nil - -#if os(macOS) NotificationCenter.default.removeObserver(self) -#endif } // MARK: App Operations @@ -127,7 +118,6 @@ extension Ghostty { func openConfig() { let str = configPath ?? Ghostty.AllocatedString(ghostty_config_open_path()).string guard !str.isEmpty else { return } - #if os(macOS) let fileURL = URL(fileURLWithPath: str).absoluteString var action = ghostty_action_open_url_s() action.kind = GHOSTTY_ACTION_OPEN_URL_KIND_TEXT @@ -136,9 +126,6 @@ extension Ghostty { action.len = UInt(fileURL.count) _ = App.openURL(action) } - #else - fatalError("Unsupported platform for opening config file") - #endif } /// Reload the configuration. @@ -266,39 +253,6 @@ extension Ghostty { } } - #if os(iOS) - // MARK: Ghostty Callbacks (iOS) - - static func wakeup(_ userdata: UnsafeMutableRawPointer?) {} - static func action(_ app: ghostty_app_t, target: ghostty_target_s, action: ghostty_action_s) -> Bool { return false } - static func readClipboard( - _ userdata: UnsafeMutableRawPointer?, - location: ghostty_clipboard_e, - state: UnsafeMutableRawPointer? - ) -> Bool { - return false - } - - static func confirmReadClipboard( - _ userdata: UnsafeMutableRawPointer?, - string: UnsafePointer?, - state: UnsafeMutableRawPointer?, - request: ghostty_clipboard_request_e - ) {} - - static func writeClipboard( - _ userdata: UnsafeMutableRawPointer?, - location: ghostty_clipboard_e, - content: UnsafePointer?, - len: Int, - confirm: Bool - ) {} - - static func closeSurface(_ userdata: UnsafeMutableRawPointer?, processAlive: Bool) {} - #endif - - #if os(macOS) - // MARK: Notifications // Called when the selected keyboard changes. We have to notify Ghostty so that @@ -354,21 +308,31 @@ extension Ghostty { state: UnsafeMutableRawPointer?, request: ghostty_clipboard_request_e ) { - let surface = self.surfaceUserdata(from: userdata) - guard let valueStr = String(cString: string!, encoding: .utf8) else { return } - guard let request = Ghostty.ClipboardRequest.from(request: request) else { return } - NotificationCenter.default.post( - name: Notification.confirmClipboard, - object: surface, - userInfo: [ - Notification.ConfirmClipboardStrKey: valueStr, - Notification.ConfirmClipboardStateKey: state as Any, - Notification.ConfirmClipboardRequestKey: request, - ] - ) + let surfaceView = self.surfaceUserdata(from: userdata) + guard surfaceView.surface != nil, + let string, + let valueStr = String(cString: string, encoding: .utf8), + let kind = Ghostty.ClipboardRequest.from(request: request) else { return } + + // libghostty reaches this callback only when the request attempted + // by readClipboard requires confirmation. Reads allowed by policy + // complete immediately and never become pending Swift state. + let request = Ghostty.ClipboardConfirmationRequest( + surface: surfaceView, + contents: valueStr, + kind: kind + ) { surfaceView, contents in + guard let surface = surfaceView.surface else { return } + completeClipboardRequest( + surface, + data: contents ?? "", + state: state, + confirmed: true) + } + surfaceView.pendingClipboardConfirmation = request } - static func completeClipboardRequest( + private static func completeClipboardRequest( _ surface: ghostty_surface_t, data: String, state: UnsafeMutableRawPointer?, @@ -386,7 +350,7 @@ extension Ghostty { len: Int, confirm: Bool ) { - let surface = self.surfaceUserdata(from: userdata) + let surfaceView = self.surfaceUserdata(from: userdata) guard let pasteboard = NSPasteboard.ghostty(location) else { return } guard let content = content, len > 0 else { return } @@ -402,7 +366,8 @@ extension Ghostty { "clipboard contents should have at most one text/plain entry") if !confirm { - // Declare all types + // Apply writes allowed by policy immediately. Only writes that + // require confirmation continue to the pending request below. let types = contentArray.compactMap { item in NSPasteboard.PasteboardType(mimeType: item.mime) } @@ -421,14 +386,16 @@ extension Ghostty { return } - NotificationCenter.default.post( - name: Notification.confirmClipboard, - object: surface, - userInfo: [ - Notification.ConfirmClipboardStrKey: textPlainContent.data, - Notification.ConfirmClipboardRequestKey: Ghostty.ClipboardRequest.osc_52_write(pasteboard), - ] - ) + let request = Ghostty.ClipboardConfirmationRequest( + surface: surfaceView, + contents: textPlainContent.data, + kind: .osc_52_write + ) { _, contents in + guard let contents else { return } + pasteboard.declareTypes([.string], owner: nil) + pasteboard.setString(contents, forType: .string) + } + surfaceView.pendingClipboardConfirmation = request } static func wakeup(_ userdata: UnsafeMutableRawPointer?) { @@ -539,6 +506,9 @@ extension Ghostty { case GHOSTTY_ACTION_RENDER_INSPECTOR: renderInspector(app, target: target) + case GHOSTTY_ACTION_EXPORT_TERMINAL_IO: + return exportTerminalIO(app, target: target, v: action.action.export_terminal_io) + case GHOSTTY_ACTION_DESKTOP_NOTIFICATION: showDesktopNotification(app, target: target, n: action.action.desktop_notification) @@ -662,18 +632,12 @@ extension Ghostty { case GHOSTTY_ACTION_PRESENT_TERMINAL: return presentTerminal(app, target: target) - case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: - fallthrough - case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: - fallthrough - case GHOSTTY_ACTION_SIZE_LIMIT: - fallthrough - case GHOSTTY_ACTION_QUIT_TIMER: - fallthrough case GHOSTTY_ACTION_SHOW_CHILD_EXITED: return showChildExited(app, target: target, v: action.action.child_exited) + case GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD: return copyTitleToClipboard(app, target: target) + default: Ghostty.logger.warning("unknown action action=\(action.tag.rawValue, privacy: .public)") return false @@ -685,18 +649,8 @@ extension Ghostty { } private static func quit(_ app: ghostty_app_t) { - // On iOS, applications do not terminate programmatically like they do - // on macOS. On iOS, applications are only terminated when a user physically - // closes the application (i.e. going to the home screen). If we request - // exit on iOS we ignore it. - #if os(iOS) - logger.info("quit request received, ignoring on iOS") - #endif - - #if os(macOS) // We want to quit, start that process NSApplication.shared.terminate(nil) - #endif } private static func checkForUpdates( @@ -712,6 +666,13 @@ extension Ghostty { ) -> Bool { let action = Ghostty.Action.OpenURL(c: v) + // OSC 8 targets are producer-controlled terminal output. Keep them + // out of the unrestricted generic opener so unsafe local files and + // deceptive targets cannot reach Launch Services directly. + if action.kind == .osc8 { + return openUntrustedURL(action.url) + } + // If the URL doesn't have a valid scheme we assume its a file path. The URL // initializer will gladly take invalid URLs (e.g. plain file paths) and turn // them into schema-less URLs, but these won't open properly in text editors. @@ -741,6 +702,10 @@ extension Ghostty { case .unknown: break + + case .osc8: + assertionFailure("OSC 8 URLs must use the safe-opening policy") + return true } // Open with the default application for the URL @@ -748,6 +713,30 @@ extension Ghostty { return true } + private static func openUntrustedURL(_ value: String) -> Bool { + let target = UntrustedURL(value) + switch target.decision { + case .allow(let url): + _ = NSWorkspace.shared.open(url) + + case .confirm(let url): + UntrustedURLAlert.presentConfirmation( + for: url, + displayString: target.displayString + ) + + case .deny(let reason): + UntrustedURLAlert.presentBlock( + reason: reason, + displayString: target.displayString + ) + } + + // Always report OSC 8 actions as handled. Returning false would + // cause the core to retry with the unrestricted fallback opener. + return true + } + private static func undo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool { let undoManager: UndoManager? switch target.tag { @@ -1394,6 +1383,41 @@ extension Ghostty { } } + private static func exportTerminalIO( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_export_terminal_io_s + ) -> Bool { + guard target.tag == GHOSTTY_TARGET_SURFACE, + let surface = target.target.surface, + let surfaceView = self.surfaceView(from: surface), + let window = surfaceView.window, + let contents = v.contents + else { return false } + + // The action data is borrowed for the duration of this callback, + // so copy it before presenting the asynchronous save panel. + let data = Data(bytes: contents, count: v.len) + DispatchQueue.main.async { + let panel = NSSavePanel() + panel.allowedContentTypes = [.plainText] + panel.canCreateDirectories = true + panel.nameFieldStringValue = "ghostty-terminal-io.txt" + panel.beginSheetModal(for: window) { response in + guard response == .OK, let url = panel.url else { return } + do { + try data.write(to: url, options: .atomic) + } catch { + Ghostty.logger.error( + "Failed to export terminal IO events: \(error, privacy: .public)" + ) + } + } + } + + return true + } + private static func showDesktopNotification( _ app: ghostty_app_t, target: ghostty_target_s, @@ -2086,7 +2110,7 @@ extension Ghostty { DispatchQueue.main.async { if let searchState = surfaceView.searchState { if let needle = startSearch.needle, !needle.isEmpty { - searchState.needle = needle + searchState.setNeedle(needle) } } else { surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch) @@ -2284,7 +2308,5 @@ extension Ghostty { break } } - - #endif } } diff --git a/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift b/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift new file mode 100644 index 000000000..3049366f0 --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift @@ -0,0 +1,114 @@ +import Foundation +import GhosttyKit + +extension Ghostty { + /// The type of a clipboard request. + enum ClipboardRequest { + /// A direct paste of clipboard contents. + case paste + + /// An application is attempting to read from the clipboard using OSC 52. + case osc_52_read + + /// An application is attempting to write to the clipboard using OSC 52. + case osc_52_write + + /// The text to show in the clipboard confirmation prompt for this request. + func text() -> String { + switch self { + case .paste: + return """ + Pasting this text to the terminal may be dangerous as it looks like some commands may be executed. + """ + case .osc_52_read: + return """ + An application is attempting to read from the clipboard. + The current clipboard contents are shown below. + """ + case .osc_52_write: + return """ + An application is attempting to write to the clipboard. + The content to write is shown below. + """ + } + } + + static func from(request: ghostty_clipboard_request_e) -> ClipboardRequest? { + switch request { + case GHOSTTY_CLIPBOARD_REQUEST_PASTE: + return .paste + case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ: + return .osc_52_read + case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE: + return .osc_52_write + default: + return nil + } + } + } + + /// A one-shot clipboard confirmation originating from libghostty. + /// + /// This object owns the callback state until it is completed or cancelled. + /// Dropping an unresolved request schedules its cancellation so raw + /// libghostty state cannot leak when no UI is available to handle the + /// notification. Cancellation is deferred because notification delivery + /// occurs from inside the libghostty callback that created the request. + final class ClipboardConfirmationRequest { + private(set) weak var surface: SurfaceView? + let contents: String + let kind: ClipboardRequest + + private var completion: ((SurfaceView, String?) -> Void)? + + init( + surface: SurfaceView, + contents: String, + kind: ClipboardRequest, + completion: @escaping (SurfaceView, String?) -> Void + ) { + self.surface = surface + self.contents = contents + self.kind = kind + self.completion = completion + } + + deinit { + guard let surface, let completion else { return } + self.completion = nil + DispatchQueue.main.async { + completion(surface, nil) + } + } + + /// Complete the request using the displayed clipboard contents. + func complete() { + finish(contents) + } + + /// Cancel the request without using the displayed clipboard contents. + func cancel() { + finish(nil) + } + + /// Cancel using the owning surface explicitly. SurfaceView uses this + /// for replacement and teardown because its weak reference is already + /// nil during the owner's deinitialization. + func cancel(from surface: SurfaceView) { + finish(nil, on: surface) + } + + private func finish( + _ contents: String?, + on explicitSurface: SurfaceView? = nil + ) { + guard let surface = explicitSurface ?? self.surface, + let completion else { + self.completion = nil + return + } + self.completion = nil + completion(surface, contents) + } + } +} diff --git a/macos/Sources/Ghostty/Ghostty.Command.swift b/macos/Sources/Ghostty/Ghostty.Command.swift index 797d469c5..045ac595e 100644 --- a/macos/Sources/Ghostty/Ghostty.Command.swift +++ b/macos/Sources/Ghostty/Ghostty.Command.swift @@ -26,6 +26,7 @@ extension Ghostty { static let unsupportedActionKeys: [String] = [ "toggle_tab_overview", "toggle_window_decorations", + "prompt_window_title", "show_gtk_inspector", ] diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index d7134029d..bfe42b114 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -65,9 +65,6 @@ extension Ghostty { } // Load our configuration from files, CLI args, and then any referenced files. - // We only do this on macOS because other Apple platforms do not have the - // same filesystem concept. -#if os(macOS) if let path { ghostty_config_load_file(cfg, path) } else { @@ -81,7 +78,6 @@ extension Ghostty { } ghostty_config_load_recursive_files(cfg) -#endif // TODO: we'd probably do some config loading here... for now we'd // have to do this synchronously. When we support config updating we can do @@ -108,7 +104,6 @@ extension Ghostty { return cfg } -#if os(macOS) // MARK: - Keybindings /// Return the key equivalent for the given action. The action is the name of the action @@ -122,7 +117,6 @@ extension Ghostty { let trigger = ghostty_config_trigger(cfg, action, UInt(action.lengthOfBytes(using: .utf8))) return Ghostty.keyboardShortcut(for: trigger) } -#endif // MARK: - Configuration Values @@ -274,10 +268,19 @@ extension Ghostty { return v } + var dragHandle: DragHandle { + let defaultValue = DragHandle.auto + guard let config = self.config else { return defaultValue } + var v: UnsafePointer? + let key = "drag-handle" + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } + guard let ptr = v else { return defaultValue } + return DragHandle(rawValue: String(cString: ptr)) ?? defaultValue + } + /// Returns the fullscreen mode if fullscreen is enabled, or nil if disabled. /// This parses the `fullscreen` enum config which supports both /// native and non-native fullscreen modes. - #if canImport(AppKit) var windowFullscreen: FullscreenMode? { guard let config = self.config else { return nil } var v: UnsafePointer? @@ -300,21 +303,9 @@ extension Ghostty { nil } } - #else - var windowFullscreen: Bool { - guard let config = self.config else { return false } - var v: UnsafePointer? - let key = "fullscreen" - guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return false } - guard let ptr = v else { return false } - let str = String(cString: ptr) - return str != "false" - } - #endif /// Returns the fullscreen mode for toggle actions (keybindings). /// This is controlled by `macos-non-native-fullscreen` config. - #if canImport(AppKit) var windowFullscreenMode: FullscreenMode { let defaultValue: FullscreenMode = .native guard let config = self.config else { return defaultValue } @@ -336,7 +327,6 @@ extension Ghostty { defaultValue } } - #endif var windowTitleFontFamily: String? { guard let config = self.config else { return nil } @@ -410,7 +400,6 @@ extension Ghostty { } var macosCustomIcon: String { - #if os(macOS) let defaultValue = NSString("~/.config/ghostty/Ghostty.icns").expandingTildeInPath guard let config = self.config else { return defaultValue } var v: UnsafePointer? @@ -419,9 +408,6 @@ extension Ghostty { guard let ptr = v else { return defaultValue } guard let path = NSString(utf8String: ptr) else { return defaultValue } return path.expandingTildeInPath - #else - return "" - #endif } var macosIconFrame: MacOSIconFrame { @@ -435,7 +421,7 @@ extension Ghostty { return MacOSIconFrame(rawValue: str) ?? defaultValue } - var macosIconGhostColor: OSColor? { + var macosIconGhostColor: NSColor? { guard let config = self.config else { return nil } var v: ghostty_config_color_s = .init() let key = "macos-icon-ghost-color" @@ -443,7 +429,7 @@ extension Ghostty { return .init(ghostty: v) } - var macosIconScreenColor: [OSColor]? { + var macosIconScreenColor: [NSColor]? { guard let config = self.config else { return nil } var v: ghostty_config_color_list_s = .init() let key = "macos-icon-screen-color" @@ -475,13 +461,7 @@ extension Ghostty { var color: ghostty_config_color_s = .init() let bg_key = "background" if !ghostty_config_get(config, &color, bg_key, UInt(bg_key.lengthOfBytes(using: .utf8))) { -#if os(macOS) return Color(NSColor.windowBackgroundColor) -#elseif os(iOS) - return Color(UIColor.systemBackground) -#else -#error("unsupported") -#endif } return .init( @@ -533,7 +513,7 @@ extension Ghostty { } var splitDividerColor: Color { - let backgroundColor = OSColor(backgroundColor) + let backgroundColor = NSColor(backgroundColor) let isLightBackground = backgroundColor.isLightColor let newColor = isLightBackground ? backgroundColor.darken(by: 0.08) : backgroundColor.darken(by: 0.4) @@ -552,7 +532,6 @@ extension Ghostty { ) } - #if canImport(AppKit) var quickTerminalPosition: QuickTerminalPosition { guard let config = self.config else { return .top } var v: UnsafePointer? @@ -606,7 +585,6 @@ extension Ghostty { guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return QuickTerminalSize() } return QuickTerminalSize(from: v) } - #endif var resizeOverlay: ResizeOverlay { guard let config = self.config else { return .after_first } @@ -933,4 +911,8 @@ extension Ghostty.Config { static let `default` = MacOSTitlebarStyle.transparent case native, transparent, tabs, hidden } + + enum DragHandle: String { + case always, auto, never + } } diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index d90fb987e..308312bce 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -518,7 +518,6 @@ extension Ghostty.Input.Momentum: AppEnum { ] } -#if canImport(AppKit) import AppKit extension Ghostty.Input.Momentum { @@ -535,7 +534,6 @@ extension Ghostty.Input.Momentum { } } } -#endif // MARK: Ghostty.Input.Mods diff --git a/macos/Sources/Ghostty/Ghostty.Inspector.swift b/macos/Sources/Ghostty/Ghostty.Inspector.swift index 79567bc4a..fc1983b8f 100644 --- a/macos/Sources/Ghostty/Ghostty.Inspector.swift +++ b/macos/Sources/Ghostty/Ghostty.Inspector.swift @@ -6,7 +6,9 @@ extension Ghostty { /// /// Wraps a `ghostty_inspector_t` final class Inspector: Sendable { - private let inspector: ghostty_inspector_t + /// A inspector is sendable because it is just a reference type. Using the inspector in parameters + /// may be unsafe but the value itself is safe to send across threads. + nonisolated(unsafe) private let inspector: ghostty_inspector_t /// Read the underlying C value for this inspector. This is unsafe because the value will be /// freed when the Inspector class is deinitialized. diff --git a/macos/Sources/Ghostty/Ghostty.Surface.swift b/macos/Sources/Ghostty/Ghostty.Surface.swift index 820441090..a076063d5 100644 --- a/macos/Sources/Ghostty/Ghostty.Surface.swift +++ b/macos/Sources/Ghostty/Ghostty.Surface.swift @@ -10,7 +10,9 @@ extension Ghostty { /// /// Wraps a `ghostty_surface_t` final class Surface: Sendable { - private let surface: ghostty_surface_t + /// A surface is sendable because it is just a reference type. Using the surface in parameters + /// may be unsafe but the value itself is safe to send across threads. + nonisolated(unsafe) private let surface: ghostty_surface_t /// Read the underlying C value for this surface. This is unsafe because the value will be /// freed when the Surface class is deinitialized. diff --git a/macos/Sources/Ghostty/GhosttyPackage.swift b/macos/Sources/Ghostty/GhosttyPackage.swift index 757970e00..313223aa3 100644 --- a/macos/Sources/Ghostty/GhosttyPackage.swift +++ b/macos/Sources/Ghostty/GhosttyPackage.swift @@ -4,13 +4,6 @@ import GhosttyKit // MARK: C Extensions -/// A command is fully self-contained so it is Sendable. -extension ghostty_command_s: @unchecked @retroactive Sendable {} - -/// A surface is sendable because it is just a reference type. Using the surface in parameters -/// may be unsafe but the value itself is safe to send across threads. -extension ghostty_surface_t: @unchecked @retroactive Sendable {} - extension Ghostty { // The user notification category identifier static let userNotificationCategory = "com.mitchellh.ghostty.userNotification" @@ -216,7 +209,6 @@ extension Ghostty { } } -#if canImport(AppKit) // MARK: SplitFocusDirection Extensions extension Ghostty.SplitFocusDirection { @@ -243,54 +235,8 @@ extension Ghostty.SplitFocusDirection { } } } -#endif extension Ghostty { - /// The type of a clipboard request - enum ClipboardRequest { - /// A direct paste of clipboard contents - case paste - - /// An application is attempting to read from the clipboard using OSC 52 - case osc_52_read - - /// An application is attempting to write to the clipboard using OSC 52 - case osc_52_write(OSPasteboard?) - - /// The text to show in the clipboard confirmation prompt for a given request type - func text() -> String { - switch self { - case .paste: - return """ - Pasting this text to the terminal may be dangerous as it looks like some commands may be executed. - """ - case .osc_52_read: - return """ - An application is attempting to read from the clipboard. - The current clipboard contents are shown below. - """ - case .osc_52_write: - return """ - An application is attempting to write to the clipboard. - The content to write is shown below. - """ - } - } - - static func from(request: ghostty_clipboard_request_e) -> ClipboardRequest? { - switch request { - case GHOSTTY_CLIPBOARD_REQUEST_PASTE: - return .paste - case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ: - return .osc_52_read - case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE: - return .osc_52_write(nil) - default: - return nil - } - } - } - struct ClipboardContent { let mime: String let data: String @@ -426,11 +372,6 @@ extension Ghostty.Notification { /// Notification to show/hide the inspector static let didControlInspector = Notification.Name("com.mitchellh.ghostty.didControlInspector") - static let confirmClipboard = Notification.Name("com.mitchellh.ghostty.confirmClipboard") - static let ConfirmClipboardStrKey = confirmClipboard.rawValue + ".str" - static let ConfirmClipboardStateKey = confirmClipboard.rawValue + ".state" - static let ConfirmClipboardRequestKey = confirmClipboard.rawValue + ".request" - /// Notification sent to the active split view to resize the split. static let didResizeSplit = Notification.Name("com.mitchellh.ghostty.didResizeSplit") static let ResizeSplitDirectionKey = didResizeSplit.rawValue + ".direction" diff --git a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift index bc822cbd9..9f2926c8b 100644 --- a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift @@ -3,7 +3,7 @@ import GhosttyKit import SwiftUI extension Ghostty { - class OSSurfaceView: OSView, ObservableObject { + class OSSurfaceView: NSView, ObservableObject { typealias ID = UUID /// Unique ID per surface @@ -117,41 +117,55 @@ extension Ghostty { extension Ghostty.OSSurfaceView { @MainActor class SearchState: ObservableObject { + + /// We should always change needle's text and its selection together + struct Needle: Equatable { + var text: String + var selection: Range? + + static let empty = Needle(text: "", selection: nil) + } + /// The pasteboard used to persist the search needle. /// /// The `.find` pasteboard lets us sync our needle across the system and other find bars. - private let pasteboard: OSPasteboard + private let pasteboard: NSPasteboard + + @Published var needle = Needle.empty - @Published var needle: String = "" @Published var selected: UInt? @Published var total: UInt? - /// The range of the needle's text selection in the find bar. - @Published var needleSelection: Range? - init( from startSearch: Ghostty.Action.StartSearch, - pasteboard: OSPasteboard = OSPasteboard.find + pasteboard: NSPasteboard? = nil ) { - self.pasteboard = pasteboard + self.pasteboard = pasteboard ?? .find if let needle = startSearch.needle, !needle.isEmpty { - self.needle = needle + setNeedle(needle) writePasteboardNeedle() } else { readPasteboardNeedle() } } + /// Replaces the search needle while keeping its selection valid. + func setNeedle(_ needle: String, selectAll: Bool = false) { + self.needle = .init( + text: needle, + selection: selectAll ? needle.startIndex.. Self? { - #if canImport(AppKit) guard let del = NSApp.delegate as? Ghostty.Delegate else { return nil } return del.ghosttySurface(id: uuid) as? Self - #elseif canImport(UIKit) - // We should be able to use UIApplication here. - return nil - #else - return nil - #endif } } @@ -50,9 +41,7 @@ extension UTType { static let ghosttySurfaceId = UTType(exportedAs: "com.mitchellh.ghosttySurfaceId") } -#if canImport(AppKit) extension NSPasteboard.PasteboardType { /// Pasteboard type for dragging surface IDs. static let ghosttySurfaceId = NSPasteboard.PasteboardType(UTType.ghosttySurfaceId.identifier) } -#endif diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index f6b30a7ad..e1fab0a3a 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -4,35 +4,6 @@ import GhosttyKit import System extension Ghostty { - /// Render a terminal for the active app in the environment. - struct Terminal: View { - @EnvironmentObject private var ghostty: Ghostty.App - - var body: some View { - if let app = self.ghostty.app { - SurfaceForApp(app) { surfaceView in - SurfaceWrapper(surfaceView: surfaceView) - } - } - } - } - - /// Yields a SurfaceView for a ghostty app that can then be used however you want. - struct SurfaceForApp: View { - let content: ((SurfaceView) -> Content) - - @StateObject private var surfaceView: SurfaceView - - init(_ app: ghostty_app_t, @ViewBuilder content: @escaping ((SurfaceView) -> Content)) { - _surfaceView = StateObject(wrappedValue: SurfaceView(app)) - self.content = content - } - - var body: some View { - content(surfaceView) - } - } - struct SurfaceWrapper: View { // The surface to create a view for. This must be created upstream. As long as this // remains the same, the surface that is being rendered remains the same. @@ -48,10 +19,8 @@ extension Ghostty { // Maintain whether our window has focus (is key) or not @State private var windowFocus: Bool = true - #if canImport(AppKit) // Observe SecureInput to detect when its enabled @ObservedObject private var secureInput = SecureInput.shared - #endif @EnvironmentObject private var ghostty: Ghostty.App @Environment(\.ghosttyLastFocusedSurface) private var lastFocusedSurface @@ -68,17 +37,14 @@ extension Ghostty { // is up to date. See TerminalSurfaceView for why we don't use the NSView // resize callback. GeometryReader { geo in - #if canImport(AppKit) let pubBecomeKey = center.publisher(for: NSWindow.didBecomeKeyNotification) let pubResign = center.publisher(for: NSWindow.didResignKeyNotification) - #endif SurfaceRepresentable(view: surfaceView, size: geo.size) .focused($surfaceFocus) .focusedValue(\.ghosttySurfacePwd, surfaceView.pwd) .focusedValue(\.ghosttySurfaceView, surfaceView) .focusedValue(\.ghosttySurfaceCellSize, surfaceView.cellSize) - #if canImport(AppKit) .onReceive(pubBecomeKey) { notification in guard let window = notification.object as? NSWindow else { return } guard let surfaceWindow = surfaceView.window else { return } @@ -91,7 +57,6 @@ extension Ghostty { windowFocus = false } } - #endif // If our geo size changed then we show the resize overlay as configured. if let surfaceSize = surfaceView.surfaceSize { @@ -118,7 +83,6 @@ extension Ghostty { .transition(.opacity) } -#if canImport(AppKit) // Readonly indicator badge if surfaceView.readonly { ReadonlyBadge { @@ -132,7 +96,6 @@ extension Ghostty { keySequence: surfaceView.keySequence ) .zIndex(1) -#endif VStack(spacing: 0) { // If we have a URL from hovering a link, we show that. @@ -148,7 +111,6 @@ extension Ghostty { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - #if canImport(AppKit) // If we have secure input enabled and we're the focused surface and window // then we want to show the secure input overlay. if ghostty.config.secureInputIndication && @@ -157,7 +119,6 @@ extension Ghostty { windowFocus { SecureInputOverlay() } - #endif // Search overlay if let searchState = surfaceView.searchState { @@ -200,13 +161,12 @@ extension Ghostty { } } - #if canImport(AppKit) // Grab handle for dragging the window. We want this to appear at the very // top Z-index os it isn't faded by the unfocused overlay. - // - // This is disabled except on macOS because it uses AppKit drag/drop APIs. - SurfaceGrabHandle(surfaceView: surfaceView) - #endif + SurfaceGrabHandle( + surfaceView: surfaceView, + dragHandle: ghostty.config.dragHandle, + ) } } } @@ -379,8 +339,8 @@ extension Ghostty { HStack(spacing: 4) { BackportSelectionTextField( "Search", - text: $searchState.needle, - selection: $searchState.needleSelection + text: $searchState.needle.text, + selection: $searchState.needle.selection ) .textFieldStyle(.plain) .frame(width: 180) @@ -405,12 +365,12 @@ extension Ghostty { .padding(.trailing, 8) } } - .onChange(of: searchState.needle) { _ in + .onChange(of: searchState.needle.text) { _ in searchState.writePasteboardNeedle() } .onReceive( NotificationCenter.default.publisher( - for: OSApplication.didBecomeActiveNotification + for: NSApplication.didBecomeActiveNotification ) ) { _ in // When the app becomes active, we want to check for external changes @@ -420,15 +380,13 @@ extension Ghostty { .onSubmit { _ = surfaceView.navigateSearchToNext() } -#if canImport(AppKit) .onExitCommand { - if searchState.needle.isEmpty { + if searchState.needle.text.isEmpty { onClose() } else { Ghostty.moveFocus(to: surfaceView) } } -#endif .backport.onKeyPress(.return) { modifiers in if modifiers.contains(.shift) { _ = surfaceView.navigateSearchToPrevious() @@ -503,7 +461,7 @@ extension Ghostty { } private var clipShape: some Shape { - if #available(iOS 26.0, macOS 26.0, *) { + if #available(macOS 26.0, *) { return ConcentricRectangle(corners: .concentric(minimum: 8), isUniform: true) } else { return RoundedRectangle(cornerRadius: 8) @@ -583,7 +541,7 @@ extension Ghostty { /// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn /// and interacted with. The word "surface" is used because a surface may represent a window, a tab, /// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it. - struct SurfaceRepresentable: OSViewRepresentable { + struct SurfaceRepresentable: NSViewRepresentable { /// The view to render for the terminal surface. let view: SurfaceView @@ -597,13 +555,11 @@ extension Ghostty { /// The best approach is to wrap this view in a GeometryReader and pass in the geo.size. let size: CGSize - #if canImport(AppKit) - func makeOSView(context: Context) -> SurfaceScrollView { - // On macOS, wrap the surface view in a scroll view + func makeNSView(context: Context) -> SurfaceScrollView { return SurfaceScrollView(contentSize: size, surfaceView: view) } - func updateOSView(_ scrollView: SurfaceScrollView, context: Context) { + func updateNSView(_ scrollView: SurfaceScrollView, context: Context) { // SwiftUI may defer frame updates under system load (e.g., memory // pressure, heavy I/O) or when external window managers trigger rapid // layout changes. When that happens, the scroll view's bounds can @@ -612,16 +568,6 @@ extension Ghostty { guard scrollView.bounds.size != size else { return } scrollView.needsLayout = true } - #else - func makeOSView(context: Context) -> SurfaceView { - // On iOS, return the surface view directly - return view - } - - func updateOSView(_ view: SurfaceView, context: Context) { - view.sizeDidChange(size) - } - #endif } /// The configuration for a surface. For any configuration not set, defaults will be chosen from @@ -682,25 +628,11 @@ extension Ghostty { func withCValue(view: SurfaceView, _ body: (inout ghostty_surface_config_s) throws -> T) rethrows -> T { var config = ghostty_surface_config_new() config.userdata = Unmanaged.passUnretained(view).toOpaque() -#if os(macOS) config.platform_tag = GHOSTTY_PLATFORM_MACOS config.platform = ghostty_platform_u(macos: ghostty_platform_macos_s( nsview: Unmanaged.passUnretained(view).toOpaque() )) config.scale_factor = NSScreen.main!.backingScaleFactor -#elseif os(iOS) - config.platform_tag = GHOSTTY_PLATFORM_IOS - config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s( - uiview: Unmanaged.passUnretained(view).toOpaque() - )) - // Note that UIScreen.main is deprecated and we're supposed to get the - // screen through the view hierarchy instead. This means that we should - // probably set this to some default, then modify the scale factor through - // libghostty APIs when a UIView is attached to a window/scene. TODO. - config.scale_factor = UIScreen.main.scale -#else -#error("unsupported target") -#endif // Zero is our default value that means to inherit the font size. config.font_size = fontSize ?? 0 @@ -751,7 +683,6 @@ extension Ghostty { } } -#if canImport(AppKit) /// Floating indicator that shows active key tables and pending key sequences. /// Displayed as a compact draggable pill that can be positioned at the top or bottom. struct KeyStateIndicator: View { @@ -969,7 +900,6 @@ extension Ghostty { } } } -#endif /// Visual overlay that shows a border around the edges when the bell rings with border feature enabled. struct BellBorderOverlay: View { @@ -1133,7 +1063,6 @@ extension Ghostty { } } - #if canImport(AppKit) /// When changing the split state, or going full screen (native or non), the terminal view /// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't /// figure it out so we're going to do this hacky thing to bring focus back to the terminal @@ -1187,7 +1116,6 @@ extension Ghostty { queue.async(execute: work) } } - #endif } // MARK: Surface Environment Keys @@ -1246,12 +1174,12 @@ extension FocusedValues { typealias Value = String } - var ghosttySurfaceCellSize: OSSize? { + var ghosttySurfaceCellSize: CGSize? { get { self[FocusedGhosttySurfaceCellSize.self] } set { self[FocusedGhosttySurfaceCellSize.self] = newValue } } struct FocusedGhosttySurfaceCellSize: FocusedValueKey { - typealias Value = OSSize + typealias Value = CGSize } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 4fb156317..35b99e436 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -48,6 +48,7 @@ extension Ghostty { // needle is less than 3 chars, we debounce it for a few hundred ms to // avoid kicking off expensive searches. searchNeedleCancellable = searchState.$needle + .map(\.text) .removeDuplicates() .map { needle -> AnyPublisher in if needle.isEmpty || needle.count >= 3 { @@ -108,6 +109,13 @@ extension Ghostty { /// True when the bell is active. This is set inactive on focus or event. @Published private(set) var bell: Bool = false + /// A clipboard confirmation waiting to be handled by its controller. + @Published var pendingClipboardConfirmation: ClipboardConfirmationRequest? { + didSet { + pendingClipboardConfirmationDidChange(from: oldValue) + } + } + // An initial size to request for a window. This will only affect // then the view is moved to a new window. var initialSize: NSSize? @@ -190,6 +198,8 @@ extension Ghostty { // This is set to non-null during keyDown to accumulate insertText contents private var keyTextAccumulator: [String]? + /// Temporary lead surrogate that's waiting for the trail + private var leadSurrogate: LeadSurrogate? // True when we've consumed a left mouse-down only to move focus and // should suppress the matching mouse-up from being reported. @@ -294,13 +304,21 @@ extension Ghostty { accessibilitySelectionCancellable = NotificationCenter.default // The publisher retains its object, so filtering with a weak capture // avoids a cycle between self and the stored cancellable. + // But we also need to be careful to do the map below (see + // comment below) .publisher(for: .ghosttySelectionDidChange) .filter { [weak self] notification in guard let self else { return false } return notification.object as AnyObject? === self } + .map { _ in + // Debounce retains its latest upstream value. In this + // case its a Notification, which retains its object, + // which is a surface. So this creates a retain cycle. + // This discards the notification before debounce. + } .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) - .sink { [weak self] _ in + .sink { [weak self] in guard let self else { return } NSAccessibility.post(element: self, notification: .selectedTextChanged) } @@ -388,6 +406,11 @@ extension Ghostty { } deinit { + // Resolve clipboard callback state while surfaceModel is still + // alive. The request's weak SurfaceView reference is already nil + // during deinit, so didSet passes this instance explicitly. + pendingClipboardConfirmation = nil + // Remove all of our notificationcenter subscriptions let center = NotificationCenter.default center.removeObserver(self) @@ -1764,26 +1787,25 @@ extension Ghostty { // Note the callback may be executed on a background thread as documented // so we need @MainActor since we're reading/writing view state. - UNUserNotificationCenter.current().add(request) { @MainActor error in - if let error = error { - AppDelegate.logger.error("Error scheduling user notification: \(error, privacy: .public)") - return - } + Task { @MainActor in + do { + try await UNUserNotificationCenter.current().add(request) - // We need to keep track of this notification so we can remove it - // under certain circumstances - self.notificationIdentifiers.insert(uuid) + // We need to keep track of this notification so we can remove it + // under certain circumstances + notificationIdentifiers.insert(uuid) - // If we're focused then we schedule to remove the notification - // after a few seconds. If we gain focus we automatically remove it - // in focusDidChange. - if self.focused { - Task { @MainActor [weak self] in + // If we're focused then we schedule to remove the notification + // after a few seconds. If we gain focus we automatically remove it + // in focusDidChange. + if self.focused { try await Task.sleep(for: .seconds(3)) - self?.notificationIdentifiers.remove(uuid) + notificationIdentifiers.remove(uuid) UNUserNotificationCenter.current() .removeDeliveredNotifications(withIdentifiers: [uuid]) } + } catch { + AppDelegate.logger.error("Error scheduling user notification: \(error, privacy: .public)") } } } @@ -1874,6 +1896,18 @@ extension Ghostty { } } +// MARK: Clipboard Confirmation + +extension Ghostty.SurfaceView { + /// Cancel the request that a new published value replaces or clears. + private func pendingClipboardConfirmationDidChange( + from previous: Ghostty.ClipboardConfirmationRequest? + ) { + guard previous !== pendingClipboardConfirmation else { return } + previous?.cancel(from: self) + } +} + // MARK: - NSTextInputClient extension Ghostty.SurfaceView: NSTextInputClient { @@ -2008,7 +2042,7 @@ extension Ghostty.SurfaceView: NSTextInputClient { x += cellSize.width * Double(range.location + range.length) } // Ghostty coordinates are in top-left (0, 0) so we have to convert to - // bottom-left since that is what UIKit expects + // bottom-left since that is what AppKit expects // when there's is no characters selected, // width should be 0 so that dictation indicator // can start in the right place @@ -2036,8 +2070,21 @@ extension Ghostty.SurfaceView: NSTextInputClient { switch string { case let v as NSAttributedString: chars = v.string - case let v as String: - chars = v + case let v as NSString: + if let leadSurrogate = LeadSurrogate(v) { + self.leadSurrogate = leadSurrogate + chars = "" + } else if let trail = TrailSurrogate(v) { + // We ignore trail surrogate without a lead like Terminal.app. + chars = leadSurrogate?.encode(trail: trail) ?? "" + leadSurrogate = nil + } else { + chars = v as String + // Clear whenever other text got inserted. + // Ideally we should encode any adjacent lead and trail surrogate into one, + // but getting the cursor position and reading could be rather expensive to do. + leadSurrogate = nil + } default: return } @@ -2370,6 +2417,7 @@ extension Ghostty.SurfaceView { /// We use this to cache our surface content. This probably should be extracted some day /// to a more generic helper. class CachedValue { + private let lock = NSLock() private var value: T? private let fetch: () -> T private let duration: Duration @@ -2381,10 +2429,15 @@ class CachedValue { } deinit { + lock.lock() expiryTask?.cancel() + lock.unlock() } func get() -> T { + lock.lock() + defer { lock.unlock() } + if let value { return value } @@ -2399,8 +2452,7 @@ class CachedValue { expiryTask = Task { [weak self] in do { try await Task.sleep(until: expires) - self?.value = nil - self?.expiryTask = nil + self?.expire() } catch { // Task was cancelled, do nothing } @@ -2408,4 +2460,50 @@ class CachedValue { return result } + + private func expire() { + lock.lock() + defer { lock.unlock() } + + value = nil + expiryTask = nil + } +} + +/// Check if a UTF16 text is a single lead surrogate character +struct LeadSurrogate { + let char: UTF16Char + + init?(_ text: NSString) { + guard text.length == 1 else { + return nil + } + let char = text.character(at: 0) + if UTF16.isLeadSurrogate(char) { + self.char = char + } else { + return nil + } + } + + func encode(trail: TrailSurrogate) -> String { + String(decoding: [char, trail.char], as: UTF16.self) + } +} + +/// Check if a UTF16 text is a single trail surrogate character +struct TrailSurrogate { + let char: UTF16Char + + init?(_ text: NSString) { + guard text.length == 1 else { + return nil + } + let char = text.character(at: 0) + if UTF16.isTrailSurrogate(char) { + self.char = char + } else { + return nil + } + } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_UIKit.swift deleted file mode 100644 index 6c1480b4e..000000000 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_UIKit.swift +++ /dev/null @@ -1,84 +0,0 @@ -import SwiftUI -import GhosttyKit - -extension Ghostty { - /// The UIView implementation for a terminal surface. - class SurfaceView: OSSurfaceView { - // The current title of the surface as defined by the pty. This can be - // changed with escape codes. - @Published private(set) var title: String = "👻" - - /// True when the bell is active. This is set inactive on focus or event. - @Published var bell: Bool = false - - private(set) var _surface: ghostty_surface_t? - - override var surface: ghostty_surface_t? { - _surface - } - - init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) { - - // Initialize with some default frame size. The important thing is that this - // is non-zero so that our layer bounds are non-zero so that our renderer - // can do SOMETHING. - super.init(id: uuid, frame: CGRect(x: 0, y: 0, width: 800, height: 600)) - - // Setup our surface. This will also initialize all the terminal IO. - let surface_cfg = baseConfig ?? SurfaceConfiguration() - let surface = surface_cfg.withCValue(view: self) { surface_cfg_c in - ghostty_surface_new(app, &surface_cfg_c) - } - guard let surface = surface else { - // TODO - return - } - self._surface = surface - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) is not supported for this view") - } - - deinit { - guard let surface = self.surface else { return } - ghostty_surface_free(surface) - } - - override func focusDidChange(_ focused: Bool) { - guard let surface = self.surface else { return } - ghostty_surface_set_focus(surface, focused) - - // On macOS 13+ we can store our continuous clock... - if focused { - focusInstant = ContinuousClock.now - } - } - - override func sizeDidChange(_ size: CGSize) { - guard let surface = self.surface else { return } - - // Ghostty wants to know the actual framebuffer size... It is very important - // here that we use "size" and NOT the view frame. If we're in the middle of - // an animation (i.e. a fullscreen animation), the frame will not yet be updated. - // The size represents our final size we're going for. - let scale = self.contentScaleFactor - ghostty_surface_set_content_scale(surface, scale, scale) - ghostty_surface_set_size( - surface, - UInt32(size.width * scale), - UInt32(size.height * scale) - ) - } - - // MARK: UIView - - override class var layerClass: AnyClass { - return CAMetalLayer.self - } - - override func didMoveToWindow() { - sizeDidChange(frame.size) - } - } -} diff --git a/macos/Sources/Helpers/Backport.swift b/macos/Sources/Helpers/Backport.swift index 3dcbaa0c0..4f7123424 100644 --- a/macos/Sources/Helpers/Backport.swift +++ b/macos/Sources/Helpers/Backport.swift @@ -26,32 +26,23 @@ enum BackportKeyPressResult { extension Backport where Content: View { func pointerVisibility(_ v: BackportVisibility) -> some View { - #if canImport(AppKit) if #available(macOS 15, *) { return content.pointerVisibility(v.official) } else { return content } - #else - return content - #endif } func pointerStyle(_ style: BackportPointerStyle?) -> some View { - #if canImport(AppKit) if #available(macOS 15, *) { return content.pointerStyle(style?.official) } else { return content } - #else - return content - #endif } /// Backported onKeyPress that works on macOS 14+ and is a no-op on macOS 13. func onKeyPress(_ key: KeyEquivalent, action: @escaping (EventModifiers) -> BackportKeyPressResult) -> some View { - #if canImport(AppKit) if #available(macOS 14, *) { return content.onKeyPress(key, phases: .down, action: { keyPress in switch action(keyPress.modifiers) { @@ -62,9 +53,6 @@ extension Backport where Content: View { } else { return content } - #else - return content - #endif } } @@ -97,7 +85,6 @@ enum BackportPointerStyle { case resizeUpDown case resizeLeftRight - #if canImport(AppKit) @available(macOS 15, *) var official: PointerStyle { switch self { @@ -115,13 +102,11 @@ enum BackportPointerStyle { case .resizeLeftRight: return .columnResize } } - #endif } enum BackportNSGlassStyle { case regular, clear - #if canImport(AppKit) @available(macOS 26, *) var official: NSGlassEffectView.Style { switch self { @@ -129,11 +114,21 @@ enum BackportNSGlassStyle { case .clear: return .clear } } - #endif } -/// Backported `TextField` that supports text selection on macOS 15/iOS 18 and up. The `selection` -/// has no effect on versions below macOS 15/iOS 18. +/// Backported `TextField` that supports text selection on macOS 26 and up. The `selection` +/// has no effect on versions below macOS 26. +/// +/// Although the API is available from macOS 15, we force it to be 26. Because on macOS 15, +/// SwiftUI will crash when deleting texts, even for this simple example. +/// +/// struct ContentView: View { +/// @State private var text = "" +/// @State private var selection: TextSelection? +/// var body: some View { +/// TextField("Search", text: $text, selection: $selection) +/// } +/// } struct BackportSelectionTextField: View { private let titleKey: LocalizedStringKey @Binding private var text: String @@ -150,7 +145,7 @@ struct BackportSelectionTextField: View { } var body: some View { - if #available(iOS 18.0, macOS 15, *) { + if #available(macOS 26, *) { TextField( titleKey, text: _text, diff --git a/macos/Sources/Helpers/CrossKit.swift b/macos/Sources/Helpers/CrossKit.swift deleted file mode 100644 index c7b782072..000000000 --- a/macos/Sources/Helpers/CrossKit.swift +++ /dev/null @@ -1,58 +0,0 @@ -// This file is a helper to bridge some types that are effectively identical -// between AppKit and UIKit. - -import SwiftUI - -#if canImport(AppKit) - -import AppKit - -typealias OSView = NSView -typealias OSColor = NSColor -typealias OSSize = NSSize -typealias OSPasteboard = NSPasteboard -typealias OSApplication = NSApplication - -protocol OSViewRepresentable: NSViewRepresentable where NSViewType == OSViewType { - associatedtype OSViewType: NSView - func makeOSView(context: Context) -> OSViewType - func updateOSView(_ osView: OSViewType, context: Context) -} - -extension OSViewRepresentable { - func makeNSView(context: Context) -> OSViewType { - makeOSView(context: context) - } - - func updateNSView(_ nsView: OSViewType, context: Context) { - updateOSView(nsView, context: context) - } -} - -#elseif canImport(UIKit) - -import UIKit - -typealias OSView = UIView -typealias OSColor = UIColor -typealias OSSize = CGSize -typealias OSPasteboard = UIPasteboard -typealias OSApplication = UIApplication - -protocol OSViewRepresentable: UIViewRepresentable { - associatedtype OSViewType: UIView - func makeOSView(context: Context) -> OSViewType - func updateOSView(_ osView: OSViewType, context: Context) -} - -extension OSViewRepresentable { - func makeUIView(context: Context) -> OSViewType { - makeOSView(context: context) - } - - func updateUIView(_ uiView: OSViewType, context: Context) { - updateOSView(uiView, context: context) - } -} - -#endif diff --git a/macos/Sources/Helpers/Extensions/NSAppearance+Extension.swift b/macos/Sources/Helpers/Extensions/NSAppearance+Extension.swift index c45f37a62..53a30e3ee 100644 --- a/macos/Sources/Helpers/Extensions/NSAppearance+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSAppearance+Extension.swift @@ -1,4 +1,4 @@ -import Cocoa +import AppKit extension NSAppearance { /// Returns true if the appearance is some kind of dark. @@ -17,7 +17,7 @@ extension NSAppearance { self.init(named: .aqua) case "auto": - let color = OSColor(config.backgroundColor) + let color = NSColor(config.backgroundColor) if color.isLightColor { self.init(named: .aqua) } else { diff --git a/macos/Sources/Helpers/Extensions/NSMenuItem+Extension.swift b/macos/Sources/Helpers/Extensions/NSMenuItem+Extension.swift index e512904ef..83e167999 100644 --- a/macos/Sources/Helpers/Extensions/NSMenuItem+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSMenuItem+Extension.swift @@ -7,5 +7,10 @@ extension NSMenuItem { if #available(macOS 26, *) { image = NSImage(systemSymbolName: symbol, accessibilityDescription: title) } +#if compiler(>=6.4) + if #available(macOS 27.0, *) { + preferredImageVisibility = .automatic + } +#endif } } diff --git a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift index 762b67f75..c95d1a05e 100644 --- a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift @@ -58,6 +58,21 @@ extension NSWindow { } } +extension NSWindowController { + /// Wraps `showWindow` with an Objective-C exception catcher because selecting + /// a tab can raise an AppKit fullscreen window-stack exception. + @discardableResult + func showWindowSafely(_ sender: Any?) -> Bool { + var error: NSError? + let success = GhosttyShowWindowSafely(self, sender, &error) + if let error { + Ghostty.logger.error("showWindow failed: \(error.localizedDescription, privacy: .public)") + } + + return success + } +} + /// Native tabbing private API usage. :( extension NSWindow { var titlebarView: NSView? { diff --git a/macos/Sources/Helpers/Extensions/OSColor+Extension.swift b/macos/Sources/Helpers/Extensions/OSColor+Extension.swift index 67246bcf5..65b13f98e 100644 --- a/macos/Sources/Helpers/Extensions/OSColor+Extension.swift +++ b/macos/Sources/Helpers/Extensions/OSColor+Extension.swift @@ -1,9 +1,10 @@ import Foundation +import AppKit #if !DOCK_TILE_PLUGIN import GhosttyKit #endif -extension OSColor { +extension NSColor { var isLightColor: Bool { return self.luminance > 0.5 } @@ -15,42 +16,20 @@ extension OSColor { var a: CGFloat = 0 // getRed:green:blue:alpha requires sRGB space - #if canImport(AppKit) guard let rgb = self.usingColorSpace(.sRGB) else { return 0 } - #else - let rgb = self - #endif rgb.getRed(&r, green: &g, blue: &b, alpha: &a) return (0.299 * r) + (0.587 * g) + (0.114 * b) } var hexString: String? { -#if canImport(AppKit) guard let rgb = usingColorSpace(.deviceRGB) else { return nil } let red = Int(rgb.redComponent * 255) let green = Int(rgb.greenComponent * 255) let blue = Int(rgb.blueComponent * 255) return String(format: "#%02X%02X%02X", red, green, blue) -#elseif canImport(UIKit) - var red: CGFloat = 0 - var green: CGFloat = 0 - var blue: CGFloat = 0 - var alpha: CGFloat = 0 - guard self.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { - return nil - } - - // Convert to 0–255 range - let r = Int(red * 255) - let g = Int(green * 255) - let b = Int(blue * 255) - - // Format to hexadecimal - return String(format: "#%02X%02X%02X", r, g, b) -#endif } - /// Create an OSColor from a hex string. + /// Create an NSColor from a hex string. convenience init?(hex: String) { var cleanedHex = hex.trimmingCharacters(in: .whitespacesAndNewlines) @@ -81,10 +60,10 @@ extension OSColor { self.init(red: red, green: green, blue: blue, alpha: alpha) } - func darken(by amount: CGFloat) -> OSColor { + func darken(by amount: CGFloat) -> NSColor { var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 self.getHue(&h, saturation: &s, brightness: &b, alpha: &a) - return OSColor( + return NSColor( hue: h, saturation: s, brightness: min(b * (1 - amount), 1), @@ -95,7 +74,7 @@ extension OSColor { // MARK: Ghostty Types #if !DOCK_TILE_PLUGIN -extension OSColor { +extension NSColor { /// Create a color from a Ghostty color. convenience init(ghostty: ghostty_config_color_s) { let red = Double(ghostty.r) / 255 diff --git a/macos/Sources/Helpers/Extensions/OSPasteboard+Extension.swift b/macos/Sources/Helpers/Extensions/OSPasteboard+Extension.swift index 6a59124e1..4be01bbd4 100644 --- a/macos/Sources/Helpers/Extensions/OSPasteboard+Extension.swift +++ b/macos/Sources/Helpers/Extensions/OSPasteboard+Extension.swift @@ -1,9 +1,8 @@ -#if canImport(AppKit) +import AppKit -/// Normalizes the interface between NSPasteboard and UIPasteboard for working with pasteboard -/// strings. -extension OSPasteboard { - @MainActor static let find = OSPasteboard(name: .find) +/// Convenience interface for working with pasteboard strings. +extension NSPasteboard { + @MainActor static let find = NSPasteboard(name: .find) /// The pasteboard's current string value. @MainActor var string: String? { @@ -18,11 +17,3 @@ extension OSPasteboard { } } } - -#elseif canImport(UIKit) - -extension OSPasteboard { - static let find = OSPasteboard.withUniqueName() -} - -#endif diff --git a/macos/Sources/Helpers/Extensions/String+Extension.swift b/macos/Sources/Helpers/Extensions/String+Extension.swift index 4fa61cd78..beb5d87e7 100644 --- a/macos/Sources/Helpers/Extensions/String+Extension.swift +++ b/macos/Sources/Helpers/Extensions/String+Extension.swift @@ -7,7 +7,6 @@ extension String { return self.prefix(maxLength) + trailing } -#if canImport(AppKit) func temporaryFile(_ filename: String = "temp") -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent(filename) @@ -25,7 +24,6 @@ extension String { } return self } -#endif /// Converts a four-character ASCII string to its `FourCharCode` (`UInt32`) value. var fourCharCode: UInt32 { diff --git a/macos/Sources/Helpers/ObjCExceptionCatcher.h b/macos/Sources/Helpers/ObjCExceptionCatcher.h index 7906b5945..58b342ebe 100644 --- a/macos/Sources/Helpers/ObjCExceptionCatcher.h +++ b/macos/Sources/Helpers/ObjCExceptionCatcher.h @@ -11,3 +11,10 @@ FOUNDATION_EXPORT BOOL GhosttyAddTabbedWindowSafely( NSInteger ordered, NSError * _Nullable * _Nullable error ); + +/// NSWindowController.showWindow wrapper +FOUNDATION_EXPORT BOOL GhosttyShowWindowSafely( + id _Nonnull controller, + id _Nullable sender, + NSError * _Nullable * _Nullable error +); diff --git a/macos/Sources/Helpers/ObjCExceptionCatcher.m b/macos/Sources/Helpers/ObjCExceptionCatcher.m index e91fb14a7..6a5768a8e 100644 --- a/macos/Sources/Helpers/ObjCExceptionCatcher.m +++ b/macos/Sources/Helpers/ObjCExceptionCatcher.m @@ -2,6 +2,16 @@ #import +static NSError *GhosttyErrorFromException(NSException *exception, NSInteger code) { + NSString *reason = exception.reason ?: @"Unknown Objective-C exception"; + return [NSError errorWithDomain:@"Ghostty.ObjCException" + code:code + userInfo:@{ + NSLocalizedDescriptionKey: reason, + @"exception_name": exception.name, + }]; +} + BOOL GhosttyAddTabbedWindowSafely( id parent, id child, @@ -18,13 +28,27 @@ BOOL GhosttyAddTabbedWindowSafely( return YES; } @catch (NSException *exception) { if (error != NULL) { - NSString *reason = exception.reason ?: @"Unknown Objective-C exception"; - *error = [NSError errorWithDomain:@"Ghostty.ObjCException" - code:1 - userInfo:@{ - NSLocalizedDescriptionKey: reason, - @"exception_name": exception.name, - }]; + *error = GhosttyErrorFromException(exception, 1); + } + + return NO; + } +} + +BOOL GhosttyShowWindowSafely( + id controller, + id _Nullable sender, + NSError * _Nullable * _Nullable error +) { + // Selecting a newly added tab can throw from NSWindowStackController when + // the tab group contains windows in inconsistent native fullscreen states. + // Catch the exception here so the AppKit assertion doesn't abort the app. + @try { + [((NSWindowController *)controller) showWindow:sender]; + return YES; + } @catch (NSException *exception) { + if (error != NULL) { + *error = GhosttyErrorFromException(exception, 2); } return NO; diff --git a/macos/Sources/Helpers/URLHoverBanner.swift b/macos/Sources/Helpers/URLHoverBanner.swift index 860a746d6..44d99f109 100644 --- a/macos/Sources/Helpers/URLHoverBanner.swift +++ b/macos/Sources/Helpers/URLHoverBanner.swift @@ -6,6 +6,11 @@ struct URLHoverBanner: View { let padding: CGFloat = 5 let cornerRadius: CGFloat = 9 let url: String + + private var displayedURL: String { + UntrustedURL(url).displayString + } + var body: some View { ZStack { HStack { @@ -13,7 +18,7 @@ struct URLHoverBanner: View { VStack(alignment: .leading) { Spacer() - Text(verbatim: url) + Text(verbatim: displayedURL) .padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding)) .background( UnevenRoundedRectangle(cornerRadii: .init(topLeading: cornerRadius)) @@ -29,7 +34,7 @@ struct URLHoverBanner: View { VStack(alignment: .leading) { Spacer() - Text(verbatim: url) + Text(verbatim: displayedURL) .padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding)) .background( UnevenRoundedRectangle(cornerRadii: .init(topTrailing: cornerRadius)) diff --git a/macos/Sources/Helpers/UntrustedURL.swift b/macos/Sources/Helpers/UntrustedURL.swift new file mode 100644 index 000000000..587897858 --- /dev/null +++ b/macos/Sources/Helpers/UntrustedURL.swift @@ -0,0 +1,262 @@ +import Foundation +import UniformTypeIdentifiers + +/// A URL supplied by a source that should not be allowed to dispatch directly +/// to the operating system, such as terminal output. +struct UntrustedURL: Equatable { + enum DenialReason: Equatable { + case malformedURL + case unsafeCharacters + case invalidWebURL + case inaccessibleFile + case unsafeFile + + var message: String { + switch self { + case .malformedURL: + "The target is not an absolute URL with a scheme." + case .unsafeCharacters: + "The target contains invisible or line-breaking characters." + case .invalidWebURL: + "The web target does not contain a valid host." + case .inaccessibleFile: + "The local target does not exist or is not a regular file or directory." + case .unsafeFile: + "Opening this local target could execute code." + } + } + } + + enum Decision: Equatable { + /// Open schemes with non-executing, well-understood behavior directly. + case allow(URL) + + /// Ask before dispatching a custom scheme to its registered handler. + case confirm(URL) + + /// Never dispatch malformed targets or executable local files. + case deny(DenialReason) + } + + let string: String + + init(_ string: String) { + self.string = string + } + + var decision: Decision { + guard !string.isEmpty else { return .deny(.malformedURL) } + + // Foundation accepts many Unicode control and formatting characters in + // a URL. UI frameworks can render those same characters as line breaks, + // zero-width text, or bidirectional overrides, so reject them before + // parsing changes their representation. + guard !string.unicodeScalars.contains(where: Self.isUnsafeCharacter) else { + return .deny(.unsafeCharacters) + } + + // URL(string:) also accepts relative references. An untrusted target + // must include an explicit scheme so it cannot be reinterpreted as a + // local path by a later layer. + guard + let url = URL(string: string), + let scheme = url.scheme?.lowercased(), + !scheme.isEmpty + else { + return .deny(.malformedURL) + } + + switch scheme { + case "http", "https": + // Reject values such as "https:relative". They have a scheme, but + // no authority, and different consumers may resolve them against a + // base URL differently. + guard let host = url.host, !host.isEmpty else { + return .deny(.invalidWebURL) + } + return .allow(url) + + case "mailto": + // URLComponents places the address portion of a mailto URL in the + // path. Require one so a bare "mailto:" cannot dispatch an empty + // request to the user's mail application. + guard + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + !components.path.isEmpty + else { + return .deny(.malformedURL) + } + return .allow(url) + + case "file": + return fileDecision(for: url) + + default: + // A custom scheme can invoke any application registered with + // Launch Services. The caller must show the target and handler + // before allowing that dispatch. + return .confirm(url) + } + } + + /// A single-line representation of the effective target. Paths are + /// standardized before display so traversal and repeated separators cannot + /// cause the visible and opened targets to differ. + var displayString: String { + let normalized: String + if let url = URL(string: string), url.scheme != nil { + // File URLs are standardized exactly as they are before opening, + // including symlink resolution. Keep non-file URLs byte-for-byte + // equivalent because repeated separators can be meaningful to a + // web or custom-scheme handler. + normalized = url.isFileURL + ? url.standardizedFileURL.resolvingSymlinksInPath().path + : string + } else { + // Scheme-less values are never allowed to open, but they still + // appear in the blocked-target UI. Standardizing them prevents + // slash padding and dot traversal from hiding the effective path. + normalized = URL(filePath: string).standardizedFileURL.path + } + + // Escaping happens after normalization so any unsafe scalar that + // remains is visible as text and cannot create a second display line. + var result = String() + result.reserveCapacity(normalized.count) + for scalar in normalized.unicodeScalars { + if Self.isUnsafeCharacter(scalar) { + result += "\\u{\(String(scalar.value, radix: 16, uppercase: true))}" + } else { + result.unicodeScalars.append(scalar) + } + } + + return result + } +} + +private extension UntrustedURL { + func fileDecision(for url: URL) -> Decision { + // Only local file URLs are meaningful here. Queries and fragments do + // not identify part of a filesystem object and may be interpreted + // inconsistently by Launch Services handlers. + guard url.isFileURL, url.query == nil, url.fragment == nil else { + return .deny(.malformedURL) + } + + // An empty host and localhost both refer to this machine. Do not allow + // file URLs that name a remote host and could trigger network access. + if let host = url.host, + !host.isEmpty, + host.caseInsensitiveCompare("localhost") != .orderedSame { + return .deny(.malformedURL) + } + + // Classify the effective object, not the spelling supplied by terminal + // output. This collapses dot traversal and prevents a harmless-looking + // symlink name from hiding an executable target. + let canonicalURL = url.standardizedFileURL.resolvingSymlinksInPath() + let resourceValues: URLResourceValues + do { + // Reading all relevant resource keys together also proves that the + // canonical target exists and is accessible. + resourceValues = try canonicalURL.resourceValues(forKeys: [ + .contentTypeKey, + .isDirectoryKey, + .isExecutableKey, + .isRegularFileKey, + ]) + } catch { + return .deny(.inaccessibleFile) + } + + // Exclude devices, sockets, and other special filesystem objects. A + // directory is safe to reveal in Finder unless its extension or UTI + // identifies it as an application bundle. + guard resourceValues.isDirectory == true || resourceValues.isRegularFile == true else { + return .deny(.inaccessibleFile) + } + guard !Self.isUnsafeFile(canonicalURL, resourceValues: resourceValues) else { + return .deny(.unsafeFile) + } + + return .allow(canonicalURL) + } + + static func isUnsafeFile( + _ url: URL, + resourceValues: URLResourceValues + ) -> Bool { + // Launch Services uses extensions when choosing a handler. Block known + // executable containers even when their POSIX executable bit is clear. + if unsafePathExtensions.contains(url.pathExtension.lowercased()) { + return true + } + + // UTIs cover files whose extension is missing or intentionally + // misleading. Use broad system-declared types so subclasses such as + // shell scripts and application bundles are included automatically. + if let contentType = resourceValues.contentType, + unsafeContentTypes.contains(where: { contentType.conforms(to: $0) }) { + return true + } + + // Finally, reject any regular file the filesystem marks executable, + // regardless of its name or detected content type. + return resourceValues.isDirectory != true && resourceValues.isExecutable == true + } + + static func isUnsafeCharacter(_ scalar: Unicode.Scalar) -> Bool { + switch scalar.value { + // C0/C1 controls include CR, LF, NEL, and other non-printing bytes. + case 0x00...0x1F, 0x7F...0x9F: + return true + + // Directional marks and zero-width characters can reorder or conceal + // portions of the target without changing what the handler receives. + case 0x061C, 0x200B...0x200F, 0x202A...0x202E, 0x2066...0x2069: + return true + + // Unicode line/paragraph separators create additional visual lines in + // SwiftUI and AppKit text even though OSC accepts their UTF-8 bytes. + case 0x2028...0x2029: + return true + + // Word Joiner and BOM are invisible formatting characters that can be + // used as padding or to disguise otherwise identical-looking targets. + case 0x2060, 0xFEFF: + return true + + default: + return false + } + } + + // Keep the large policy tables after the behavior so the primary type and + // its decision flow remain easy to scan. + static let unsafePathExtensions: Set = [ + "action", + "app", + "applescript", + "class", + "command", + "desktop", + "inetloc", + "jar", + "mobileconfig", + "mpkg", + "pkg", + "scpt", + "terminal", + "tool", + "url", + "webloc", + "workflow", + ] + + static let unsafeContentTypes: [UTType] = [ + .application, + .executable, + .script, + ] +} diff --git a/macos/Sources/Helpers/UntrustedURLAlert.swift b/macos/Sources/Helpers/UntrustedURLAlert.swift new file mode 100644 index 000000000..0c93b7c40 --- /dev/null +++ b/macos/Sources/Helpers/UntrustedURLAlert.swift @@ -0,0 +1,104 @@ +import AppKit + +/// Presents decisions for untrusted URLs at the AppKit boundary. +enum UntrustedURLAlert { + static func presentConfirmation(for url: URL, displayString: String) { + deferPresentation { + let workspace = NSWorkspace.shared + let handler = workspace.urlForApplication(toOpen: url) + .map { "\u{201c}\($0.deletingPathExtension().lastPathComponent)\u{201d}" } + ?? "the default application" + let alert = NSAlert() + alert.alertStyle = .warning + alert.icon = NSImage(named: NSImage.cautionName) + alert.messageText = "Open Link from Terminal Output?" + alert.informativeText = """ + This link will open in \(handler). Only continue if you recognize \ + and trust the destination. + """ + alert.accessoryView = targetView(displayString) + alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: "Open Link") + + present(alert) { response in + // Cancel is deliberately the default action. + guard response == .alertSecondButtonReturn else { return } + _ = workspace.open(url) + } + } + } + + static func presentBlock( + reason: UntrustedURL.DenialReason, + displayString: String + ) { + deferPresentation { + let alert = NSAlert() + alert.alertStyle = .warning + alert.icon = NSImage(named: NSImage.cautionName) + alert.messageText = "Ghostty Blocked This Link" + alert.informativeText = reason.message + alert.accessoryView = targetView(displayString) + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Copy Link") + + present(alert) { response in + // Keep blocked targets out of Launch Services. Copying the + // displayed, sanitized value gives the user an explicit path + // forward without adding a one-click policy bypass. + guard response == .alertSecondButtonReturn else { return } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(displayString, forType: .string) + } + } + } + + /// The core action callback runs with the renderer mutex held. Queue modal + /// presentation for the next main-loop turn so AppKit cannot reenter a + /// render callback before that mutex is released. + private static func deferPresentation(_ action: @escaping () -> Void) { + DispatchQueue.main.async(execute: action) + } + + private static func present( + _ alert: NSAlert, + completion: @escaping (NSApplication.ModalResponse) -> Void + ) { + if let window = NSApp.keyWindow { + alert.beginSheetModal(for: window, completionHandler: completion) + } else { + completion(alert.runModal()) + } + } + + private static func targetView(_ target: String) -> NSView { + let scrollView = NSScrollView(frame: NSRect( + x: 0, + y: 0, + width: 480, + height: 96 + )) + scrollView.borderType = .bezelBorder + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + + let textView = NSTextView(frame: scrollView.contentView.bounds) + textView.isEditable = false + textView.isSelectable = true + textView.isRichText = false + textView.font = .monospacedSystemFont( + ofSize: NSFont.systemFontSize, + weight: .regular + ) + textView.textContainerInset = NSSize(width: 6, height: 6) + textView.string = target + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize( + width: scrollView.contentSize.width, + height: .greatestFiniteMagnitude + ) + scrollView.documentView = textView + return scrollView + } +} diff --git a/macos/Tests/CommandLineOpenFileFilterTests.swift b/macos/Tests/CommandLineOpenFileFilterTests.swift new file mode 100644 index 000000000..e1d7a820c --- /dev/null +++ b/macos/Tests/CommandLineOpenFileFilterTests.swift @@ -0,0 +1,85 @@ +import Testing +@testable import Ghostty + +@Suite +struct CommandLineOpenFileFilterTests { + @Test func requiresExecuteFlag() { + let filter = CommandLineOpenFileFilter( + arguments: ["ghostty", "/tmp/file.txt"], + workingDirectory: "/tmp", + fileExists: { _ in true } + ) + + #expect(!filter.shouldIgnore("/tmp/file.txt")) + } + + @Test func ignoresExistingPathsAfterExecuteFlag() { + let existing: Set = [ + "/usr/bin/vim", + "/tmp/project/file.txt", + "/tmp/other.txt", + ] + let filter = CommandLineOpenFileFilter( + arguments: [ + "ghostty", + "/tmp/before.txt", + "-e", + "/usr/bin/vim", + "./file.txt", + "../other.txt", + "missing.txt", + ], + workingDirectory: "/tmp/project", + fileExists: { existing.contains($0) } + ) + + #expect(!filter.shouldIgnore("/tmp/before.txt")) + #expect(filter.shouldIgnore("/usr/bin/vim")) + #expect(filter.shouldIgnore("/tmp/project/file.txt")) + #expect(filter.shouldIgnore("/tmp/other.txt")) + #expect(!filter.shouldIgnore("/tmp/project/missing.txt")) + } + + @Test func ignoresEachPathOnce() { + let filter = CommandLineOpenFileFilter( + arguments: ["ghostty", "-e", "./file.txt"], + workingDirectory: "/tmp/project", + fileExists: { $0 == "/tmp/project/file.txt" } + ) + + #expect(filter.shouldIgnore("./file.txt")) + #expect(!filter.shouldIgnore("/tmp/project/file.txt")) + } + + @Test func preservesUnrelatedOpenFileRequests() { + let filter = CommandLineOpenFileFilter( + arguments: ["ghostty", "-e", "vim", "/tmp/command-file.txt"], + workingDirectory: "/tmp", + fileExists: { $0 == "/tmp/command-file.txt" } + ) + + #expect(!filter.shouldIgnore("/tmp/finder-file.txt")) + #expect(filter.shouldIgnore("/tmp/command-file.txt")) + } + + @Test func normalizesFileURLs() { + let existing: Set = [ + "/tmp/project/file #100%.txt", + "/tmp/project/directory", + ] + let filter = CommandLineOpenFileFilter( + arguments: [ + "ghostty", + "-e", + "command", + "./file #100%.txt", + "./directory/", + ], + workingDirectory: "/tmp/project", + fileExists: { existing.contains($0) } + ) + + #expect(filter.shouldIgnore("/tmp/project/file #100%.txt")) + #expect(filter.shouldIgnore("/tmp/project/directory")) + } +} diff --git a/macos/Tests/CommandPaletteTests.swift b/macos/Tests/CommandPaletteTests.swift new file mode 100644 index 000000000..fc36ef9fa --- /dev/null +++ b/macos/Tests/CommandPaletteTests.swift @@ -0,0 +1,49 @@ +// +// CommandPaletteTests.swift +// GhosttyTests +// +// Tests for command palette query filtering and match ranking. +// + +import Testing +import SwiftUI +@testable import Ghostty + +struct CommandPaletteFilterTests { + private func option( + title: String, + subtitle: String? = nil, + description: String? = nil, + leadingColor: Color? = nil + ) -> CommandOption { + CommandOption( + title: title, + subtitle: subtitle, + description: description, + leadingColor: leadingColor + ) {} + } + + /// Title matches outrank subtitle matches, which outrank description + /// matches. Options that don't match at all are dropped. + @Test func textMatchTiers() { + let byDescription = option(title: "Alpha", description: "make it fast") + let bySubtitle = option(title: "Beta", subtitle: "fast scrolling") + let byTitle = option(title: "Fast Redraw") + let noMatch = option(title: "Quit") + + let results = [noMatch, byDescription, bySubtitle, byTitle] + .filteredAndSorted(query: "fast") + + #expect(results == [byTitle, bySubtitle, byDescription]) + } + + /// Options with equal scores keep their original relative order. + @Test func tiesPreserveOriginalOrder() { + let first = option(title: "New Window") + let second = option(title: "New Tab") + + #expect([first, second].filteredAndSorted(query: "new") == [first, second]) + #expect([second, first].filteredAndSorted(query: "new") == [second, first]) + } +} diff --git a/macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift b/macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift index 1e0231d31..20894cf54 100644 --- a/macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift +++ b/macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift @@ -8,7 +8,7 @@ import Testing typealias StartSearch = Ghostty.Action.StartSearch /// A unique pasteboard for each test case prevents flakiness. - let pasteboard = OSPasteboard.withUniqueName() + let pasteboard = NSPasteboard.withUniqueName() init() { pasteboard.setString("pb", forType: .string) @@ -19,7 +19,7 @@ import Testing from: StartSearch(c: .init(needle: nil)), pasteboard: pasteboard ) - #expect(sut.needle == "pb") + #expect(sut.needle.text == "pb") } @Test func init_withEmptyNeedle_readsPasteboardNeedle() { @@ -28,7 +28,7 @@ import Testing from: StartSearch(c: .init(needle: needle)), pasteboard: pasteboard ) - #expect(sut.needle == "pb") + #expect(sut.needle.text == "pb") } } @@ -38,7 +38,7 @@ import Testing from: StartSearch(c: .init(needle: needle)), pasteboard: pasteboard ) - #expect(sut.needle == "start") + #expect(sut.needle.text == "start") } } @@ -57,20 +57,33 @@ import Testing from: StartSearch(c: .init(needle: nil)), pasteboard: pasteboard ) - sut.needle = "sut" + sut.setNeedle("sut") sut.writePasteboardNeedle() #expect(pasteboard.string(forType: .string) == "sut") } + @Test func setNeedle_clearsNeedleSelection() { + let sut = SearchState( + from: StartSearch(c: .init(needle: nil)), + pasteboard: pasteboard + ) + sut.needle.selection = sut.needle.text.startIndex.. URL { + let result = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + try FileManager.default.createDirectory( + at: result, + withIntermediateDirectories: false + ) + return result + } +} diff --git a/macos/Tests/Update/UpdateStateTests.swift b/macos/Tests/Update/UpdateStateTests.swift index 6aefa22a2..dfb21ef24 100644 --- a/macos/Tests/Update/UpdateStateTests.swift +++ b/macos/Tests/Update/UpdateStateTests.swift @@ -25,11 +25,9 @@ struct UpdateStateTests { } @Test func testInstallingEquality() { - let state1: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {})) - let state2: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {})) + let state1: UpdateState = .installing(.init(retryTerminatingApplication: {})) + let state2: UpdateState = .installing(.init(retryTerminatingApplication: {})) #expect(state1 == state2) - let state3: UpdateState = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {})) - #expect(state3 != state2) } @Test func testPermissionRequestEquality() { @@ -98,15 +96,30 @@ struct UpdateStateTests { #expect(state1 != state2) } - // MARK: - isIdle Tests + // MARK: - isHidden Tests - @Test func testIsIdleTrue() { - let state: UpdateState = .idle - #expect(state.isIdle == true) + @Test( + arguments: [ + (UpdateState.idle, true), + (.installing(.init(appcastItem: .empty(), retryTerminatingApplication: {})), true), + (.checking(.init(cancel: {})), false), + (.installing(.init(retryTerminatingApplication: {})), false) + ] + ) + func testIsHidden(_ state: UpdateState, expected: Bool) { + #expect(state.isHidden == expected) } - @Test func testIsIdleFalse() { - let state: UpdateState = .checking(.init(cancel: {})) - #expect(state.isIdle == false) + // MARK: - shouldTerminateWithoutWarning Tests + + @Test( + arguments: [ + (UpdateState.idle, false), + (.installing(.init(appcastItem: .empty(), retryTerminatingApplication: {})), false), + (.installing(.init(retryTerminatingApplication: {})), true) + ] + ) + func testShouldTerminateWithoutWarning(_ state: UpdateState, expected: Bool) { + #expect(state.shouldTerminateWithoutWarning == expected) } } diff --git a/macos/Tests/Update/UpdateViewModelTests.swift b/macos/Tests/Update/UpdateViewModelTests.swift index 9b747f9ec..ee22007f2 100644 --- a/macos/Tests/Update/UpdateViewModelTests.swift +++ b/macos/Tests/Update/UpdateViewModelTests.swift @@ -52,9 +52,9 @@ struct UpdateViewModelTests { @Test func testInstallingText() { let viewModel = UpdateViewModel() - viewModel.state = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {})) + viewModel.state = .installing(.init(retryTerminatingApplication: {})) #expect(viewModel.text == "Installing…") - viewModel.state = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {})) + viewModel.state = .installing(.init(appcastItem: .empty(), retryTerminatingApplication: {})) #expect(viewModel.text == "Restart to Complete Update") } diff --git a/macos/build.nu b/macos/build.nu index 8c456d9b6..98d0b40d2 100755 --- a/macos/build.nu +++ b/macos/build.nu @@ -4,7 +4,7 @@ # to avoid Nix shell interference (NIX_LDFLAGS, NIX_CFLAGS_COMPILE, etc.). def main [ - --scheme: string = "Ghostty" # Xcode scheme (Ghostty, Ghostty-iOS, DockTilePlugin) + --scheme: string = "Ghostty" # Xcode scheme (Ghostty, DockTilePlugin) --configuration: string = "Debug" # Build configuration (Debug, Release, ReleaseLocal) --action: string = "build" # xcodebuild action (build, test, clean, etc.) ] { diff --git a/nix/devShell.nix b/nix/devShell.nix index 5401ba0d1..609a8a31f 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -87,7 +87,6 @@ inherit pkgs lib stdenv; }; python = python3.withPackages (python-pkgs: [ - python-pkgs.blake3 python-pkgs.kaitaistruct python-pkgs.ucs-detect ]); @@ -223,8 +222,7 @@ in '') + (lib.optionalString stdenv.hostPlatform.isDarwin '' # On macOS, we unset the macOS SDK env vars that Nix sets up because - # we rely on a system installation. Nix only provides a macOS SDK - # and we need iOS too. + # we rely on a system installation. Nix only provides a macOS SDK. unset SDKROOT unset DEVELOPER_DIR diff --git a/pkg/android-ndk/build.zig b/pkg/android-ndk/build.zig index 2f921e7d6..105017003 100644 --- a/pkg/android-ndk/build.zig +++ b/pkg/android-ndk/build.zig @@ -143,9 +143,8 @@ fn findNDKPath(b: *std.Build) ?[]const u8 { &.{ home, switch (builtin.os.tag) { - .linux => "Android/sdk", + .linux, .windows => "Android/Sdk", .macos => "Library/Android/Sdk", - .windows => "Android/Sdk", else => return null, }, }, diff --git a/pkg/gtk4-layer-shell/build.zig b/pkg/gtk4-layer-shell/build.zig index 8ab67e62b..38dfd2c8a 100644 --- a/pkg/gtk4-layer-shell/build.zig +++ b/pkg/gtk4-layer-shell/build.zig @@ -39,18 +39,24 @@ pub fn build(b: *std.Build) !void { else link_system_libs_full[0..1], }); + + if (!b.systemIntegrationOption("gtk4-layer-shell", .{})) { + // local deps (non-system layer-shell/wayland) + const deps = try LocalDeps.get(b) orelse break :translate; + headers.addIncludePath(deps.upstream.path("include")); + headers.addIncludePath(deps.upstream.path("src")); + headers.addIncludePath(deps.client_header_directory); + } + module.addImport("c", headers.mod); } if (!b.systemIntegrationOption("gtk4-layer-shell", .{})) { - _ = try buildLib(b, module, .{ - .target = target, - .optimize = optimize, - }); + _ = try buildLib(b, .{ .target = target, .optimize = optimize }); } } -fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { +fn buildLib(b: *std.Build, options: anytype) !*std.Build.Step.Compile { const lib_version = try std.SemanticVersion.parse(version); const target = options.target; const optimize = options.optimize; @@ -67,58 +73,20 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu }); b.installArtifact(lib); - // We need to call both lazy dependencies to tell Zig we need both - const upstream_ = b.lazyDependency("gtk4_layer_shell", .{}); - const wayland_protocols_ = b.lazyDependency("wayland_protocols", .{}); - const upstream = upstream_ orelse return lib; - const wayland_protocols = wayland_protocols_ orelse return lib; - - lib.root_module.addIncludePath(upstream.path("include")); - lib.root_module.addIncludePath(upstream.path("src")); - module.addIncludePath(upstream.path("include")); - // GTK lib.root_module.linkSystemLibrary("gtk4", dynamic_link_opts); - // Wayland headers and source files - { - const protocols = [_]struct { []const u8, std.Build.LazyPath }{ - .{ - "wlr-layer-shell-unstable-v1", - upstream.path("protocol/wlr-layer-shell-unstable-v1.xml"), - }, - .{ - "xdg-shell", - wayland_protocols.path("stable/xdg-shell/xdg-shell.xml"), - }, - // Even though we don't use session lock, we still need its headers - .{ - "ext-session-lock-v1", - wayland_protocols.path("staging/ext-session-lock/ext-session-lock-v1.xml"), - }, - }; - - const wf = b.addWriteFiles(); - for (protocols) |protocol| { - const name, const xml = protocol; - - const header_scanner = b.addSystemCommand(&.{ "wayland-scanner", "client-header" }); - header_scanner.addFileArg(xml); - _ = wf.addCopyFile( - header_scanner.addOutputFileArg(name), - b.fmt("{s}-client.h", .{name}), - ); - - const source_scanner = b.addSystemCommand(&.{ "wayland-scanner", "private-code" }); - source_scanner.addFileArg(xml); - const source = source_scanner.addOutputFileArg(b.fmt("{s}.c", .{name})); - lib.root_module.addCSourceFile(.{ .file = source }); - } - lib.root_module.addIncludePath(wf.getDirectory()); + // local deps (non-system layer-shell/wayland) + const deps = try LocalDeps.get(b) orelse return lib; + lib.root_module.addIncludePath(deps.upstream.path("include")); + lib.root_module.addIncludePath(deps.upstream.path("src")); + lib.root_module.addIncludePath(deps.client_header_directory); + for (deps.private_code_files) |source| { + lib.root_module.addCSourceFile(.{ .file = source }); } lib.installHeadersDirectory( - upstream.path("include"), + deps.upstream.path("include"), "", .{ .include_extensions = &.{".h"} }, ); @@ -134,7 +102,7 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu "xdg-surface-server.c", }; lib.root_module.addCSourceFiles(.{ - .root = upstream.path("src"), + .root = deps.upstream.path("src"), .files = srcs, .flags = &.{ b.fmt("-DGTK_LAYER_SHELL_MAJOR={}", .{lib_version.major}), @@ -145,3 +113,68 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu return lib; } + +const LocalDeps = struct { + var cached: ?LocalDeps = null; + + upstream: *std.Build.Dependency, + wayland_protocols: *std.Build.Dependency, + client_header_directory: std.Build.LazyPath, + private_code_files: []std.Build.LazyPath, + + fn init(b: *std.Build) !?LocalDeps { + var result: LocalDeps = .{ + .upstream = b.lazyDependency("gtk4_layer_shell", .{}) orelse return null, + .wayland_protocols = b.lazyDependency("wayland_protocols", .{}) orelse return null, + .client_header_directory = undefined, + .private_code_files = &.{}, + }; + + // Wayland headers and source files + { + const protocols = [_]struct { []const u8, std.Build.LazyPath }{ + .{ + "wlr-layer-shell-unstable-v1", + result.upstream.path("protocol/wlr-layer-shell-unstable-v1.xml"), + }, + .{ + "xdg-shell", + result.wayland_protocols.path("stable/xdg-shell/xdg-shell.xml"), + }, + // Even though we don't use session lock, we still need its headers + .{ + "ext-session-lock-v1", + result.wayland_protocols.path("staging/ext-session-lock/ext-session-lock-v1.xml"), + }, + }; + + const wf = b.addWriteFiles(); + const private_code_files = try b.allocator.alloc(std.Build.LazyPath, protocols.len); + for (protocols, 0..) |protocol, idx| { + const name, const xml = protocol; + + const header_scanner = b.addSystemCommand(&.{ "wayland-scanner", "client-header" }); + header_scanner.addFileArg(xml); + _ = wf.addCopyFile( + header_scanner.addOutputFileArg(name), + b.fmt("{s}-client.h", .{name}), + ); + + const source_scanner = b.addSystemCommand(&.{ "wayland-scanner", "private-code" }); + source_scanner.addFileArg(xml); + const source = source_scanner.addOutputFileArg(b.fmt("{s}.c", .{name})); + private_code_files[idx] = source; + } + result.private_code_files = private_code_files; + result.client_header_directory = wf.getDirectory(); + } + + cached = result; + return result; + } + + fn get(b: *std.Build) !?LocalDeps { + if (cached) |c| return c; + return init(b); + } +}; diff --git a/pkg/macos/foundation/string.zig b/pkg/macos/foundation/string.zig index 2f679fef4..3d1c5c05c 100644 --- a/pkg/macos/foundation/string.zig +++ b/pkg/macos/foundation/string.zig @@ -21,13 +21,13 @@ pub const String = opaque { pub fn createWithCharactersNoCopy( unichars: []const u16, - ) *String { - return @as(*String, @ptrFromInt(@intFromPtr(c.CFStringCreateWithCharactersNoCopy( + ) Allocator.Error!*String { + return @ptrCast(@constCast(c.CFStringCreateWithCharactersNoCopy( null, @ptrCast(unichars.ptr), @intCast(unichars.len), foundation.c.kCFAllocatorNull, - )))); + ) orelse return error.OutOfMemory)); } pub fn release(self: *String) void { diff --git a/pkg/macos/text/font.zig b/pkg/macos/text/font.zig index ea37891f5..626b9089b 100644 --- a/pkg/macos/text/font.zig +++ b/pkg/macos/text/font.zig @@ -18,6 +18,17 @@ pub const Font = opaque { ) orelse Allocator.Error.OutOfMemory; } + pub fn createWithName(name: *foundation.String, size: f32) Allocator.Error!*Font { + return @as( + ?*Font, + @ptrFromInt(@intFromPtr(c.CTFontCreateWithName( + @ptrCast(name), + size, + null, + ))), + ) orelse Allocator.Error.OutOfMemory; + } + pub fn createForString( self: *Font, str: *foundation.String, @@ -156,7 +167,7 @@ pub const Font = opaque { return @ptrFromInt(@intFromPtr(c.CTFontCopyFamilyName(@ptrCast(self)))); } - pub fn copyDisplayName(self: *Font) *foundation.String { + pub fn copyDisplayName(self: *Font) ?*foundation.String { return @ptrFromInt(@intFromPtr(c.CTFontCopyDisplayName(@ptrCast(self)))); } diff --git a/pkg/macos/text/typesetter.zig b/pkg/macos/text/typesetter.zig index dc07df980..16cd78169 100644 --- a/pkg/macos/text/typesetter.zig +++ b/pkg/macos/text/typesetter.zig @@ -27,10 +27,10 @@ pub const Typesetter = opaque { pub fn createLine( self: *Typesetter, range: foundation.c.CFRange, - ) *text.Line { - return @ptrFromInt(@intFromPtr(c.CTTypesetterCreateLine( + ) Allocator.Error!*text.Line { + return @ptrCast(@constCast(c.CTTypesetterCreateLine( @ptrCast(self), range, - ))); + ) orelse return error.OutOfMemory)); } }; diff --git a/pkg/macos/video/display_link.zig b/pkg/macos/video/display_link.zig index 7d6b437f9..dc984422d 100644 --- a/pkg/macos/video/display_link.zig +++ b/pkg/macos/video/display_link.zig @@ -1,21 +1,19 @@ -const std = @import("std"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; const c = @import("c.zig").c; pub const DisplayLink = opaque { pub const Error = error{ + CreationFailed, InvalidOperation, }; - pub fn createWithActiveCGDisplays() Allocator.Error!*DisplayLink { + pub fn createWithActiveCGDisplays() Error!*DisplayLink { var result: ?*DisplayLink = null; if (c.CVDisplayLinkCreateWithActiveCGDisplays( @ptrCast(&result), ) != c.kCVReturnSuccess) - return error.OutOfMemory; + return error.CreationFailed; - return result orelse error.OutOfMemory; + return result orelse error.CreationFailed; } pub fn release(self: *DisplayLink) void { diff --git a/po/README_CONTRIBUTORS.md b/po/README_CONTRIBUTORS.md index e232c0620..46dad96f9 100644 --- a/po/README_CONTRIBUTORS.md +++ b/po/README_CONTRIBUTORS.md @@ -44,6 +44,30 @@ else const label = gtk.Label.new(text); ``` +If a string must be stored untranslated and only translated later, use +`i18n.N_` instead. This marks the string for extraction into the translation +template but returns the original msgid unchanged. A common use case is +compile-time or static metadata that is translated only when it is presented +to the user. + +```zig +const i18n = @import("i18n.zig"); + +const Command = struct { + title: [:0]const u8, +}; + +const cmd = Command{ + .title = i18n.N_("Reset Terminal"), +}; + +const label = gtk.Label.new(i18n._(cmd.title)); +``` + +If `i18n._` is called at comptime, it returns the original msgid unchanged +while still marking the string for translation. For strings that are stored +untranslated and translated later, prefer `i18n.N_`. + All translatable strings are extracted into the _translation template file_, located under `po/com.mitchellh.ghostty.pot`. **This file must stay in sync with the list of translatable strings present in source code or Blueprints at all times.** diff --git a/po/README_TRANSLATORS.md b/po/README_TRANSLATORS.md index 9197dfa84..b802a19f3 100644 --- a/po/README_TRANSLATORS.md +++ b/po/README_TRANSLATORS.md @@ -160,25 +160,31 @@ the right click menu. ## Localization teams -Every locale has a localization team consisting of the locale's maintainers. -These maintainers review contributions to their locale's translations, and are -responsible for translating new strings when requested: occasionally, all locale -maintainers are pinged and requested to translate missing strings. +Every locale has a _localization team_ consisting of the locale's _maintainers_. +These maintainers are responsible for upkeep of their locale's translations. +Anyone can contribute strings for any language's translations, and may also +volunteer to join that locale's team; a locale maintainer is only distinguished +from other speakers of the language in that: -The primary purposes of being a locale maintainer are a declaration of -_commitment_ to their upkeep, and being _informed_ of updates or update requests -of the translations, via GitHub's review requests or @mentions. +1. Locale maintainers _declare commitment_ to updating their translations. +2. Locale maintainers are _occasionally pinged_, via GitHub's @mentions, when + there are untranslated strings. +3. Locale maintainers are _informed of updates_ to the translations they + maintain via GitHub's review requests. -So that future updates to a locale are possible, each localization team must -have at least two members. If you are introducing a new language, please -**consider volunteering** to be a part of the localization team, by mentioning -that you are willing to be a part of it in the pull request description! You, -and all reviewers, will be offered to join the locale team before the pull -request to add the new language is merged, but this denotes your dedication -upfront—for a pull request adding a new language to be merged, it needs at least -one review from a speaker of that language _and_ at least two localization team -members. No one is _required_ to join a localization team, even if they -introduced support for the language. +That's all there is to it, really. If you wish, you may leave your localization +teams at any point in the future. + +Each localization team must have at least two maintainers, who can review each +other's translations. Likewise, if you open a pull request for a new language, +it won't be merged until at least two people volunteer. You—the creator of the +PR—likely intend to continue helping out with them, so please make it clear +up-front if that is not your intent. + +There is no requirement that locale maintainers must not know each other. If you +are adding translations for languages that have few speakers, you might wish to +ask a friend of yours who also speaks that language whether they'd be willing to +help with Ghostty translations. ### `CODEOWNERS` diff --git a/po/be.po b/po/be.po index 86cb27199..bc2dd6e97 100644 --- a/po/be.po +++ b/po/be.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-04-14 00:00+0000\n" "Last-Translator: Illia Krauchanka \n" "Language-Team: Belarusian\n" @@ -49,11 +49,12 @@ msgstr "Перазагрузіць канфігурацыю, каб паказа #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Скасаваць" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Закрыць" @@ -62,7 +63,7 @@ msgstr "Закрыць" msgid "Configuration Errors" msgstr "Канфігурацыйныя памылкі" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -70,12 +71,12 @@ msgstr "" "Знойдзена адна або некалькі канфігурацыйных памылак. Прагледзьце памылкі " "ніжэй і альбо перазагрузіце канфігурацыю, альбо ігнаруйце гэтыя памылкі." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Ігнараваць" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Перазагрузіць канфігурацыю" @@ -94,23 +95,23 @@ msgstr "Ghostty: інспектар тэрмінала" msgid "Find…" msgstr "Знайсці…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Папярэдняе супадзенне" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Наступнае супадзенне" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Ой." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Не ўдалося атрымаць кантэкст OpenGL для адмалёўкі." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -120,99 +121,111 @@ msgstr "" "праглядаць, вылучаць і пракручваць змест, але ніякія падзеі ўводу не будуць " "адпраўлены ў запушчаную праграму." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Толькі для чытання" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Скапіяваць" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Уставіць" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Апавясціць пры завяршэнні наступнай каманды" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Ачысціць" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Скінуць" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Падзяліць" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Змяніць назву…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Падзяліць уверх" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Падзяліць уніз" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Падзяліць улева" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Падзяліць управа" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Укладка" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Змяніць назву ўкладкі…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Новая ўкладка" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Закрыць укладку" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Акно" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Новае акно" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Закрыць акно" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Налады" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -222,31 +235,31 @@ msgstr "Пакіньце пустым, каб аднавіць назву па msgid "OK" msgstr "ОК" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Новы падзел" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Паглядзець адкрытыя ўкладкі" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Галоўнае меню" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Палітра каманд" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Інспектар тэрмінала" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Пра Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Выйсці" @@ -254,18 +267,30 @@ msgstr "Выйсці" msgid "Execute a command…" msgstr "Выканаць каманду…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -326,46 +351,754 @@ msgstr "Усе сесіі тэрмінала ў гэтым акне будуць msgid "The currently running process in this split will be terminated." msgstr "Бягучы працэс у гэтым падзеле будзе завершаны." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Каманда завершана" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Каманда выканана паспяхова" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Каманда завершылася з памылкай" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Каманда выканана паспяхова" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Каманда завершылася з памылкай" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Змяніць назву тэрмінала" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Змяніць назву ўкладкі" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Канфігурацыя перазагружана" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Скапіявана ў буфер абмену" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Буфер абмену ачышчаны" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Распрацоўшчыкі Ghostty" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/bg.po b/po/bg.po index 6d880ea33..2069f5235 100644 --- a/po/bg.po +++ b/po/bg.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-09 22:07+0200\n" "Last-Translator: reo101 \n" "Language-Team: Bulgarian \n" @@ -49,11 +49,12 @@ msgstr "За да покажеш това съобщение отново, пр #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Отказ" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Затвори" @@ -62,7 +63,7 @@ msgstr "Затвори" msgid "Configuration Errors" msgstr "Грешки в конфигурацията" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -70,12 +71,12 @@ msgstr "" "Открити са една или повече грешки в конфигурацията. Моля, прегледайте " "грешките по-долу и или презаредете конфигурацията си, или ги игнорирайте." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Игнорирай" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Презареди конфигурацията" @@ -94,23 +95,23 @@ msgstr "Ghostty: Инспектор на терминала" msgid "Find…" msgstr "Намери…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Предишно съвпадение" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Следващо съвпадение" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "О, не." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Неуспешно придобиване на OpenGL контекст за рендиране." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -120,99 +121,111 @@ msgstr "" "селектирате и превъртате съдържанието, но към работещото приложение няма да " "бъдат изпращани входни събития." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Само за четене" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Копирай" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Постави" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Уведомяване при завършване на следващата команда" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Изчисти" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Нулирай" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Раздели" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Промени заглавие…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Раздели нагоре" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Раздели надолу" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Раздели наляво" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Раздели надясно" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Раздел" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Смени името на таба…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Нов раздел" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Затвори раздел" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Прозорец" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Нов прозорец" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Затвори прозорец" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Конфигурация" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -222,31 +235,31 @@ msgstr "Оставете празно за възстановяване на з msgid "OK" msgstr "ОК" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Ново разделяне" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Преглед на отворените раздели" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Главно меню" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Командна палитра" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Инспектор на терминала" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "За Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Изход" @@ -254,18 +267,30 @@ msgstr "Изход" msgid "Execute a command…" msgstr "Изпълни команда…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -326,46 +351,754 @@ msgstr "Всички терминални сесии в този прозоре msgid "The currently running process in this split will be terminated." msgstr "Текущият процес в това разделяне ще бъде прекратен." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Командата завърши" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Командата завърши успешно" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Командата завърши неуспешно" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Командата завърши успешно" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Командата завърши неуспешно" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Промяна на заглавието на терминала" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Смени името на таба" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Конфигурацията е презаредена" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Копирано в клипборда" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Клипбордът е изчистен" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Разработчици на Ghostty" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/ca.po b/po/ca.po index 57ac8b39b..c1a8aa009 100644 --- a/po/ca.po +++ b/po/ca.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2025-08-24 19:22+0200\n" "Last-Translator: Kristofer Soler " "<31729650+KristoferSoler@users.noreply.github.com>\n" @@ -50,11 +50,12 @@ msgstr "Recarrega la configuració per tornar a mostrar aquest missatge" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Cancel·la" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Tanca" @@ -63,7 +64,7 @@ msgstr "Tanca" msgid "Configuration Errors" msgstr "Errors de configuració" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -71,12 +72,12 @@ msgstr "" "S'han trobat un o més errors de configuració. Si us plau, revisa els errors " "a continuació i torna a carregar la configuració o ignora aquests errors." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Ignora" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Carrega la configuració" @@ -96,23 +97,23 @@ msgstr "Ghostty: Inspector de terminal" msgid "Find…" msgstr "Cerca…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Coincidència anterior" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Coincidència següent" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Oh, no." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "No s'ha pogut obtenir un context OpenGL per al renderitzat." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -122,99 +123,111 @@ msgstr "" "i desplaçar-te pel contingut, però no s'enviaran esdeveniments d'entrada a " "l'aplicació en execució." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Només lectura" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Copia" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Enganxa" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Notifica en finalitzar la propera comanda" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Neteja" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Reinicia" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Divideix" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Canvia el títol…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Divideix cap amunt" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Divideix cap avall" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Divideix a l'esquerra" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Divideix a la dreta" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Pestanya" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Canvia el títol de la pestanya…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Nova pestanya" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Tanca la pestanya" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Finestra" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Nova finestra" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Tanca la finestra" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Configuració" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -msgstr "Obre la configuració" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -224,31 +237,31 @@ msgstr "Deixa en blanc per restaurar el títol per defecte." msgid "OK" msgstr "D'acord" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Nova divisió" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Mostra les pestanyes obertes" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Menú principal" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Paleta de comandes" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Inspector de terminal" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Sobre Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Surt" @@ -256,18 +269,30 @@ msgstr "Surt" msgid "Execute a command…" msgstr "Executa una ordre…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -328,46 +353,754 @@ msgstr "Totes les sessions del terminal en aquesta finestra es tancaran." msgid "The currently running process in this split will be terminated." msgstr "El procés actualment en execució en aquesta divisió es tancarà." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Comanda finalitzada" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Comanda completada amb èxit" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Comanda fallida" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Comanda completada amb èxit" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Comanda fallida" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Canvia el títol del terminal" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Canvia el títol de la pestanya" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "S'ha tornat a carregar la configuració" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Copiat al porta-retalls" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Porta-retalls netejat" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Desenvolupadors de Ghostty" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/com.mitchellh.ghostty.pot b/po/com.mitchellh.ghostty.pot index 6787fb7b7..73b78f03f 100644 --- a/po/com.mitchellh.ghostty.pot +++ b/po/com.mitchellh.ghostty.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -48,11 +48,12 @@ msgstr "" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "" @@ -61,18 +62,18 @@ msgstr "" msgid "Configuration Errors" msgstr "" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." msgstr "" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "" @@ -90,121 +91,133 @@ msgstr "" msgid "Find…" msgstr "" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " "application." msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 @@ -215,31 +228,31 @@ msgstr "" msgid "OK" msgstr "" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "" @@ -247,18 +260,30 @@ msgstr "" msgid "Execute a command…" msgstr "" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -313,46 +338,754 @@ msgstr "" msgid "The currently running process in this split will be terminated." msgstr "" -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/de.po b/po/de.po index 010488b53..285374ce1 100644 --- a/po/de.po +++ b/po/de.po @@ -10,7 +10,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-13 08:05+0100\n" "Last-Translator: Klaus Hipp \n" "Language-Team: German \n" @@ -52,11 +52,12 @@ msgstr "" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Abbrechen" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Schließen" @@ -65,7 +66,7 @@ msgstr "Schließen" msgid "Configuration Errors" msgstr "Konfigurationsfehler" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -74,12 +75,12 @@ msgstr "" "untenstehenden Fehler und lade entweder deine Konfiguration erneut oder " "ignoriere die Fehler." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Ignorieren" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Konfiguration neu laden" @@ -99,23 +100,23 @@ msgstr "Ghostty: Terminalinspektor" msgid "Find…" msgstr "Suchen…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Vorherige Übereinstimmung" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Nächste Übereinstimmung" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Oh nein." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Es kann kein OpenGL-Kontext für das Rendering abgerufen werden." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -125,99 +126,111 @@ msgstr "" "Inhalt weiterhin anzeigen, auswählen und durchscrollen, es werden jedoch " "keine Eingabeereignisse an die laufende Anwendung gesendet." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Schreibgeschützt" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Kopieren" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Einfügen" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Bei Abschluss des nächsten Befehls benachrichtigen" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Leeren" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Zurücksetzen" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Fenster teilen" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Titel bearbeiten…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Fenster nach oben teilen" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Fenster nach unten teilen" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Fenter nach links teilen" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Fenster nach rechts teilen" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Tab" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Tab-Titel ändern…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Neuer Tab" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Tab schließen" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Fenster" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Neues Fenster" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Fenster schließen" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Konfiguration" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -msgstr "Konfiguration öffnen" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -227,31 +240,31 @@ msgstr "Leer lassen, um den Standardtitel wiederherzustellen." msgid "OK" msgstr "OK" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Neues geteiltes Fenster" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Offene Tabs einblenden" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Hauptmenü" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Befehlspalette" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Terminalinspektor" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Über Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Beenden" @@ -259,18 +272,30 @@ msgstr "Beenden" msgid "Execute a command…" msgstr "Einen Befehl ausführen…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -331,46 +356,754 @@ msgstr "Alle Terminalsitzungen in diesem Fenster werden beendet." msgid "The currently running process in this split will be terminated." msgstr "Der aktuell laufende Prozess in diesem geteilten Fenster wird beendet." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Befehl abgeschlossen" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Befehl erfolgreich" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Befehl fehlgeschlagen" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Befehl erfolgreich" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Befehl fehlgeschlagen" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Terminaltitel bearbeiten" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Tab-Titel ändern" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Konfiguration wurde neu geladen" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "In die Zwischenablage kopiert" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Zwischenablage geleert" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Ghostty-Entwickler" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/es_AR.po b/po/es_AR.po index cca0cc086..31a87037b 100644 --- a/po/es_AR.po +++ b/po/es_AR.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-19 13:34-0300\n" "Last-Translator: Alan Moyano \n" "Language-Team: Argentinian \n" @@ -48,11 +48,12 @@ msgstr "Recargar la configuración para volver a mostrar este mensaje" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Cancelar" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Cerrar" @@ -61,7 +62,7 @@ msgstr "Cerrar" msgid "Configuration Errors" msgstr "Errores de configuración" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -69,12 +70,12 @@ msgstr "" "Se encontraron uno o más errores de configuración. Por favor revisá los " "errores a continuación, y recargá tu configuración o ignorá estos errores." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Ignorar" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Recargar configuración" @@ -94,23 +95,23 @@ msgstr "Ghostty: Inspector de la terminal" msgid "Find…" msgstr "Buscar…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Coincidencia anterior" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Coincidencia siguiente" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Uy, no." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "No se pudo obtener un contexto de OpenGL para el renderizado" -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -120,99 +121,111 @@ msgstr "" "desplazarte por el contenido, pero no se enviarán los eventos de entrada a " "la aplicación en ejecución." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Solo lectura" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Copiar" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Pegar" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Notificar al finalizar el siguiente comando" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Limpiar" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Reiniciar" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Dividir" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Cambiar título…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Dividir arriba" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Dividir abajo" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Dividir a la izquierda" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Dividir a la derecha" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Pestaña" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Cambiar título de la pestaña…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Nueva pestaña" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Cerrar pestaña" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Ventana" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Nueva ventana" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Cerrar ventana" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Configuración" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -msgstr "Abrir configuración" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -222,31 +235,31 @@ msgstr "Dejar en blanco para restaurar el título predeterminado." msgid "OK" msgstr "Aceptar" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Nueva división" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Ver pestañas abiertas" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Menú principal" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Paleta de comandos" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Inspector de la terminal" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Acerca de Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Salir" @@ -254,18 +267,30 @@ msgstr "Salir" msgid "Execute a command…" msgstr "Ejecutar un comando…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -326,46 +351,754 @@ msgstr "Todas las sesiones de terminal en esta ventana serán terminadas." msgid "The currently running process in this split will be terminated." msgstr "El proceso actualmente en ejecución en esta división será terminado." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Comando finalizado" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Comando ejecutado correctamente" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Comando fallido" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Comando ejecutado correctamente" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Comando fallido" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Cambiar título de la terminal" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Cambiar título de la pestaña" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Configuración recargada" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Copiado al portapapeles" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Portapapeles limpiado" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Desarrolladores de Ghostty" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/es_BO.po b/po/es_BO.po index 142250e9b..1887e288c 100644 --- a/po/es_BO.po +++ b/po/es_BO.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-12 17:46+0200\n" "Last-Translator: Miguel Peredo \n" "Language-Team: Spanish \n" @@ -48,11 +48,12 @@ msgstr "Recargar configuración para mostrar este aviso nuevamente" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Cancelar" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Cerrar" @@ -61,7 +62,7 @@ msgstr "Cerrar" msgid "Configuration Errors" msgstr "Errores de configuración" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -69,12 +70,12 @@ msgstr "" "Se encontraron uno o más errores de configuración. Por favor revise los " "errores a continuación, y recargue su configuración o ignore estos errores." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Ignorar" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Recargar configuración" @@ -94,23 +95,23 @@ msgstr "Ghostty: Inspector de la terminal" msgid "Find…" msgstr "Encontrar…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Resultado anterior" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Resultado siguiente" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "¡Epa!" -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "No se puede iniciar OpenGL para rendering." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -120,99 +121,111 @@ msgstr "" "través del contenido, pero ninguna entrada (evento) va a ser enviada a la " "aplicación que se está ejecutando." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Solo lectura" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Copiar" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Pegar" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Notificar cuando el próximo comando finalice" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Limpiar" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Reiniciar" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Dividir" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Cambiar título…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Dividir arriba" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Dividir abajo" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Dividir a la izquierda" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Dividir a la derecha" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Pestaña" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Cambiar el título de la pestaña…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Nueva pestaña" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Cerrar pestaña" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Ventana" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Nueva ventana" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Cerrar ventana" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Configuración" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -msgstr "Abrir configuración" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -222,31 +235,31 @@ msgstr "Dejar en blanco para restaurar el título predeterminado." msgid "OK" msgstr "Aceptar" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Nueva ventana dividida" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Ver pestañas abiertas" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Menú principal" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Paleta de comandos" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Inspector de la terminal" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Acerca de Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Salir" @@ -254,18 +267,30 @@ msgstr "Salir" msgid "Execute a command…" msgstr "Ejecutar comando…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -326,46 +351,754 @@ msgstr "Todas las sesiones de terminal en esta ventana serán terminadas." msgid "The currently running process in this split will be terminated." msgstr "El proceso actualmente en ejecución en esta división será terminado." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Comando finalizado" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Comando exitoso" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Comando fallido" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Comando ejecutado con éxito" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Comando fallido" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Cambiar el título de la terminal" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Cambiar el título de la pestaña" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Configuración recargada" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Copiado al portapapeles" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "El portapapeles está limpio" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Desarrolladores de Ghostty" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/es_ES.po b/po/es_ES.po index 3527d5428..5f2cd9c84 100644 --- a/po/es_ES.po +++ b/po/es_ES.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-18 10:57+0100\n" "Last-Translator: José Miguel Sarasola \n" "Language-Team: \n" @@ -49,11 +49,12 @@ msgstr "Recargar configuración para volver a mostrar este aviso" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Cancelar" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Cerrar" @@ -62,7 +63,7 @@ msgstr "Cerrar" msgid "Configuration Errors" msgstr "Errores en la configuración" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -70,12 +71,12 @@ msgstr "" "Se detectaron uno o más errores de configuración. Por favor, revisa los " "errores más abajo y recarga la configuración o ignora estos errores." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Ignorar" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Recargar configuración" @@ -95,23 +96,23 @@ msgstr "Ghostty: Inspector de terminal" msgid "Find…" msgstr "Buscar…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Resultado previo" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Resultado siguiente" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Oh, no." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "No se pudo obtener un contexto de OpenGL para el renderizado." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -121,99 +122,111 @@ msgstr "" "scroll del contenido, pero no se enviarán eventos de entrada a la aplicación " "en ejecución." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Solo lectura" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Copiar" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Pegar" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Notificar al finalizar el siguiente comando" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Limpiar" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Reiniciar" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Dividir" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Cambiar título…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Dividir arriba" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Dividir abajo" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Dividir a la izquierda" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Dividir a la derecha" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Pestaña" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Cambiar título de la pestaña…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Nueva pestaña" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Cerrar pestaña" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Ventana" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Nueva ventana" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Cerrar ventana" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Configuración" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -msgstr "Abrir configuración" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -223,31 +236,31 @@ msgstr "Dejar en blanco para restaurar el título predeterminado." msgid "OK" msgstr "Aceptar" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Nueva división" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Ver pestañas abiertas" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Menú principal" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Paleta de comandos" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Inspector de terminal" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Acerca de Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Salir" @@ -255,18 +268,30 @@ msgstr "Salir" msgid "Execute a command…" msgstr "Ejecutar un comando…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -327,46 +352,754 @@ msgstr "Todas las sesiones del terminal en esta ventana serán finalizadas." msgid "The currently running process in this split will be terminated." msgstr "El proceso ejecutándose en esta división será finalizado." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Comando finalizado" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Comando completado" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Comando fallido" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Comando completado" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Comando fallido" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Cambiar el título del terminal" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Cambiar el título de la pestaña" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Configuración recargada" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Copiado al portapapeles" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Portapapeles vaciado" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Desarrolladores de Ghostty" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/eu.po b/po/eu.po index e36381d3b..a6e456052 100644 --- a/po/eu.po +++ b/po/eu.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-05-01 12:56+0200\n" "Last-Translator: Mikel Larreategi \n" "Language-Team: Language eu\n" @@ -47,11 +47,12 @@ msgstr "Birkargatu konfigurazioa eta erakutsi hau berriz" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Utzi" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Itxi" @@ -60,7 +61,7 @@ msgstr "Itxi" msgid "Configuration Errors" msgstr "Konfigurazio erroreak" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -68,12 +69,12 @@ msgstr "" "Konfigurazio erroreren bat aurkitu da. Begiratu erroreak azpian, eta " "birkargatu konfigurazioa edo baztertu erroreak." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Baztertu" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Birkargatu konfigurazioa" @@ -93,23 +94,23 @@ msgstr "Ghostty: terminal ikuskatzailea" msgid "Find…" msgstr "Bilatu…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Aurreko bilaketa" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Hurrengo bilaketa" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Oh, ez!" -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Ezin izan da OpenGL kontestua erabili." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -118,99 +119,111 @@ msgstr "" "Terminal hau irakurtzeko moduan dago. Ikusi, aukeratu eta kontestuan zehar " "gora eta behera ibili zaitezke, baina ez da sarrera ekintzarik bidaliko." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Irakurtzeko-bakarrik" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Kopiatu" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Itsatsi" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Jakinarazi hurrengo komandoa amaitzean" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Garbitu" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Berrezarri" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Zatitu" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Aldatu izenburua…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Zatitu gorantz" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Zatitu beherantz" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Zatitu ezkerrera" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Zatitu eskumara" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Fitxa" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Aldatu fitxaren izenburua…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Fitxa berria" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Itxi fitxa" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Leihoa" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Leiho berria" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Itxi leihoa" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Konfigurazioa" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -msgstr "Ireki konfigurazioa" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -220,31 +233,31 @@ msgstr "Utzi hutsik defektuzko izenburua berrezartzeko" msgid "OK" msgstr "OK" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Zatitu" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Ikusi irekitako fitxak" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Menu nagusia" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Komando paleta" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Terminal ikuskatzailea" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Ghosttyri buruz" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Irten" @@ -252,18 +265,30 @@ msgstr "Irten" msgid "Execute a command…" msgstr "Exekutatu komando bat…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -323,46 +348,754 @@ msgstr "Leiho honetako terminal saio guztiak itxi egingo dira." msgid "The currently running process in this split will be terminated." msgstr "Zatikatze honetan martxan dagoen prozesua itxi egingo da." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Komandoa amaitu da" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Komandoa ondo amaitu da" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Komandoak huts egin du" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Komandoa ondo amaitu da" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Komandoak huts egin du" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Aldatu terminalaren izenburua" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Aldatu fitxaren izenburua" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Konfigurazioa berriz kargatu da" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Arbelera kopiatu da" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Arbela garbitu da" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Ghostty garatzaileak" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/fr.po b/po/fr.po index d73815528..c1d485e8e 100644 --- a/po/fr.po +++ b/po/fr.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-18 15:03+0200\n" "Last-Translator: Pangoraw \n" "Language-Team: French \n" @@ -48,11 +48,12 @@ msgstr "Recharger la configuration pour afficher à nouveau ce message" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Annuler" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Fermer" @@ -61,7 +62,7 @@ msgstr "Fermer" msgid "Configuration Errors" msgstr "Erreurs de configuration" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -70,12 +71,12 @@ msgstr "" "les erreurs ci-dessous, et recharger votre configuration ou bien ignorer ces " "erreurs." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Ignorer" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Recharger la configuration" @@ -95,23 +96,23 @@ msgstr "Ghostty: Inspecteur" msgid "Find…" msgstr "Chercher…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Résultat précédent" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Résultat suivant" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Oh, non." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Impossible d'obtenir un contexte OpenGL pour le rendu." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -121,99 +122,111 @@ msgstr "" "sélectionner, et naviguer dans son contenu, mais aucune entrée ne sera " "envoyée à l'application en cours." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Lecture seule" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Copier" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Coller" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Notifier à la complétion de la prochaine commande" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Tout effacer" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Réinitialiser" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Créer panneau" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Changer le titre…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Panneau en haut" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Panneau en bas" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Panneau à gauche" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Panneau à droite" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Onglet" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Changer le titre de l'onglet…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Nouvel onglet" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Fermer l'onglet" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Fenêtre" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Nouvelle fenêtre" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Fermer la fenêtre" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Config" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -msgstr "Ouvrir la configuration" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -223,31 +236,31 @@ msgstr "Laisser vide pour restaurer le titre par défaut." msgid "OK" msgstr "OK" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Nouveau panneau" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Voir les onglets ouverts" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Menu principal" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Palette de commandes" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Inspecteur de terminal" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "À propos de Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Quitter" @@ -255,18 +268,30 @@ msgstr "Quitter" msgid "Execute a command…" msgstr "Exécuter une commande…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -327,46 +352,754 @@ msgstr "Toutes les sessions de cette fenêtre vont être arrêtées." msgid "The currently running process in this split will be terminated." msgstr "Le processus en cours dans ce panneau va être arrêté." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Commande terminée" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Commande réussie" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "La commande a échoué" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Commande réussie" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "La commande a échoué" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Changer le nom du terminal" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Changer le titre de l'onglet" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Configuration rechargée" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Copié dans le presse-papiers" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Presse-papiers vidé" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Les développeurs de Ghostty" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/ga.po b/po/ga.po index 3747561ef..d5f5e6175 100644 --- a/po/ga.po +++ b/po/ga.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-18 14:32+0000\n" "Last-Translator: Aindriú Mac Giolla Eoin \n" "Language-Team: Irish \n" @@ -48,11 +48,12 @@ msgstr "Athlódáil an chumraíocht chun an teachtaireacht seo a thaispeáint ar #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Cealaigh" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Dún" @@ -61,7 +62,7 @@ msgstr "Dún" msgid "Configuration Errors" msgstr "Earráidí cumraíochta" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -69,12 +70,12 @@ msgstr "" "Fuarthas earráid chumraíochta amháin nó níos mó. Athbhreithnigh na hearráidí " "thíos, agus athlódáil do chumraíocht nó déan neamhaird de na hearráidí seo." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Déan neamhaird de" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Athlódáil cumraíocht" @@ -93,23 +94,23 @@ msgstr "Ghostty: Cigire teirminéil" msgid "Find…" msgstr "Cuardaigh…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "An toradh roimhe seo" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "An chéad toradh eile" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Ó, fadbh." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Ní féidir comhthéacs OpenGL a fháil le haghaidh rindreála." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -119,99 +120,111 @@ msgstr "" "roghnú agus scroláil tríd an ábhar, ach ní seolfar aon teagmhais ionchuir " "chuig an bhfeidhmchlár atá ag rith." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Inléite amháin" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Cóipeáil" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Greamaigh" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Seol fógra nuair a chríochnaíonn an chéad ordú eile" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Glan" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Athshocraigh" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Scoilt" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Athraigh teideal…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Scoilt suas" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Scoilt síos" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Scoilt ar chlé" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Scoilt ar dheis" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Táb" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Athraigh teideal an táb…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Táb nua" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Dún táb" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Fuinneog" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Fuinneog nua" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Dún fuinneog" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Cumraíocht" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -msgstr "Oscail cumraíocht" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -221,31 +234,31 @@ msgstr "Fág bán chun an teideal réamhshocraithe a athbhunú." msgid "OK" msgstr "Ceart go leor" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Scoilt nua" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Féach ar na táib oscailte" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Príomh-Roghchlár" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Pailéad ordaithe" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Cigire teirminéil" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Maidir le Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Scoir" @@ -253,18 +266,30 @@ msgstr "Scoir" msgid "Execute a command…" msgstr "Rith ordú…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -326,46 +351,754 @@ msgid "The currently running process in this split will be terminated." msgstr "" "Cuirfear deireadh leis an bpróiseas atá ar siúl faoi láthair sa scoilt seo." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Ordú críochnaithe" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "D’éirigh leis an ordú" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Theip ar an ordú" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "D'éirigh leis an ordú" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Theip ar an ordú" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Athraigh teideal teirminéil" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Athraigh teideal an táb" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Tá an chumraíocht athlódáilte" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Cóipeáilte chuig an ghearrthaisce" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Gearrthaisce glanta" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Forbróirí Ghostty" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/he.po b/po/he.po index bc37a090f..fe94ced8c 100644 --- a/po/he.po +++ b/po/he.po @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-18 18:14+0300\n" "Last-Translator: Sl (Shahaf Levi), Sl's Repository Ltd " "\n" @@ -51,11 +51,12 @@ msgstr "טען/י את ההגדרות מחדש כדי להציג את הבקשה #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "ביטול" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "סגירה" @@ -64,7 +65,7 @@ msgstr "סגירה" msgid "Configuration Errors" msgstr "שגיאות בהגדרות" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -72,12 +73,12 @@ msgstr "" "נמצאו אחת או יותר שגיאות בהגדרות. אנא בדוק/י את השגיאות המופיעות מטה ולאחר " "מכן טען/י את ההגדרות מחדש או התעלם/י מהשגיאות." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "התעלמות" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "טעינה מחדש של ההגדרות" @@ -95,23 +96,23 @@ msgstr "Ghostty: בודק המסוף" msgid "Find…" msgstr "חפש/י…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "ההתאמה הקודמת" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "ההתאמה הבאה" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "אוי, לא" -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "לא ניתן לקבל הקשר OpenGL לצורך רינדור." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -120,99 +121,111 @@ msgstr "" "מסוף זה נמצא במצב קריאה בלבד. עדיין תוכל/י לצפות, לבחור ולגלול בתוכן, אך לא " "יישלחו אירועי קלט לאפליקציה הפעילה." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "לקריאה בלבד" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "העתקה" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "הדבקה" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "תזכורת בסיום הפקודה הבאה" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "ניקוי" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "איפוס" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "פיצול" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "שינוי כותרת…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "פיצול למעלה" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "פיצול למטה" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "פיצול שמאלה" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "פיצול ימינה" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "כרטיסייה" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "שנה/י את כותרת הכרטיסייה…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "כרטיסייה חדשה" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "סגור/י כרטיסייה" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "חלון" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "חלון חדש" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "סגור/י חלון" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "הגדרות" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -222,31 +235,31 @@ msgstr "השאר/י ריק כדי לשחזר את כותרת ברירת המחד msgid "OK" msgstr "אישור" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "פיצול חדש" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "הצג/י כרטיסיות פתוחות" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "תפריט ראשי" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "לוח פקודות" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "בודק המסוף" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "אודות Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "יציאה" @@ -254,18 +267,30 @@ msgstr "יציאה" msgid "Execute a command…" msgstr "הרץ/י פקודה…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -323,46 +348,754 @@ msgstr "כל הפעלות המסוף בחלון זה יסתיימו." msgid "The currently running process in this split will be terminated." msgstr "התהליך שרץ כרגע בפיצול זה יסתיים." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "הפקודה הסתיימה" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "הפקודה הצליחה" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "הפקודה נכשלה" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "הפקודה הצליחה" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "הפקודה נכשלה" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "שינוי כותרת המסוף" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "שינוי כותרת הכרטיסייה" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "ההגדרות הוטענו מחדש" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "הועתק ללוח ההעתקה" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "לוח ההעתקה רוקן" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "המפתחים של Ghostty" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/hr.po b/po/hr.po index 3ccd8057f..1ecd9ca44 100644 --- a/po/hr.po +++ b/po/hr.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-18 21:00+0200\n" "Last-Translator: Filip7 \n" "Language-Team: Croatian \n" @@ -50,11 +50,12 @@ msgstr "Ponovno učitaj postavke za prikaz ovog upita" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Otkaži" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Zatvori" @@ -63,7 +64,7 @@ msgstr "Zatvori" msgid "Configuration Errors" msgstr "Greške u postavkama" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -71,12 +72,12 @@ msgstr "" "Pronađena je greška (ili više njih) u postavkama. Pregledaj niže navedene " "greške te ponovno učitaj postavke ili zanemari ove greške." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Zanemari" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Ponovno učitaj postavke" @@ -94,23 +95,23 @@ msgstr "Ghostty: inspektor terminala" msgid "Find…" msgstr "Pretraži…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Prethodno podudaranje" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Sljedeće podudaranje" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Oh, ne." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Neuspjelo dohvaćanje OpenGL konteksta za renderiranje." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -120,99 +121,111 @@ msgstr "" "odabirati i skrolati kroz sadržaj, no unos neće biti poslan pokrenutoj " "aplikaciji." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Samo za čitanje" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Kopiraj" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Zalijepi" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Obavijesti kada iduća naredba završi" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Očisti" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Resetiraj" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Podijeli" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Promijeni naslov…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Podijeli gore" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Podijeli dolje" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Podijeli lijevo" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Podijeli desno" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Kartica" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Promijeni naslov kartice…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Nova kartica" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Zatvori karticu" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Prozor" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Novi prozor" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Zatvori prozor" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Postavke" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -msgstr "Otvori postavke" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -222,31 +235,31 @@ msgstr "Ostavi prazno za povratak zadanog naslova." msgid "OK" msgstr "OK" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Nova podjela" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Pregledaj otvorene kartice" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Glavni izbornik" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Paleta naredbi" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Inspektor terminala" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "O Ghosttyju" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Izađi" @@ -254,18 +267,30 @@ msgstr "Izađi" msgid "Execute a command…" msgstr "Izvrši naredbu…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -326,46 +351,754 @@ msgstr "Sve sesije terminala u ovom prozoru će biti prekinute." msgid "The currently running process in this split will be terminated." msgstr "Pokrenuti procesi u ovoj podjeli će biti prekinuti." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Naredba je završena" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Naredba je uspjela" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Naredba nije uspjela" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Naredba je uspjela" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Naredba nije uspjela" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Promijeni naslov terminala" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Promijeni naslov kartice" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Ponovno učitane postavke" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Kopirano u međuspremnik" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Očišćen međuspremnik" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Razvijatelji Ghosttyja" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/hu.po b/po/hu.po index 61751e47d..4083235d8 100644 --- a/po/hu.po +++ b/po/hu.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-26 21:00+0100\n" "Last-Translator: Balázs Szücs \n" "Language-Team: Hungarian \n" @@ -48,11 +48,12 @@ msgstr "Konfiguráció frissítése a kérdés újbóli megjelenítéséhez" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Mégse" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Bezárás" @@ -61,7 +62,7 @@ msgstr "Bezárás" msgid "Configuration Errors" msgstr "Konfigurációs hibák" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -70,12 +71,12 @@ msgstr "" "hibákat, és frissítse a konfigurációt, vagy hagyja figyelmen kívül ezeket a " "hibákat." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Figyelmen kívül hagyás" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Konfiguráció frissítése" @@ -94,23 +95,23 @@ msgstr "Ghostty: Terminálvizsgáló" msgid "Find…" msgstr "Keresés…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Előző találat" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Következő találat" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Jaj, ne." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Nem sikerült OpenGL-környezetet létrehozni a megjelenítéshez." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -120,99 +121,111 @@ msgstr "" "megtekintheti, kijelölheti és görgetheti, de nem küld bemeneti eseményeket a " "futó alkalmazásnak." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Csak olvasható" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Másolás" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Beillesztés" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Értesítés a következő parancs befejezésekor" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Törlés" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Visszaállítás" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Felosztás" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Cím módosítása…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Felosztás felfelé" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Felosztás lefelé" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Felosztás balra" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Felosztás jobbra" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Fül" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Fül címének módosítása…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Új fül" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Fül bezárása" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Ablak" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Új ablak" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Ablak bezárása" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Konfiguráció" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -msgstr "Konfiguráció megnyitása" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -222,31 +235,31 @@ msgstr "Hagyja üresen az alapértelmezett cím visszaállításához." msgid "OK" msgstr "Rendben" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Új felosztás" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Megnyitott fülek megtekintése" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Főmenü" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Parancspaletta" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Terminálvizsgáló" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "A Ghostty névjegye" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Kilépés" @@ -254,18 +267,30 @@ msgstr "Kilépés" msgid "Execute a command…" msgstr "Parancs végrehajtása…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -326,46 +351,754 @@ msgstr "Ebben az ablakban minden terminál munkamenet lezárul." msgid "The currently running process in this split will be terminated." msgstr "Ebben a felosztásban a jelenleg futó folyamat lezárul." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Parancs befejeződött" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Parancs sikeres" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Parancs sikertelen" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Parancs sikeres" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Parancs sikertelen" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Terminál címének módosítása" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Fül címének módosítása" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Konfiguráció frissítve" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Vágólapra másolva" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Vágólap törölve" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Ghostty fejlesztők" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/id.po b/po/id.po index 93db4af96..711fceae2 100644 --- a/po/id.po +++ b/po/id.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-03-08 20:23+0700\n" "Last-Translator: Mikail Muzakki \n" "Language-Team: Indonesian \n" @@ -49,11 +49,12 @@ msgstr "Muat ulang konfigurasi untuk menampilkan pesan ini lagi" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Batal" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Tutup" @@ -62,7 +63,7 @@ msgstr "Tutup" msgid "Configuration Errors" msgstr "Kesalahan konfigurasi" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -70,12 +71,12 @@ msgstr "" "Ditemukan satu atau lebih kesalahan konfigurasi. Silakan tinjau kesalahan di " "bawah ini, dan muat ulang konfigurasi anda atau abaikan kesalahan ini." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Abaikan" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Muat ulang konfigurasi" @@ -94,23 +95,23 @@ msgstr "Ghostty: Inspektur terminal" msgid "Find…" msgstr "Cari…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Hasil sebelumnya" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Hasil berikutnya" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Oh, tidak." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Tidak dapat memperoleh konteks OpenGL untuk penampilan grafis." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -120,99 +121,111 @@ msgstr "" "memilih, dan menggulir konten, tetapi peristiwa input tidak akan dikirim ke " "aplikasi berjalan." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Hanya baca" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Salin" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Tempel" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Beri tahu saat perintah berikutnya selesai" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Bersihkan" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Atur ulang" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Belah" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Ubah judul…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Belah atas" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Belah bawah" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Belah kiri" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Belah kanan" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Tab" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Ubah judul tab…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Tab baru" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Tutup tab" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Jendela" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Jendela baru" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Tutup jendela" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Konfigurasi" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -msgstr "Buka konfigurasi" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -222,31 +235,31 @@ msgstr "Biarkan kosong untuk mengembalikan judul bawaan." msgid "OK" msgstr "OK" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Belahan baru" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Lihat tab terbuka" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Menu utama" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Palet perintah" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Inspektur terminal" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Tentang Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Keluar" @@ -254,18 +267,30 @@ msgstr "Keluar" msgid "Execute a command…" msgstr "Eksekusi perintah…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -326,46 +351,754 @@ msgstr "Semua sesi terminal di jendela ini akan diakhiri." msgid "The currently running process in this split will be terminated." msgstr "Proses yang sedang berjalan dalam belahan ini akan diakhiri." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Perintah selesai" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Perintah berhasil" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Perintah gagal" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Perintah berhasil" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Perintah gagal" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Ubah judul terminal" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Ubah judul tab" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Memuat ulang konfigurasi" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Disalin ke papan klip" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Papan klip dibersihkan" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Pengembang Ghostty" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/it.po b/po/it.po index 39b660f06..e5ad831b2 100644 --- a/po/it.po +++ b/po/it.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2025-09-06 19:40+0200\n" "Last-Translator: Giacomo Bettini \n" "Language-Team: Italian \n" @@ -50,11 +50,12 @@ msgstr "" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Annulla" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Chiudi" @@ -63,7 +64,7 @@ msgstr "Chiudi" msgid "Configuration Errors" msgstr "Errori di configurazione" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -71,12 +72,12 @@ msgstr "" "Sono stati trovati uno o più errori di configurazione. Controlla gli errori " "seguenti, poi ricarica la tua configurazione o ignora quegli errori." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Ignora" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Ricarica configurazione" @@ -95,23 +96,23 @@ msgstr "Ghostty: Ispettore del terminale" msgid "Find…" msgstr "Cerca…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Corrispondenza precedente" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Corrispondenza successiva" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Oh no!" -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Impossibile ottenere un contesto OpenGL per il rendering." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -121,99 +122,111 @@ msgstr "" "selezionare e scorrere il contenuto, ma non verrà inviato alcun evento di " "input all'applicazione in esecuzione." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Sola lettura" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Copia" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Incolla" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Notifica al termine del prossimo comando" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Pulisci" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Reimposta" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Divisione" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Cambia titolo…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Dividi in alto" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Dividi in basso" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Dividi a sinistra" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Dividi a destra" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Scheda" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Cambia titolo scheda…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Nuova scheda" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Chiudi scheda" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Finestra" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Nuova finestra" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Chiudi finestra" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Configurazione" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -msgstr "Apri configurazione" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -223,31 +236,31 @@ msgstr "Lasciare vuoto per ripristinare il titolo predefinito." msgid "OK" msgstr "OK" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Nuova divisione" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Vedi schede aperte" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Menù principale" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Riquadro comandi" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Ispettore del terminale" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Informazioni su Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Chiudi" @@ -255,18 +268,30 @@ msgstr "Chiudi" msgid "Execute a command…" msgstr "Esegui un comando…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -328,46 +353,754 @@ msgid "The currently running process in this split will be terminated." msgstr "" "Il processo attualmente in esecuzione in questa divisione sarà terminato." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Comando terminato" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Comando riuscito" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Comando fallito" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Comando riuscito" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Comando fallito" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Cambia il titolo del terminale" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Cambia il titolo della scheda" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Configurazione ricaricata" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Copiato negli Appunti" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Appunti svuotati" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Sviluppatori di Ghostty" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/ja.po b/po/ja.po index 0bb0e1e50..eeb194608 100644 --- a/po/ja.po +++ b/po/ja.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-11 12:02+0900\n" "Last-Translator: Takayuki Nagatomi \n" "Language-Team: Japanese\n" @@ -49,11 +49,12 @@ msgstr "このプロンプトを再び表示するには設定を再読み込み #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "キャンセル" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "閉じる" @@ -62,7 +63,7 @@ msgstr "閉じる" msgid "Configuration Errors" msgstr "設定ファイルにエラーがあります" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -70,12 +71,12 @@ msgstr "" "設定ファイルにエラーがあります。以下のエラーを確認し、設定ファイルの再読み込" "みをするか、無視してください。" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "無視" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "設定ファイルの再読み込み" @@ -94,23 +95,23 @@ msgstr "Ghostty: 端末インスペクター" msgid "Find…" msgstr "検索…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "前の一致" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "次の一致" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "おっと。" -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "レンダリング用のOpenGLコンテキストを取得できませんでした。" -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -119,99 +120,111 @@ msgstr "" "このターミナルは読み取り専用モードです。コンテンツの表示、選択、スクロールは" "可能ですが、入力イベントは実行中のアプリケーションに送信されません。" -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "読み取り専用" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "コピー" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "貼り付け" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "次のコマンド実行終了時に通知する" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "クリア" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "リセット" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "分割" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "タイトルを変更…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "上に分割" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "下に分割" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "左に分割" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "右に分割" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "タブ" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "タブのタイトルを変更…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "新しいタブ" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "タブを閉じる" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "ウィンドウ" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "新しいウィンドウ" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "ウィンドウを閉じる" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "設定" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -221,31 +234,31 @@ msgstr "空白にした場合、デフォルトのタイトルを使用します msgid "OK" msgstr "OK" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "新しい分割" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "開いているすべてのタブを表示" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "メインメニュー" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "コマンドパレット" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "端末インスペクター" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Ghostty について" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "終了" @@ -253,18 +266,30 @@ msgstr "終了" msgid "Execute a command…" msgstr "コマンドを実行…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -325,46 +350,754 @@ msgstr "ウィンドウ内のすべてのターミナルセッションが終了 msgid "The currently running process in this split will be terminated." msgstr "分割ウィンドウ内のすべてのプロセスが終了します。" -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "コマンド実行終了" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "コマンド実行成功" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "コマンド実行失敗" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "コマンド実行成功" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "コマンド実行失敗" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "ターミナルのタイトルを変更する" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "タブのタイトルを変更する" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "設定を再読み込みしました" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "クリップボードにコピーしました" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "クリップボードを空にしました" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Ghostty 開発者" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/kk.po b/po/kk.po index 91525453e..ee9d47399 100644 --- a/po/kk.po +++ b/po/kk.po @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-06-20 17:07+0500\n" "Last-Translator: AnmiTaliDev \n" "Language-Team: Kazakh \n" @@ -50,11 +50,12 @@ msgstr "Бұл сұрауды қайта көрсету үшін конфигу #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Бас тарту" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Жабу" @@ -63,7 +64,7 @@ msgstr "Жабу" msgid "Configuration Errors" msgstr "Конфигурация қателері" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -71,12 +72,12 @@ msgstr "" "Бір немесе бірнеше конфигурация қатесі табылды. Төмендегі қателерді қарап " "шығып, конфигурацияны қайта жүктеңіз немесе бұл қателерді елемеңіз." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Елемеу" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Конфигурацияны қайта жүктеу" @@ -96,23 +97,23 @@ msgstr "Ghostty: Терминал инспекторы" msgid "Find…" msgstr "Табу…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Алдыңғы сәйкестік" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Келесі сәйкестік" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "О, жоқ." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Рендеринг үшін OpenGL контекстін алу мүмкін емес." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -122,99 +123,111 @@ msgstr "" "жылжыта аласыз, бірақ іске қосылған қолданбаға ешқандай енгізу оқиғалары " "жіберілмейді." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Тек оқу" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Көшіріп алу" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Кірістіру" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Келесі команда аяқталғанда хабарлау" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Тазарту" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Тастау" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Бөлу" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Тақырыпты өзгерту…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Жоғарыға бөлу" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Төменге бөлу" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Сол жаққа бөлу" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Оң жаққа бөлу" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "Бөлуді жабу" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Бет" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Бет атауын өзгерту…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Жаңа бет" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Бетті жабу" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Терезе" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Жаңа терезе" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Терезені жабу" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Конфигурация" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -224,31 +237,31 @@ msgstr "Бастапқы атауды қалпына келтіру үшін б msgid "OK" msgstr "ОК" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Жаңа бөлу" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Ашық беттерді көру" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Бас мәзір" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Командалар палитрасы" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Терминал инспекторы" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Ghostty туралы" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Шығу" @@ -256,18 +269,30 @@ msgstr "Шығу" msgid "Execute a command…" msgstr "Команданы орындау…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -328,46 +353,754 @@ msgstr "Осы терезедегі барлық терминал сессиял msgid "The currently running process in this split will be terminated." msgstr "Осы бөлудегі ағымдағы орындалып жатқан процесс тоқтатылады." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Команда аяқталды" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Команда сәтті орындалды" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Команда сәтсіз аяқталды" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Команда сәтті орындалды" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Команда сәтсіз аяқталды" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Терминал атауын өзгерту" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Бет атауын өзгерту" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Конфигурация қайта жүктелді" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Алмасу буферіне көшірілді" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Алмасу буфері тазартылды" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Ghostty әзірлеушілері" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/ko_KR.po b/po/ko_KR.po index f1ebadfcc..ffaf4c957 100644 --- a/po/ko_KR.po +++ b/po/ko_KR.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-11 12:50+0900\n" "Last-Translator: GyuYong Jung \n" "Language-Team: Korean \n" @@ -48,11 +48,12 @@ msgstr "이 창을 다시 보려면 설정을 다시 불러오세요" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "취소" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "닫기" @@ -61,7 +62,7 @@ msgstr "닫기" msgid "Configuration Errors" msgstr "설정 오류" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -69,12 +70,12 @@ msgstr "" "설정에 하나 이상의 문제가 발견되었습니다. 아래 오류를 확인한 후 설정을 다시 " "불러오거나 무시하세요." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "무시" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "설정 값 다시 불러오기" @@ -92,23 +93,23 @@ msgstr "Ghostty: 터미널 인스펙터" msgid "Find…" msgstr "찾기…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "이전 결과" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "다음 결과" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "이런!" -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "렌더링을 위한 OpenGL 컨텍스트를 가져올 수 없습니다." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -117,99 +118,111 @@ msgstr "" "이 터미널은 읽기 전용 모드입니다. 콘텐츠를 보고 선택하고 스크롤할 수는 있지" "만 실행 중인 애플리케이션으로 입력 이벤트가 전송되지 않습니다." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "읽기 전용" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "복사" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "붙여넣기" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "다음 명령 완료 시 알림" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "지우기" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "초기화" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "나누기" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "제목 변경…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "위로 창 나누기" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "아래로 창 나누기" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "왼쪽으로 창 나누기" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "오른쪽으로 창 나누기" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "탭" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "탭 제목 변경…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "새 탭" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "탭 닫기" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "창" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "새 창" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "창 닫기" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "설정" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -219,31 +232,31 @@ msgstr "제목란을 비워 두면 기본값으로 복원됩니다." msgid "OK" msgstr "확인" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "새 분할" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "열린 탭 보기" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "메인 메뉴" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "명령 팔레트" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "터미널 인스펙터" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Ghostty 정보" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "종료" @@ -251,18 +264,30 @@ msgstr "종료" msgid "Execute a command…" msgstr "명령을 실행하세요…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -323,46 +348,754 @@ msgstr "이 창의 모든 터미널 세션이 종료됩니다." msgid "The currently running process in this split will be terminated." msgstr "이 분할에서 현재 실행 중인 프로세스가 종료됩니다." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "명령 완료" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "명령 성공" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "명령 실패" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "명령 성공" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "명령 실패" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "터미널 제목 변경" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "탭 제목 변경" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "설정값을 다시 불러왔습니다" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "클립보드에 복사됨" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "클립보드 지워짐" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Ghostty 개발자들" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/lt.po b/po/lt.po index 2f30c3b45..b9f41779d 100644 --- a/po/lt.po +++ b/po/lt.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-20 12:13+0100\n" "Last-Translator: Tadas Lotuzas \n" "Language-Team: Language LT\n" @@ -49,11 +49,12 @@ msgstr "Iš naujo įkelkite konfigūraciją, kad vėl būtų rodoma ši užuomin #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Atšaukti" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Uždaryti" @@ -62,7 +63,7 @@ msgstr "Uždaryti" msgid "Configuration Errors" msgstr "Konfigūracijos klaidos" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -71,12 +72,12 @@ msgstr "" "klaidas ir arba iš naujo įkelkite konfigūraciją, arba ignoruokite šias " "klaidas." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Ignoruoti" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Iš naujo įkelti konfigūraciją" @@ -94,23 +95,23 @@ msgstr "Ghostty: terminalo inspektorius" msgid "Find…" msgstr "Rasti…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Ankstesnis atitikmuo" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Kitas atitikmuo" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Oi, ne." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Nepavyko gauti OpenGL konteksto vaizdavimui." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -120,99 +121,111 @@ msgstr "" "slinkti per turinį, tačiau jokie įvesties įvykiai nebus siunčiami " "veikiančiai programai." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Tik skaitymui" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Kopijuoti" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Įklijuoti" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Pranešti apie sekančios komandos užbaigimą" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Išvalyti" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Atstatyti" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Padalinti" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Keisti pavadinimą…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Padalinti aukštyn" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Padalinti žemyn" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Padalinti kairėn" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Padalinti dešinėn" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Kortelė" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Keisti kortelės pavadinimą…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Nauja kortelė" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Uždaryti kortelę" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Langas" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Naujas langas" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Uždaryti langą" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Konfigūracija" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -msgstr "Atidaryti konfigūraciją" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -222,31 +235,31 @@ msgstr "Palikite tuščią, kad atkurtumėte numatytąjį pavadinimą." msgid "OK" msgstr "Gerai" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Naujas padalijimas" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Peržiūrėti atidarytas korteles" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Pagrindinis meniu" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Komandų paletė" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Terminalo inspektorius" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Apie Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Išeiti" @@ -254,18 +267,30 @@ msgstr "Išeiti" msgid "Execute a command…" msgstr "Vykdyti komandą…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -326,46 +351,754 @@ msgstr "Visos terminalo sesijos šiame lange bus nutrauktos." msgid "The currently running process in this split will be terminated." msgstr "Šiuo metu vykdomas procesas šiame padalijime bus nutrauktas." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Komanda užbaigta" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Komanda sėkminga" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Komanda nepavyko" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Komanda sėkminga" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Komanda nepavyko" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Keisti terminalo pavadinimą" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Keisti kortelės pavadinimą" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Konfigūracija įkelta iš naujo" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Nukopijuota į iškarpinę" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Iškarpinė išvalyta" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Ghostty kūrėjai" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/lv.po b/po/lv.po index e0178e6b5..d7eb8bb3b 100644 --- a/po/lv.po +++ b/po/lv.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-09 03:24+0200\n" "Last-Translator: Ēriks Remess \n" "Language-Team: Latvian\n" @@ -48,11 +48,12 @@ msgstr "Pārlādējiet konfigurāciju, lai šo uzvedni rādītu atkal" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Atcelt" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Aizvērt" @@ -61,7 +62,7 @@ msgstr "Aizvērt" msgid "Configuration Errors" msgstr "Konfigurācijas kļūdas" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -69,12 +70,12 @@ msgstr "" "Tika atrasta viena vai vairākas konfigurācijas kļūdas. Lūdzu, pārskatiet " "zemāk redzamās kļūdas un pārlādējiet konfigurāciju vai ignorējiet tās." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Ignorēt" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Pārlādēt konfigurāciju" @@ -92,23 +93,23 @@ msgstr "Ghostty: Termināļa inspektors" msgid "Find…" msgstr "Meklēt…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Iepriekšējā atbilstība" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Nākamā atbilstība" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Ak, nē." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Neizdevās iegūt OpenGL kontekstu attēlošanai." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -117,99 +118,111 @@ msgstr "" "Šis terminālis ir tikai lasīšanas režīmā. Jūs joprojām varat skatīt, atlasīt " "un ritināt saturu, taču ievade netiks sūtīta palaistajai lietotnei." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Tikai lasīšanai" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Kopēt" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Ielīmēt" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Paziņot, kad nākamā komanda būs izpildīta" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Notīrīt" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Atiestatīt" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Sadalīt" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Mainīt virsrakstu…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Sadalīt uz augšu" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Sadalīt uz leju" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Sadalīt pa kreisi" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Sadalīt pa labi" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Cilne" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Mainīt cilnes virsrakstu…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Jauna cilne" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Aizvērt cilni" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Logs" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Jauns logs" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Aizvērt logu" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Konfigurācija" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -msgstr "Atvērt konfigurāciju" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -219,31 +232,31 @@ msgstr "Atstāj tukšu, lai atjaunotu noklusēto virsrakstu." msgid "OK" msgstr "Labi" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Jauns sadalījums" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Skatīt atvērtās cilnes" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Galvenā izvēlne" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Komandu palete" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Termināļa inspektors" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Par Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Iziet" @@ -251,18 +264,30 @@ msgstr "Iziet" msgid "Execute a command…" msgstr "Izpildīt komandu…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -323,46 +348,754 @@ msgstr "Visas termināļa sesijas šajā logā tiks pārtrauktas." msgid "The currently running process in this split will be terminated." msgstr "Pašlaik palaistais process šajā sadalījumā tiks pārtraukts." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Komanda izpildīta" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Komanda izdevās" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Komanda neizdevās" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Komanda izdevās" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Komanda neizdevās" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Mainīt termināļa virsrakstu" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Mainīt cilnes virsrakstu" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Konfigurācija pārlādēta" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Nokopēts starpliktuvē" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Starpliktuve notīrīta" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Ghostty izstrādātāji" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/mk.po b/po/mk.po index 0f4c575bc..f796c391e 100644 --- a/po/mk.po +++ b/po/mk.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-12 17:00+0100\n" "Last-Translator: Andrej Daskalov \n" "Language-Team: Macedonian\n" @@ -48,11 +48,12 @@ msgstr "Одново вчитај конфигурација за да се по #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Откажи" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Затвори" @@ -61,7 +62,7 @@ msgstr "Затвори" msgid "Configuration Errors" msgstr "Грешки во конфигурацијата" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -70,12 +71,12 @@ msgstr "" "грешките подолу и повторно вчитајте ја конфигурацијата или игнорирајте ги " "овие грешки." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Игнорирај" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Одново вчитај конфигурација" @@ -94,23 +95,23 @@ msgstr "Ghostty: Инспектор на терминал" msgid "Find…" msgstr "Пронајди…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Претходно совпаѓање" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Следно совпаѓање" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Упс." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Не може да се добие OpenGL контекст за рендерирање." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -120,99 +121,111 @@ msgstr "" "се движите низ содржината, но влезните настани нема да бидат испратени до " "апликацијата." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Само читање" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Копирај" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Вметни" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Извести по завршување на следната команда" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Исчисти" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Ресетирај" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Подели" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Промени наслов…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Подели нагоре" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Подели надолу" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Подели налево" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Подели надесно" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Јазиче" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Промени наслов на јазиче…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Ново јазиче" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Затвори јазиче" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Прозор" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Нов прозор" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Затвори прозор" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Конфигурација" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -222,31 +235,31 @@ msgstr "Оставете празно за враќање на стандарс msgid "OK" msgstr "Во ред" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Нова поделба" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Прегледај отворени јазичиња" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Главно мени" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Командна палета" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Инспектор на терминал" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "За Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Излез" @@ -254,18 +267,30 @@ msgstr "Излез" msgid "Execute a command…" msgstr "Изврши команда…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -326,46 +351,754 @@ msgstr "Сите сесии во овој прозорец ќе бидат пр msgid "The currently running process in this split will be terminated." msgstr "Процесот кој моментално се извршува во оваа поделба ќе биде прекинат." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Командата заврши" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Командата успеа" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Командата не успеа" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Командата успеа" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Командата не успеа" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Промени наслов на терминал" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Промени наслов на јазиче" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Конфигурацијата е одново вчитана" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Копирано во привремена меморија" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Исчистена привремена меморија" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Развивачи на Ghostty" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/nb.po b/po/nb.po index 34596be08..ca50776a3 100644 --- a/po/nb.po +++ b/po/nb.po @@ -10,7 +10,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-12 15:50+0000\n" "Last-Translator: Hanna Rose \n" "Language-Team: Norwegian Bokmal \n" @@ -51,11 +51,12 @@ msgstr "Last inn konfigurasjonen på nytt for å vise denne meldingen igjen" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Avbryt" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Lukk" @@ -64,7 +65,7 @@ msgstr "Lukk" msgid "Configuration Errors" msgstr "Konfigurasjonsfeil" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -72,12 +73,12 @@ msgstr "" "Én eller flere konfigurasjonsfeil ble funnet. Vennligst gjennomgå feilene " "under, og enten last konfigurasjonen din på nytt eller ignorer disse feilene." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Ignorer" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Last konfigurasjon på nytt" @@ -95,23 +96,23 @@ msgstr "Ghostty: Terminalinspektør" msgid "Find…" msgstr "Finn…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Forrige treff" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Neste treff" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Å, nei." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Kan ikke hente en OpenGL-kontekst for rendering." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -121,99 +122,111 @@ msgstr "" "bla gjennom innholdet, men ingen inndatahendelser vil bli sendt til den " "kjørende applikasjonen." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Skrivebeskyttet" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Kopier" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Lim inn" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Varsle når neste kommandoen fullføres" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Fjern" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Nullstill" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Del vindu" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Endre tittel…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Del oppover" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Del nedover" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Del til venstre" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Del til høyre" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Fane" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Endre fanetittel…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Ny fane" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Lukk fane" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Vindu" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Nytt vindu" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Lukk vindu" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Konfigurasjon" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -msgstr "Åpne konfigurasjon" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -223,31 +236,31 @@ msgstr "Blank verdi gjenoppretter standardtittelen." msgid "OK" msgstr "OK" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Del opp vindu" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Se åpne faner" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Hovedmeny" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Kommandopalett" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Terminalinspektør" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Om Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Avslutt" @@ -255,18 +268,30 @@ msgstr "Avslutt" msgid "Execute a command…" msgstr "Kjør en kommando…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -327,46 +352,754 @@ msgstr "Alle terminaløkter i dette vinduet vil bli avsluttet." msgid "The currently running process in this split will be terminated." msgstr "Den kjørende prosessen for denne splitten vil bli avsluttet." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Kommandoen fullført" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Kommandoen lyktes" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Kommandoen mislyktes" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Kommando lyktes" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Kommando mislyktes" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Endre terminaltittel" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Endre fanetittel" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Konfigurasjonen ble lastet på nytt" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Kopiert til utklippstavlen" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Utklippstavle tømt" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Ghostty-utviklere" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/nl.po b/po/nl.po index b2fe9e0a7..e9f5d9c6e 100644 --- a/po/nl.po +++ b/po/nl.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-18 20:59+0100\n" "Last-Translator: Nico Geesink \n" "Language-Team: Dutch \n" @@ -49,11 +49,12 @@ msgstr "Herlaad de configuratie om deze prompt opnieuw weer te geven" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Annuleren" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Afsluiten" @@ -62,7 +63,7 @@ msgstr "Afsluiten" msgid "Configuration Errors" msgstr "Configuratiefouten" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -70,12 +71,12 @@ msgstr "" "Er zijn één of meer configuratiefouten gevonden. Bekijk de onderstaande " "fouten en herlaad je configuratie of negeer deze fouten." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Negeer" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Herlaad configuratie" @@ -95,23 +96,23 @@ msgstr "Ghostty: terminalinspecteur" msgid "Find…" msgstr "Zoeken…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Vorige resultaat" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Volgende resultaat" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Oh, nee." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "OpenGL-context voor rendering aanmaken mislukt." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -121,99 +122,111 @@ msgstr "" "bekijken en selecteren, maar er wordt geen invoer naar de applicatie " "verzonden." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Alleen-lezen" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Kopiëren" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Plakken" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Meld wanneer het volgende commando is afgerond" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Leegmaken" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Herstellen" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Splitsen" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Wijzig titel…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Splits naar boven" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Splits naar beneden" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Splits naar links" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Splits naar rechts" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Tabblad" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Wijzig tabbladtitel…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Nieuw tabblad" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Sluit tabblad" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Venster" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Nieuw venster" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Sluit venster" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Configuratie" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -msgstr "Open configuratie" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -223,31 +236,31 @@ msgstr "Laat leeg om de standaardtitel te herstellen." msgid "OK" msgstr "OK" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Nieuwe splitsing" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Open tabbladen bekijken" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Hoofdmenu" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Opdrachtpalet" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Terminalinspecteur" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Over Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Afsluiten" @@ -255,18 +268,30 @@ msgstr "Afsluiten" msgid "Execute a command…" msgstr "Voer een commando uit…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -327,46 +352,754 @@ msgstr "Alle terminalsessies binnen dit venster zullen worden beëindigd." msgid "The currently running process in this split will be terminated." msgstr "Alle processen in deze splitsing zullen worden beëindigd." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Commando afgerond" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Commando succesvol afgerond" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Commando onsuccesvol afgerond" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Opdracht geslaagd" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Opdracht mislukt" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Titel van de terminal wijzigen" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Wijzig tabbladtitel" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "De configuratie is herladen" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Gekopieerd naar klembord" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Klembord geleegd" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Ghostty-ontwikkelaars" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/pl.po b/po/pl.po index c3978b2f5..3d661ca3f 100644 --- a/po/pl.po +++ b/po/pl.po @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-11 14:12+0100\n" "Last-Translator: trag1c \n" "Language-Team: Polish \n" @@ -51,11 +51,12 @@ msgstr "Przeładuj konfigurację, by ponownie wyświetlić ten komunikat" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Anuluj" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Zamknij" @@ -64,7 +65,7 @@ msgstr "Zamknij" msgid "Configuration Errors" msgstr "Błędy konfiguracji" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -72,12 +73,12 @@ msgstr "" "Znaleziono jeden lub więcej błędów konfiguracji. Sprawdź błędy wylistowane " "poniżej i przeładuj konfigurację lub zignoruj je." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Zignoruj" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Przeładuj konfigurację" @@ -95,23 +96,23 @@ msgstr "Inspektor terminala Ghostty" msgid "Find…" msgstr "Znajdź…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Poprzednie dopasowanie" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Następne dopasowanie" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "O nie!" -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Nie można uzyskać kontekstu OpenGL do renderowania." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -121,99 +122,111 @@ msgstr "" "przeglądać, zaznaczać i przewijać zawartość, ale wprowadzane dane nie będą " "przesyłane do wykonywanej aplikacji." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Tylko do odczytu" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Kopiuj" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Wklej" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Powiadom o ukończeniu następnej komendy" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Wyczyść" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Zresetuj" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Podział" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Zmień tytuł…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Podziel w górę" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Podziel w dół" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Podziel w lewo" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Podziel w prawo" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Karta" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Zmień tytuł karty…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Nowa karta" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Zamknij kartę" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Okno" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Nowe okno" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Zamknij okno" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Konfiguracja" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -msgstr "Otwórz konfigurację" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -223,31 +236,31 @@ msgstr "Pozostaw puste by przywrócić domyślny tytuł." msgid "OK" msgstr "OK" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Nowy podział" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Zobacz otwarte karty" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Menu główne" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Paleta komend" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Inspektor terminala" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "O Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Zamknij" @@ -255,18 +268,30 @@ msgstr "Zamknij" msgid "Execute a command…" msgstr "Wykonaj komendę…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -327,46 +352,754 @@ msgstr "Wszystkie sesje terminala w obecnym oknie zostaną zakończone." msgid "The currently running process in this split will be terminated." msgstr "Wszyskie trwające procesy w obecnym podziale zostaną zakończone." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Komenda zakończona" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Komenda wykonana pomyślnie" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Komenda nie powiodła się" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Komenda wykonana pomyślnie" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Komenda nie powiodła się" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Zmień tytuł terminala" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Zmień tytuł karty" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Przeładowano konfigurację" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Skopiowano do schowka" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Wyczyszczono schowek" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Twórcy Ghostty" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/pt_BR.po b/po/pt_BR.po index cc748cca7..204d7a107 100644 --- a/po/pt_BR.po +++ b/po/pt_BR.po @@ -10,7 +10,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-18 10:50-0300\n" "Last-Translator: Guilherme Tiscoski \n" "Language-Team: Brazilian Portuguese \n" "Language-Team: Russian \n" @@ -50,11 +50,12 @@ msgstr "Перезагрузите конфигурацию, чтобы снов #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Отмена" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Закрыть" @@ -63,7 +64,7 @@ msgstr "Закрыть" msgid "Configuration Errors" msgstr "Ошибки конфигурации" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -71,12 +72,12 @@ msgstr "" "В конфигурации обнаружены перечисленные ниже ошибки. При необходимости " "исправьте их, а затем перезагрузите конфигурацию." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Игнорировать" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Перезагрузить конфигурацию" @@ -96,23 +97,23 @@ msgstr "Ghostty: инспектор терминала" msgid "Find…" msgstr "Найти…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Предыдущий результат" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Следующий результат" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Ой!" -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Не удалось получить доступ к контексту OpenGL для отрисовки." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -122,99 +123,111 @@ msgstr "" "прокручивать и выделять, но запущенное приложение не будет получать события " "ввода." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Режим только для чтения" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Копировать" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Вставить" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Сообщить о завершении следующей команды" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Очистить" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Сброс" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Сплит" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Переименовать…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Сплит вверх" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Сплит вниз" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Сплит влево" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Сплит вправо" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Вкладка" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Переименовать вкладку…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Новая вкладка" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Закрыть вкладку" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Окно" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Новое окно" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Закрыть окно" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Конфигурация" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -224,31 +237,31 @@ msgstr "Оставьте поле пустым, чтобы вернуть наз msgid "OK" msgstr "ОК" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Новый сплит" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Просмотреть открытые вкладки" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Главное меню" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Палитра команд" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Инспектор терминала" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "О Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Выход" @@ -256,18 +269,30 @@ msgstr "Выход" msgid "Execute a command…" msgstr "Выполнить команду…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -328,46 +353,754 @@ msgstr "Все сессии терминала в этом окне будут msgid "The currently running process in this split will be terminated." msgstr "Процесс, работающий в этой сплит-области, будет остановлен." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Команда завершилась" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Команда выполнена успешно" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Команда завершилась с ошибкой" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Команда выполнена успешно" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Команда завершилась с ошибкой" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Переименовать терминал" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Переименовать вкладку" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Конфигурация перезагружена" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Скопировано в буфер обмена" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Буфер обмена очищен" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Разработчики Ghostty" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/tr.po b/po/tr.po index 2cb95b3bd..d858522fd 100644 --- a/po/tr.po +++ b/po/tr.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-09 22:18+0300\n" "Last-Translator: Emir SARI \n" "Language-Team: Turkish\n" @@ -48,11 +48,12 @@ msgstr "Bu istemi tekrar göstermek için yapılandırmayı yeniden yükle" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "İptal" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Kapat" @@ -61,7 +62,7 @@ msgstr "Kapat" msgid "Configuration Errors" msgstr "Yapılandırma Hataları" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -70,12 +71,12 @@ msgstr "" "gözden geçirin ve ardından ya yapılandırmanızı yeniden yükleyin ya da bu " "hataları yok sayın." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Yok Say" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Yapılandırmayı Yeniden Yükle" @@ -95,23 +96,23 @@ msgstr "Ghostty: Uçbirim Denetçisi" msgid "Find…" msgstr "Bul…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Önceki Eşleşme" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Sonraki Eşleşme" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Hayır, olamaz." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Görüntü oluşturma işlemi için OpenGL bağlamı elde edilemiyor." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -121,99 +122,111 @@ msgstr "" "kaydırabilirsiniz; ancak çalışan uygulamaya hiçbir giriş olayı " "gönderilmeyecektir." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Salt Okunur" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Kopyala" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Yapıştır" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Sonraki Komut Bittiğinde Bildir" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Temizle" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Sıfırla" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Böl" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Başlığı Değiştir…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Yukarı Doğru Böl" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Aşağı Doğru Böl" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Sola Doğru Böl" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Sağa Doğru Böl" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Sekme" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Sekme Başlığını Değiştir…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Yeni Sekme" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Sekmeyi Kapat" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Pencere" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Yeni Pencere" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Pencereyi Kapat" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Yapılandırma" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -msgstr "Yapılandırmayı Aç" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -223,31 +236,31 @@ msgstr "Öntanımlı başlığı geri yüklemek için boş bırakın." msgid "OK" msgstr "Tamam" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Yeni Bölme" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Açık Sekmeleri Görüntüle" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Ana Menü" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Komut Paleti" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Uçbirim Denetçisi" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Ghostty Hakkında" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Çık" @@ -255,18 +268,30 @@ msgstr "Çık" msgid "Execute a command…" msgstr "Bir komut çalıştır…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -327,46 +352,754 @@ msgstr "Bu penceredeki tüm uçbirim oturumları sonlandırılacaktır." msgid "The currently running process in this split will be terminated." msgstr "Bu bölmedeki şu anda çalışan süreç sonlandırılacaktır." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Komut Bitti" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Komut Başarılı" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Komut Başarısız" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Komut başarılı oldu" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Komut başarısız oldu" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Uçbirim Başlığını Değiştir" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Sekme Başlığını Değiştir" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Yapılandırma yeniden yüklendi" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Panoya kopyalandı" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Pano temizlendi" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Ghostty Geliştiricileri" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/uk.po b/po/uk.po index 1d7e91192..12372e266 100644 --- a/po/uk.po +++ b/po/uk.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-18 13:14+0100\n" "Last-Translator: Volodymyr Chernetskyi " "<19735328+chernetskyi@users.noreply.github.com>\n" @@ -50,11 +50,12 @@ msgstr "Перезавантажте налаштування, щоб показ #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Скасувати" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Закрити" @@ -63,7 +64,7 @@ msgstr "Закрити" msgid "Configuration Errors" msgstr "Помилки налаштування" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -71,12 +72,12 @@ msgstr "" "Виявлено одну або декілька помилок налаштування. Будь ласка, перегляньте " "помилки нижче і або перезавантажте налаштування, або проігноруйте ці помилки." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Ігнорувати" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Перезавантажити налаштування" @@ -95,23 +96,23 @@ msgstr "Ghostty: Інспектор терміналу" msgid "Find…" msgstr "Знайти…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Попередній збіг" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Наступний збіг" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Халепа." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Не вдалося отримати контекст OpenGL для відображення." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -120,99 +121,111 @@ msgstr "" "Цей термінал працює в режимі читання. Можна переглядати, виділяти і гортати " "вміст, але ввід не буде передано до запущеної програми." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Тільки для читання" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Скопіювати" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Вставити" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Сповістити про завершення наступної команди" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Очистити" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Скинути" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Панель" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Змінити заголовок…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Нова панель зверху" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Нова панель знизу" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Нова панель ліворуч" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Нова панель праворуч" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Вкладка" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Змінити заголовок вкладки…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Нова вкладка" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Закрити вкладку" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Вікно" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Нове вікно" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Закрити вікно" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Налаштування" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -222,31 +235,31 @@ msgstr "Залиште порожнім, щоб відновити заголо msgid "OK" msgstr "ОК" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Нова панель" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Переглянути відкриті вкладки" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Головне меню" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Палітра команд" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Інспектор терміналу" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Про Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Завершити" @@ -254,18 +267,30 @@ msgstr "Завершити" msgid "Execute a command…" msgstr "Виконати команду…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -326,46 +351,754 @@ msgstr "Всі сесії терміналу в цьому вікні будут msgid "The currently running process in this split will be terminated." msgstr "Процес, що виконується в цій панелі, буде завершено." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Команда завершилась" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Команда завершилась успішно" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Команда завершилась з помилкою" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Команда завершилась успішно" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Команда завершилась з помилкою" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Змінити заголовок терміналу" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Змінити заголовок вкладки" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Налаштування перезавантажено" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Скопійовано до буферa обміну" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Буфер обміну очищено" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Розробники Ghostty" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/vi.po b/po/vi.po index af02cc20d..6e621866a 100644 --- a/po/vi.po +++ b/po/vi.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-03-04 09:32+0700\n" "Last-Translator: Anh Thang \n" "Language-Team: Vietnamese \n" @@ -48,11 +48,12 @@ msgstr "Tải lại cấu hình để hiển thị lại thông báo này" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "Hủy" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "Đóng" @@ -61,7 +62,7 @@ msgstr "Đóng" msgid "Configuration Errors" msgstr "Lỗi cấu hình" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -69,12 +70,12 @@ msgstr "" "Phát hiện một hoặc nhiều lỗi cấu hình. Vui lòng xem xét các lỗi bên dưới, " "sau đó tải lại cấu hình hoặc bỏ qua các lỗi này." -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "Bỏ qua" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "Tải lại cấu hình" @@ -94,23 +95,23 @@ msgstr "Ghostty: Bộ kiểm tra Terminal" msgid "Find…" msgstr "Tìm kiếm…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "Kết quả trước" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "Kết quả tiếp theo" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "Ôi hỏng rồi." -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "Không thể lấy ngữ cảnh OpenGL để kết xuất đồ họa." -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -119,99 +120,111 @@ msgstr "" "Terminal này đang ở chế độ chỉ đọc. Bạn vẫn có thể xem, chọn và cuộn nội " "dung, nhưng các sự kiện nhập liệu sẽ không được gửi đến ứng dụng." -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "Chỉ đọc" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "Sao chép" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "Dán" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "Thông báo khi lệnh tiếp theo kết thúc" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "Xóa" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "Đặt lại" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "Chia màn hình" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "Đổi tiêu đề…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "Chia lên trên" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "Chia xuống dưới" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "Chia sang trái" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "Chia sang phải" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "Tab" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "Đổi tiêu đề Tab…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "Tab mới" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "Đóng Tab" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "Cửa sổ" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "Cửa sổ mới" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "Đóng cửa sổ" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "Cấu hình" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -msgstr "Mở tệp cấu hình" +#: 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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -221,31 +234,31 @@ msgstr "Để trống để khôi phục tiêu đề mặc định." msgid "OK" msgstr "Đồng ý" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "Chia màn hình mới" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "Xem các Tab đang mở" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "Trình đơn chính" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "Bảng lệnh" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "Bộ kiểm tra Terminal" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "Về Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "Thoát" @@ -253,18 +266,30 @@ msgstr "Thoát" msgid "Execute a command…" msgstr "Chạy một lệnh…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -325,46 +350,754 @@ msgstr "Tất cả các phiên làm việc terminal trong cửa sổ này sẽ b msgid "The currently running process in this split will be terminated." msgstr "Tiến trình đang chạy trong phần chia này sẽ bị chấm dứt." -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "Lệnh đã kết thúc" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "Lệnh thành công" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "Lệnh thất bại" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "Lệnh thành công" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "Lệnh thất bại" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "Đổi tiêu đề Terminal" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "Đổi tiêu đề Tab" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "Đã tải lại cấu hình" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "Đã sao chép vào bảng tạm" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "Đã xóa sạch bảng tạm" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Các nhà phát triển Ghostty" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/zh_CN.po b/po/zh_CN.po index c26331e1a..1abf16912 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-12 01:56+0800\n" "Last-Translator: Leah \n" "Language-Team: Chinese (simplified) \n" @@ -49,11 +49,12 @@ msgstr "本提示将在重载配置后再次出现" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "取消" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "关闭" @@ -62,19 +63,19 @@ msgstr "关闭" msgid "Configuration Errors" msgstr "配置错误" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." msgstr "" "加载配置时发现了以下错误。请仔细阅读错误信息,并选择忽略或重新加载配置文件。" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "忽略" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "重新加载配置" @@ -92,23 +93,23 @@ msgstr "Ghostty 终端调试器" msgid "Find…" msgstr "查找…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "上一个匹配项" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "下一个匹配项" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "糟糕。" -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "未能获取可用于渲染的 OpenGL 环境。" -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -117,99 +118,111 @@ msgstr "" "本终端当前处于只读模式。你仍可浏览、选择、并滚动其中内容,但任何用户输入都不" "会传给运行中的程序。" -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "只读" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "复制" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "粘贴" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "下条命令完成时发出提醒" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "清除屏幕" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "重置终端" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "分屏" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "更改标题…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "向上分屏" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "向下分屏" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "向左分屏" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "向右分屏" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "关闭分屏" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "标签页" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "更改标签页标题…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "新建标签页" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "关闭标签页" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "窗口" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "新建窗口" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "关闭窗口" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "配置" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -219,31 +232,31 @@ msgstr "留空以重置至默认标题。" msgid "OK" msgstr "确认" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "新建分屏" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "浏览标签页" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "主菜单" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "命令面板" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "终端调试器" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "关于 Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "退出" @@ -251,18 +264,30 @@ msgstr "退出" msgid "Execute a command…" msgstr "选择要执行的命令…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -317,46 +342,754 @@ msgstr "窗口内所有运行中的进程将被终止。" msgid "The currently running process in this split will be terminated." msgstr "分屏内正在运行中的进程将被终止。" -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "命令已完成" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "命令执行成功" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "命令执行失败" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "命令执行成功" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "命令执行失败" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "更改终端标题" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "更改标签页标题" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "已重新加载配置" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "已复制至剪贴板" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "已清空剪贴板" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Ghostty 开发团队" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/po/zh_TW.po b/po/zh_TW.po index 42e63aece..ce39415d9 100644 --- a/po/zh_TW.po +++ b/po/zh_TW.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-07-26 04:59+0200\n" +"POT-Creation-Date: 2026-08-10 10:06-0500\n" "PO-Revision-Date: 2026-02-18 13:58+0800\n" "Last-Translator: Yi-Jyun Pan \n" "Language-Team: Chinese (traditional)\n" @@ -48,11 +48,12 @@ msgstr "重新載入設定以再次顯示此提示" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 #: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +#: src/apprt/gtk/class/application.zig:2263 msgid "Cancel" msgstr "取消" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 -#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:90 #: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 msgid "Close" msgstr "關閉" @@ -61,18 +62,18 @@ msgstr "關閉" msgid "Configuration Errors" msgstr "設定錯誤" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:8 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." msgstr "發現有設定錯誤。請檢視以下錯誤,並重新載入設定或忽略這些錯誤。" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:12 msgid "Ignore" msgstr "忽略" -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:373 src/apprt/gtk/ui/1.5/window.blp:300 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:13 +#: src/apprt/gtk/ui/1.2/surface.blp:439 src/apprt/gtk/ui/1.5/window.blp:318 msgid "Reload Configuration" msgstr "重新載入設定" @@ -90,23 +91,23 @@ msgstr "Ghostty:終端機檢查工具" msgid "Find…" msgstr "尋找…" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:69 msgid "Previous Match" msgstr "上一筆符合" -#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:79 msgid "Next Match" msgstr "下一筆符合" -#: src/apprt/gtk/ui/1.2/surface.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Oh, no." msgstr "噢不。" -#: src/apprt/gtk/ui/1.2/surface.blp:7 +#: src/apprt/gtk/ui/1.2/surface.blp:8 msgid "Unable to acquire an OpenGL context for rendering." msgstr "無法取得用於算繪的 OpenGL 上下文。" -#: src/apprt/gtk/ui/1.2/surface.blp:99 +#: src/apprt/gtk/ui/1.2/surface.blp:101 msgid "" "This terminal is in read-only mode. You can still view, select, and scroll " "through the content, but no input events will be sent to the running " @@ -115,99 +116,111 @@ msgstr "" "本終端機目前處於唯讀模式。您仍可查看、選取及捲動內容,但不會傳送任何輸入事件" "至執行中的應用程式。" -#: src/apprt/gtk/ui/1.2/surface.blp:109 +#: src/apprt/gtk/ui/1.2/surface.blp:112 msgid "Read-only" msgstr "唯讀" -#: src/apprt/gtk/ui/1.2/surface.blp:262 src/apprt/gtk/ui/1.5/window.blp:200 +#: src/apprt/gtk/ui/1.2/surface.blp:316 src/apprt/gtk/ui/1.5/window.blp:203 msgid "Copy" msgstr "複製" -#: src/apprt/gtk/ui/1.2/surface.blp:267 src/apprt/gtk/ui/1.5/window.blp:205 +#: src/apprt/gtk/ui/1.2/surface.blp:321 src/apprt/gtk/ui/1.5/window.blp:208 msgid "Paste" msgstr "貼上" -#: src/apprt/gtk/ui/1.2/surface.blp:272 +#: src/apprt/gtk/ui/1.2/surface.blp:326 msgid "Notify on Next Command Finish" msgstr "下個命令完成時通知" -#: src/apprt/gtk/ui/1.2/surface.blp:279 src/apprt/gtk/ui/1.5/window.blp:273 +#: src/apprt/gtk/ui/1.2/surface.blp:333 src/apprt/gtk/ui/1.5/window.blp:281 msgid "Clear" msgstr "清除" -#: src/apprt/gtk/ui/1.2/surface.blp:284 src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.2/surface.blp:338 src/apprt/gtk/ui/1.5/window.blp:286 msgid "Reset" msgstr "重設" -#: src/apprt/gtk/ui/1.2/surface.blp:291 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split" msgstr "分割" -#: src/apprt/gtk/ui/1.2/surface.blp:294 src/apprt/gtk/ui/1.5/window.blp:245 +#: src/apprt/gtk/ui/1.2/surface.blp:348 src/apprt/gtk/ui/1.5/window.blp:253 msgid "Change Title…" msgstr "變更標題…" -#: src/apprt/gtk/ui/1.2/surface.blp:299 src/apprt/gtk/ui/1.5/window.blp:177 -#: src/apprt/gtk/ui/1.5/window.blp:250 +#: src/apprt/gtk/ui/1.2/surface.blp:353 src/apprt/gtk/ui/1.5/window.blp:180 +#: src/apprt/gtk/ui/1.5/window.blp:258 src/input/command.zig:487 msgid "Split Up" msgstr "向上分割" -#: src/apprt/gtk/ui/1.2/surface.blp:305 src/apprt/gtk/ui/1.5/window.blp:182 -#: src/apprt/gtk/ui/1.5/window.blp:255 +#: src/apprt/gtk/ui/1.2/surface.blp:359 src/apprt/gtk/ui/1.5/window.blp:185 +#: src/apprt/gtk/ui/1.5/window.blp:263 src/input/command.zig:492 msgid "Split Down" msgstr "向下分割" -#: src/apprt/gtk/ui/1.2/surface.blp:311 src/apprt/gtk/ui/1.5/window.blp:187 -#: src/apprt/gtk/ui/1.5/window.blp:260 +#: src/apprt/gtk/ui/1.2/surface.blp:365 src/apprt/gtk/ui/1.5/window.blp:190 +#: src/apprt/gtk/ui/1.5/window.blp:268 src/input/command.zig:477 msgid "Split Left" msgstr "向左分割" -#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:192 -#: src/apprt/gtk/ui/1.5/window.blp:265 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:195 +#: src/apprt/gtk/ui/1.5/window.blp:273 src/input/command.zig:482 msgid "Split Right" msgstr "向右分割" -#: src/apprt/gtk/ui/1.2/surface.blp:323 +#: src/apprt/gtk/ui/1.2/surface.blp:377 msgid "Close Split" msgstr "" -#: src/apprt/gtk/ui/1.2/surface.blp:329 +#: src/apprt/gtk/ui/1.2/surface.blp:383 msgid "Tab" msgstr "分頁" -#: src/apprt/gtk/ui/1.2/surface.blp:332 src/apprt/gtk/ui/1.5/window.blp:224 -#: src/apprt/gtk/ui/1.5/window.blp:320 +#: src/apprt/gtk/ui/1.2/surface.blp:386 src/apprt/gtk/ui/1.5/window.blp:232 +#: src/apprt/gtk/ui/1.5/window.blp:339 src/input/command.zig:464 msgid "Change Tab Title…" msgstr "變更分頁標題…" -#: src/apprt/gtk/ui/1.2/surface.blp:337 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +#: src/apprt/gtk/ui/1.2/surface.blp:391 src/apprt/gtk/ui/1.5/window.blp:60 +#: src/apprt/gtk/ui/1.5/window.blp:110 src/apprt/gtk/ui/1.5/window.blp:237 +#: src/input/command.zig:427 msgid "New Tab" msgstr "開新分頁" -#: src/apprt/gtk/ui/1.2/surface.blp:342 src/apprt/gtk/ui/1.5/window.blp:234 +#: src/apprt/gtk/ui/1.2/surface.blp:396 src/apprt/gtk/ui/1.5/window.blp:242 +#: src/input/command.zig:613 msgid "Close Tab" msgstr "關閉分頁" -#: src/apprt/gtk/ui/1.2/surface.blp:349 +#: src/apprt/gtk/ui/1.2/surface.blp:403 msgid "Window" msgstr "視窗" -#: src/apprt/gtk/ui/1.2/surface.blp:352 src/apprt/gtk/ui/1.5/window.blp:212 +#: src/apprt/gtk/ui/1.2/surface.blp:406 src/apprt/gtk/ui/1.5/window.blp:215 +msgid "Change Window Title…" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:411 src/apprt/gtk/ui/1.5/window.blp:220 +#: src/input/command.zig:421 msgid "New Window" msgstr "開新視窗" -#: src/apprt/gtk/ui/1.2/surface.blp:357 src/apprt/gtk/ui/1.5/window.blp:217 +#: src/apprt/gtk/ui/1.2/surface.blp:416 src/apprt/gtk/ui/1.5/window.blp:225 +#: src/input/command.zig:630 msgid "Close Window" msgstr "關閉視窗" -#: src/apprt/gtk/ui/1.2/surface.blp:365 +#: src/apprt/gtk/ui/1.2/surface.blp:424 src/apprt/gtk/ui/1.5/window.blp:303 msgid "Config" msgstr "設定" -#: src/apprt/gtk/ui/1.2/surface.blp:368 src/apprt/gtk/ui/1.5/window.blp:295 -msgid "Open Configuration" -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 "" + +#: 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 "" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." @@ -217,31 +230,31 @@ msgstr "留空即可還原為預設標題。" msgid "OK" msgstr "確定" -#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +#: src/apprt/gtk/ui/1.5/window.blp:61 src/apprt/gtk/ui/1.5/window.blp:111 msgid "New Split" msgstr "新增窗格" -#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +#: src/apprt/gtk/ui/1.5/window.blp:71 src/apprt/gtk/ui/1.5/window.blp:129 msgid "View Open Tabs" msgstr "檢視已開啟的分頁" -#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +#: src/apprt/gtk/ui/1.5/window.blp:81 src/apprt/gtk/ui/1.5/window.blp:143 msgid "Main Menu" msgstr "主選單" -#: src/apprt/gtk/ui/1.5/window.blp:285 +#: src/apprt/gtk/ui/1.5/window.blp:293 msgid "Command Palette" msgstr "命令面板" -#: src/apprt/gtk/ui/1.5/window.blp:290 +#: src/apprt/gtk/ui/1.5/window.blp:298 msgid "Terminal Inspector" msgstr "終端機檢查工具" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1866 +#: src/apprt/gtk/ui/1.5/window.blp:326 src/apprt/gtk/class/window.zig:1991 msgid "About Ghostty" msgstr "關於 Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:312 +#: src/apprt/gtk/ui/1.5/window.blp:331 src/input/command.zig:702 msgid "Quit" msgstr "結束" @@ -249,18 +262,30 @@ msgstr "結束" msgid "Execute a command…" msgstr "執行命令…" -#: src/apprt/gtk/class/application.zig:1708 +#: src/apprt/gtk/class/application.zig:1767 msgid "The keybind was revoked by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1710 +#: src/apprt/gtk/class/application.zig:1769 msgid "The keybind was denied by the system." msgstr "" -#: src/apprt/gtk/class/application.zig:1721 +#: src/apprt/gtk/class/application.zig:1780 msgid "Global keybind unavailable" msgstr "" +#: src/apprt/gtk/class/application.zig:2259 +msgid "Export Terminal IO Events" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2262 +msgid "Export" +msgstr "" + +#: src/apprt/gtk/class/application.zig:2783 +msgid "Editing configuration file" +msgstr "" + #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " @@ -315,46 +340,754 @@ msgstr "此視窗中的所有終端機工作階段都將被終止。" msgid "The currently running process in this split will be terminated." msgstr "此窗格中目前執行的處理程序將被終止。" -#: src/apprt/gtk/class/surface.zig:1170 +#: src/apprt/gtk/class/surface.zig:1181 msgid "Command Finished" msgstr "命令執行完成" -#: src/apprt/gtk/class/surface.zig:1171 +#: src/apprt/gtk/class/surface.zig:1182 +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command Succeeded" msgstr "命令執行成功" -#: src/apprt/gtk/class/surface.zig:1172 +#: src/apprt/gtk/class/surface.zig:1183 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command Failed" msgstr "命令執行失敗" -#: src/apprt/gtk/class/surface_child_exited.zig:109 -msgid "Command succeeded" -msgstr "命令執行成功" - -#: src/apprt/gtk/class/surface_child_exited.zig:113 -msgid "Command failed" -msgstr "命令執行失敗" - -#: src/apprt/gtk/class/title_dialog.zig:225 +#: src/apprt/gtk/class/title_dialog.zig:227 msgid "Change Terminal Title" msgstr "變更終端機標題" -#: src/apprt/gtk/class/title_dialog.zig:226 +#: src/apprt/gtk/class/title_dialog.zig:228 msgid "Change Tab Title" msgstr "變更分頁標題" -#: src/apprt/gtk/class/window.zig:1102 +#: src/apprt/gtk/class/title_dialog.zig:229 +msgid "Change Window Title" +msgstr "" + +#: src/apprt/gtk/class/window.zig:1153 msgid "Reloaded the configuration" msgstr "已重新載入設定" -#: src/apprt/gtk/class/window.zig:1705 +#: src/apprt/gtk/class/window.zig:1830 msgid "Copied to clipboard" msgstr "已複製到剪貼簿" -#: src/apprt/gtk/class/window.zig:1707 +#: src/apprt/gtk/class/window.zig:1832 msgid "Cleared clipboard" msgstr "已清除剪貼簿" -#: src/apprt/gtk/class/window.zig:1847 +#: src/apprt/gtk/class/window.zig:1972 msgid "Ghostty Developers" msgstr "Ghostty 開發者" + +#: src/input/command.zig:149 +msgid "Reset Terminal" +msgstr "" + +#: src/input/command.zig:150 +msgid "Reset the terminal to a clean state." +msgstr "" + +#: src/input/command.zig:155 +msgid "Copy to Clipboard" +msgstr "" + +#: src/input/command.zig:156 +msgid "" +"Copy the selected text to the clipboard in both plain and styled formats." +msgstr "" + +#: src/input/command.zig:159 +msgid "Copy Selection as Plain Text to Clipboard" +msgstr "" + +#: src/input/command.zig:160 +msgid "Copy the selected text as plain text to the clipboard." +msgstr "" + +#: src/input/command.zig:163 +msgid "Copy Selection as ANSI Sequences to Clipboard" +msgstr "" + +#: src/input/command.zig:164 +msgid "Copy the selected text as ANSI escape sequences to the clipboard." +msgstr "" + +#: src/input/command.zig:167 +msgid "Copy Selection as HTML to Clipboard" +msgstr "" + +#: src/input/command.zig:168 +msgid "Copy the selected text as HTML to the clipboard." +msgstr "" + +#: src/input/command.zig:173 +msgid "Copy URL to Clipboard" +msgstr "" + +#: src/input/command.zig:174 +msgid "Copy the URL under the cursor to the clipboard." +msgstr "" + +#: src/input/command.zig:179 +msgid "Copy Terminal Title to Clipboard" +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 "" + +#: src/input/command.zig:185 +msgid "Paste from Clipboard" +msgstr "" + +#: src/input/command.zig:186 +msgid "Paste the contents of the main clipboard." +msgstr "" + +#: src/input/command.zig:191 +msgid "Paste from Selection" +msgstr "" + +#: src/input/command.zig:192 +msgid "Paste the contents of the selection clipboard." +msgstr "" + +#: src/input/command.zig:197 +msgid "Start Search" +msgstr "" + +#: src/input/command.zig:198 +msgid "Start a search if one isn't already active." +msgstr "" + +#: src/input/command.zig:203 +msgid "Search Selection" +msgstr "" + +#: src/input/command.zig:204 +msgid "Start a search for the current text selection." +msgstr "" + +#: src/input/command.zig:209 +msgid "End Search" +msgstr "" + +#: src/input/command.zig:210 +msgid "End the current search if any and hide any GUI elements." +msgstr "" + +#: src/input/command.zig:215 +msgid "Next Search Result" +msgstr "" + +#: src/input/command.zig:216 +msgid "Navigate to the next search result, if any." +msgstr "" + +#: src/input/command.zig:219 +msgid "Previous Search Result" +msgstr "" + +#: src/input/command.zig:220 +msgid "Navigate to the previous search result, if any." +msgstr "" + +#: src/input/command.zig:225 +msgid "Increase Font Size" +msgstr "" + +#: src/input/command.zig:226 +msgid "Increase the font size by 1 point." +msgstr "" + +#: src/input/command.zig:231 +msgid "Decrease Font Size" +msgstr "" + +#: src/input/command.zig:232 +msgid "Decrease the font size by 1 point." +msgstr "" + +#: src/input/command.zig:237 +msgid "Reset Font Size" +msgstr "" + +#: src/input/command.zig:238 +msgid "Reset the font size to the default." +msgstr "" + +#: src/input/command.zig:243 +msgid "Clear Screen" +msgstr "" + +#: src/input/command.zig:244 +msgid "Clear the screen and scrollback." +msgstr "" + +#: src/input/command.zig:249 +msgid "Select All" +msgstr "" + +#: src/input/command.zig:250 +msgid "Select all text on the screen." +msgstr "" + +#: src/input/command.zig:255 +msgid "Scroll to Top" +msgstr "" + +#: src/input/command.zig:256 +msgid "Scroll to the top of the screen." +msgstr "" + +#: src/input/command.zig:261 +msgid "Scroll to Bottom" +msgstr "" + +#: src/input/command.zig:262 +msgid "Scroll to the bottom of the screen." +msgstr "" + +#: src/input/command.zig:267 +msgid "Scroll to Selection" +msgstr "" + +#: src/input/command.zig:268 +msgid "Scroll to the selected text." +msgstr "" + +#: src/input/command.zig:273 +msgid "Scroll Page Up" +msgstr "" + +#: src/input/command.zig:274 +msgid "Scroll the screen up by a page." +msgstr "" + +#: src/input/command.zig:279 +msgid "Scroll Page Down" +msgstr "" + +#: src/input/command.zig:280 +msgid "Scroll the screen down by a page." +msgstr "" + +#: src/input/command.zig:286 +msgid "Copy Screen to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:287 +msgid "" +"Copy the screen contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:291 +msgid "Copy Screen to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:292 +msgid "" +"Copy the screen contents to a temporary file and paste the path to the file." +msgstr "" + +#: src/input/command.zig:296 +msgid "Copy Screen to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:297 +msgid "Copy the screen contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:305 +msgid "Copy Screen as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:306 +msgid "" +"Copy the screen contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:313 +msgid "Copy Screen as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:314 +msgid "" +"Copy the screen contents as HTML to a temporary file and paste the path to " +"the file." +msgstr "" + +#: src/input/command.zig:321 +msgid "Copy Screen as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:322 +msgid "Copy the screen contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:330 +msgid "Copy Screen as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:338 +msgid "Copy Screen as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:346 +msgid "Copy Screen as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:347 +msgid "" +"Copy the screen contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:354 +msgid "Copy Selection to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:355 +msgid "" +"Copy the selection contents to a temporary file and copy the path to the " +"clipboard." +msgstr "" + +#: src/input/command.zig:359 +msgid "Copy Selection to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:360 +msgid "" +"Copy the selection contents to a temporary file and paste the path to the " +"file." +msgstr "" + +#: src/input/command.zig:364 +msgid "Copy Selection to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:365 +msgid "Copy the selection contents to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:373 +msgid "Copy Selection as HTML to Temporary File and Copy Path" +msgstr "" + +#: src/input/command.zig:374 +msgid "" +"Copy the selection contents as HTML to a temporary file and copy the path to " +"the clipboard." +msgstr "" + +#: src/input/command.zig:381 +msgid "Copy Selection as HTML to Temporary File and Paste Path" +msgstr "" + +#: src/input/command.zig:382 +msgid "" +"Copy the selection contents as HTML to a temporary file and paste the path " +"to the file." +msgstr "" + +#: src/input/command.zig:389 +msgid "Copy Selection as HTML to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:390 +msgid "Copy the selection contents as HTML to a temporary file and open it." +msgstr "" + +#: src/input/command.zig:398 +msgid "Copy Selection as ANSI Sequences to Temporary File and Copy Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:406 +msgid "Copy Selection as ANSI Sequences to Temporary File and Paste Path" +msgstr "" + +#: 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 "" + +#: src/input/command.zig:414 +msgid "Copy Selection as ANSI Sequences to Temporary File and Open" +msgstr "" + +#: src/input/command.zig:415 +msgid "" +"Copy the selection contents as ANSI escape sequences to a temporary file and " +"open it." +msgstr "" + +#: src/input/command.zig:422 +msgid "Open a new window." +msgstr "" + +#: src/input/command.zig:428 +msgid "Open a new tab." +msgstr "" + +#: src/input/command.zig:434 +msgid "Move Tab Left" +msgstr "" + +#: src/input/command.zig:435 +msgid "Move the current tab to the left." +msgstr "" + +#: src/input/command.zig:439 +msgid "Move Tab Right" +msgstr "" + +#: src/input/command.zig:440 +msgid "Move the current tab to the right." +msgstr "" + +#: src/input/command.zig:446 +msgid "Move Tab to New Window" +msgstr "" + +#: src/input/command.zig:447 +msgid "Move the current tab to a new window." +msgstr "" + +#: src/input/command.zig:452 +msgid "Toggle Tab Overview" +msgstr "" + +#: src/input/command.zig:453 +msgid "Toggle the tab overview." +msgstr "" + +#: src/input/command.zig:458 +msgid "Change Terminal Title…" +msgstr "" + +#: src/input/command.zig:459 +msgid "Prompt for a new title for the current terminal." +msgstr "" + +#: src/input/command.zig:465 +msgid "Prompt for a new title for the current tab." +msgstr "" + +#: src/input/command.zig:478 +msgid "Split the terminal to the left." +msgstr "" + +#: src/input/command.zig:483 +msgid "Split the terminal to the right." +msgstr "" + +#: src/input/command.zig:488 +msgid "Split the terminal up." +msgstr "" + +#: src/input/command.zig:493 +msgid "Split the terminal down." +msgstr "" + +#: src/input/command.zig:500 +msgid "Focus Split: Previous" +msgstr "" + +#: src/input/command.zig:501 +msgid "Focus the previous split, if any." +msgstr "" + +#: src/input/command.zig:505 +msgid "Focus Split: Next" +msgstr "" + +#: src/input/command.zig:506 +msgid "Focus the next split, if any." +msgstr "" + +#: src/input/command.zig:510 +msgid "Focus Split: Left" +msgstr "" + +#: src/input/command.zig:511 +msgid "Focus the split to the left, if it exists." +msgstr "" + +#: src/input/command.zig:515 +msgid "Focus Split: Right" +msgstr "" + +#: src/input/command.zig:516 +msgid "Focus the split to the right, if it exists." +msgstr "" + +#: src/input/command.zig:520 +msgid "Focus Split: Up" +msgstr "" + +#: src/input/command.zig:521 +msgid "Focus the split above, if it exists." +msgstr "" + +#: src/input/command.zig:525 +msgid "Focus Split: Down" +msgstr "" + +#: src/input/command.zig:526 +msgid "Focus the split below, if it exists." +msgstr "" + +#: src/input/command.zig:533 +msgid "Focus Window: Previous" +msgstr "" + +#: src/input/command.zig:534 +msgid "Focus the previous window, if any." +msgstr "" + +#: src/input/command.zig:538 +msgid "Focus Window: Next" +msgstr "" + +#: src/input/command.zig:539 +msgid "Focus the next window, if any." +msgstr "" + +#: src/input/command.zig:545 +msgid "Toggle Split Zoom" +msgstr "" + +#: src/input/command.zig:546 +msgid "Toggle the zoom state of the current split." +msgstr "" + +#: src/input/command.zig:551 +msgid "Toggle Read-Only Mode" +msgstr "" + +#: src/input/command.zig:552 +msgid "Toggle read-only mode for the current surface." +msgstr "" + +#: src/input/command.zig:557 +msgid "Equalize Splits" +msgstr "" + +#: src/input/command.zig:558 +msgid "Equalize the size of all splits." +msgstr "" + +#: src/input/command.zig:563 +msgid "Reset Window Size" +msgstr "" + +#: src/input/command.zig:564 +msgid "Reset the window size to the default." +msgstr "" + +#: src/input/command.zig:569 +msgid "Toggle Inspector" +msgstr "" + +#: src/input/command.zig:570 +msgid "Toggle the inspector." +msgstr "" + +#: src/input/command.zig:575 +msgid "Show the GTK Inspector" +msgstr "" + +#: src/input/command.zig:576 +msgid "Show the GTK inspector." +msgstr "" + +#: src/input/command.zig:581 +msgid "Show On-Screen Keyboard" +msgstr "" + +#: src/input/command.zig:582 +msgid "Show the on-screen keyboard if present." +msgstr "" + +#: src/input/command.zig:588 +msgid "Open Config Using OS editor" +msgstr "" + +#: src/input/command.zig:589 +msgid "Open the config file with the OS's default editor." +msgstr "" + +#: src/input/command.zig:593 +msgid "Open Config in New Terminal Window" +msgstr "" + +#: src/input/command.zig:594 +msgid "Open the config file in a new window using $EDITOR or $VISUAL." +msgstr "" + +#: src/input/command.zig:600 +msgid "Reload Config" +msgstr "" + +#: src/input/command.zig:601 +msgid "Reload the config file." +msgstr "" + +#: src/input/command.zig:606 +msgid "Close Terminal" +msgstr "" + +#: src/input/command.zig:607 +msgid "Close the current terminal." +msgstr "" + +#: src/input/command.zig:614 +msgid "Close the current tab." +msgstr "" + +#: src/input/command.zig:618 +msgid "Close Other Tabs" +msgstr "" + +#: src/input/command.zig:619 +msgid "Close all tabs in this window except the current one." +msgstr "" + +#: src/input/command.zig:623 +msgid "Close Tabs to the Right" +msgstr "" + +#: src/input/command.zig:624 +msgid "Close all tabs to the right of the current one." +msgstr "" + +#: src/input/command.zig:631 +msgid "Close the current window." +msgstr "" + +#: src/input/command.zig:636 +msgid "Close All Windows" +msgstr "" + +#: src/input/command.zig:637 +msgid "Close all windows." +msgstr "" + +#: src/input/command.zig:642 +msgid "Toggle Maximize" +msgstr "" + +#: src/input/command.zig:643 +msgid "Toggle the maximized state of the current window." +msgstr "" + +#: src/input/command.zig:648 +msgid "Toggle Fullscreen" +msgstr "" + +#: src/input/command.zig:649 +msgid "Toggle the fullscreen state of the current window." +msgstr "" + +#: src/input/command.zig:654 +msgid "Toggle Window Decorations" +msgstr "" + +#: src/input/command.zig:655 +msgid "Toggle the window decorations." +msgstr "" + +#: src/input/command.zig:660 +msgid "Toggle Float on Top" +msgstr "" + +#: src/input/command.zig:661 +msgid "Toggle the float on top state of the current window." +msgstr "" + +#: src/input/command.zig:666 +msgid "Toggle Secure Input" +msgstr "" + +#: src/input/command.zig:667 +msgid "Toggle secure input mode." +msgstr "" + +#: src/input/command.zig:672 +msgid "Toggle Mouse Reporting" +msgstr "" + +#: src/input/command.zig:673 +msgid "Toggle whether mouse events are reported to terminal applications." +msgstr "" + +#: src/input/command.zig:678 +msgid "Toggle Background Opacity" +msgstr "" + +#: src/input/command.zig:679 +msgid "Toggle the background opacity of a window that started transparent." +msgstr "" + +#: src/input/command.zig:684 +msgid "Check for Updates" +msgstr "" + +#: src/input/command.zig:685 +msgid "Check for updates to the application." +msgstr "" + +#: src/input/command.zig:690 +msgid "Undo" +msgstr "" + +#: src/input/command.zig:691 +msgid "Undo the last action." +msgstr "" + +#: src/input/command.zig:696 +msgid "Redo" +msgstr "" + +#: src/input/command.zig:697 +msgid "Redo the last undone action." +msgstr "" + +#: src/input/command.zig:703 +msgid "Quit the application." +msgstr "" + +#: src/input/command.zig:708 +msgid "Ghostty" +msgstr "" + +#: src/input/command.zig:709 +msgid "Put a little Ghostty in your terminal." +msgstr "" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 7bdbc9b48..1c6ec8bf5 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -52,7 +52,7 @@ parts: rm -rf $CRAFT_PART_SRC/* if [[ -n $arch ]]; then - curl -LO --retry-connrefused --retry 10 https://ziglang.org/download/0.15.2/zig-$arch-linux-0.15.2.tar.xz + curl -LO --retry-connrefused --retry 10 https://ziglang.org/download/0.16.0/zig-$arch-linux-0.16.0.tar.xz else echo "Unsupported arch" exit 1 @@ -61,6 +61,9 @@ parts: tar xf zig-$arch-lin*xz rm -f *xz mv zig-$arch-linux*/* . + + # TODO: remove after IPv6 resolv.conf resolution is fixed in Zig + ln --symbolic --force /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf prime: - -* diff --git a/src/App.zig b/src/App.zig index c87867ea9..2ee9f936c 100644 --- a/src/App.zig +++ b/src/App.zig @@ -78,6 +78,32 @@ pub fn create(alloc: Allocator) CreateError!*App { var app = try alloc.create(App); errdefer alloc.destroy(app); try app.init(alloc); + + // If font discovery supports warmup, then we call it. Some font + // mechanisms (e.g. CoreText) have a multi-millisecond one-time cost + // on startup. + if (comptime @hasDecl(font.Discover, "warmup")) { + if (std.Thread.spawn( + .{}, + font.Discover.warmup, + .{}, + )) |thr| thr.detach() else |err| { + log.warn("font warmup thread spawn failed err={}", .{err}); + } + } + + // Same for the renderer's graphics API (e.g. Metal), which pays + // one-time framework initialization costs on first use. + if (comptime @hasDecl(renderer.Renderer.API, "warmup")) { + if (std.Thread.spawn( + .{}, + renderer.Renderer.API.warmup, + .{}, + )) |thr| thr.detach() else |err| { + log.warn("renderer warmup thread spawn failed err={}", .{err}); + } + } + return app; } @@ -246,7 +272,15 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void { } } switch (message) { - .open_config => try self.performAction(rt_app, .open_config), + .open_config => |v| try self.performAction( + rt_app, + .{ + .open_config = switch (v) { + .os_open => .os_open, + .new_window => .new_window, + }, + }, + ), .new_window => |msg| try self.newWindow(rt_app, msg), .close => |surface| self.closeSurface(surface), .surface_message => |msg| try self.surfaceMessage(msg.surface, msg.message), @@ -425,7 +459,14 @@ pub fn performAction( .ignore => {}, .quit => _ = try rt_app.performAction(.app, .quit, {}), .new_window => _ = try self.newWindow(rt_app, .{ .parent = null }), - .open_config => _ = try rt_app.performAction(.app, .open_config, {}), + .open_config => |v| _ = try rt_app.performAction( + .app, + .open_config, + switch (v) { + .os_open => .os_open, + .new_window => .new_window, + }, + ), .reload_config => _ = try rt_app.performAction(.app, .reload_config, .{}), .close_all_windows => _ = try rt_app.performAction(.app, .close_all_windows, {}), .toggle_quick_terminal => _ = try rt_app.performAction(.app, .toggle_quick_terminal, {}), @@ -476,8 +517,8 @@ pub fn performAllAction( // are logged but processing continues. .surface => for (self.surfaces.items) |surface| { _ = surface.core().performBindingAction(action) catch |err| { - log.warn("error performing binding action on surface ptr={X} err={}", .{ - @intFromPtr(surface), + log.warn("error performing binding action on surface id={x} err={}", .{ + surface.core().id, err, }); }; @@ -527,7 +568,7 @@ fn hasRtSurface(self: *const App, surface: *apprt.Surface) bool { /// The message types that can be sent to the app thread. pub const Message = union(enum) { // Open the configuration file - open_config: void, + open_config: OpenConfig, /// Create a new terminal window. new_window: NewWindow, @@ -555,6 +596,13 @@ pub const Message = union(enum) { /// The parent surface parent: ?*Surface = null, }; + + pub const OpenConfig = enum { + /// Open the config in the OS default editor. + os_open, + /// Open the config in a new window using $EDITOR or $VISUAL + new_window, + }; }; /// Mailbox is the way that other threads send the app thread messages. diff --git a/src/Surface.zig b/src/Surface.zig index f494f6c82..7ef2038b8 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -334,6 +334,7 @@ const DerivedConfig = struct { title: ?[:0]const u8, title_report: bool, links: []DerivedConfig.Link, + link_osc8: bool, link_previews: configpkg.LinkPreviews, scroll_to_bottom: configpkg.Config.ScrollToBottom, notify_on_command_finish: configpkg.Config.NotifyOnCommandFinish, @@ -413,6 +414,7 @@ const DerivedConfig = struct { .title = config.title, .title_report = config.@"title-report", .links = links, + .link_osc8 = config.@"link-osc8", .link_previews = config.@"link-previews", .scroll_to_bottom = config.@"scroll-to-bottom", .notify_on_command_finish = config.@"notify-on-command-finish", @@ -841,7 +843,7 @@ pub fn deinit(self: *Surface) void { self.alloc.destroy(self.renderer_state.mutex); self.config.deinit(); - log.info("surface closed addr={x}", .{@intFromPtr(self)}); + log.info("surface closed id={x}", .{self.id}); } /// Close this surface. This will trigger the runtime to start the @@ -858,7 +860,7 @@ inline fn surfaceMailbox(self: *Surface) Mailbox { }; } -/// Queue a message for the IO thread. +/// Queue a message for the IO thread, taking ownership of `msg`. /// /// We centralize all our logic into this spot so we can intercept /// messages for example in readonly mode. @@ -873,7 +875,10 @@ fn queueIo( .write_small, .write_stable, .write_alloc, - => return, + => { + msg.deinit(); + return; + }, else => {}, } @@ -1068,9 +1073,10 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .pwd_change => |w| { defer w.deinit(); - // We always allocate for this because we need to null-terminate. - const str = try self.alloc.dupeZ(u8, w.slice()); - defer self.alloc.free(str); + var stack = std.heap.stackFallback(256, self.alloc); + const alloc = stack.get(); + const str = try alloc.dupeZ(u8, w.slice()); + defer alloc.free(str); _ = try self.rt_app.performAction( .{ .surface = self }, @@ -2821,11 +2827,11 @@ pub fn keyCallback( // an encoded value, we close the surface. We want to eventually // move this behavior to the apprt probably. if (self.child_exited) { + write_req.deinit(); self.close(); return .closed; } - errdefer write_req.deinit(); self.queueIo(switch (write_req) { .small => |v| .{ .write_small = v }, .stable => |v| .{ .write_stable = v }, @@ -2943,16 +2949,19 @@ fn maybeHandleBinding( // Determine if this entry has an action or if its a leader key. const leaf: input.Binding.Set.GenericLeaf = switch (entry.value_ptr.*) { .leader => |set| { - // Setup the next set we'll look at. - self.keyboard.sequence_set = set; - // Store this event so that we can drain and encode on invalid. // We don't need to cap this because it is naturally capped by // the config validation. if (try self.encodeKey(event, insp_ev)) |req| { - try self.keyboard.sequence_queued.append(self.alloc, req); + self.keyboard.sequence_queued.append(self.alloc, req) catch |err| { + req.deinit(); + return err; + }; } + // Setup the next set we'll look at only after all fallible work. + self.keyboard.sequence_set = set; + // Start or continue our key sequence _ = self.rt_app.performAction( .{ .surface = self }, @@ -3241,17 +3250,15 @@ fn encodeKey( ); defer alloc_writer.deinit(); - // This results in a double allocation but this is such an unlikely - // path the performance impact is unimportant. try input.key_encode.encode( &alloc_writer.writer, event, encoding_opts, ); - break :req try termio.Message.WriteReq.init( - self.alloc, - alloc_writer.writer.buffered(), - ); + break :req .{ .alloc = .{ + .alloc = self.alloc, + .data = try alloc_writer.toOwnedSlice(), + } }; }; // Copy the encoded data into the inspector event if we have one. @@ -3679,6 +3686,12 @@ fn isMouseReporting(self: *const Surface) bool { self.io.terminal.flags.mouse_event != .none; } +pub fn mouseReportingActive(self: *Surface) bool { + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); + return self.isMouseReporting(); +} + fn mouseReport( self: *Surface, button: ?input.MouseButton, @@ -4319,7 +4332,9 @@ fn linkAtPos( const mouse_mods = self.mouseModsWithCapture(self.mouse.mods); // If we have the proper modifiers set then we can check for OSC8 links. - if (mouse_mods.equal(input.ctrlOrSuper(.{}))) hyperlink: { + if (self.config.link_osc8 and + mouse_mods.equal(input.ctrlOrSuper(.{}))) + hyperlink: { const rac = mouse_pin.rowAndCell(); const cell = rac.cell; if (!cell.hyperlink) break :hyperlink; @@ -4429,7 +4444,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { log.warn("failed to get URI for OSC8 hyperlink", .{}); return false; }; - try self.openUrl(.{ .kind = .unknown, .url = uri }); + try self.openUrl(.{ .kind = .osc8, .url = uri }); }, } @@ -5166,6 +5181,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .tab, ), + .prompt_window_title => return try self.rt_app.performAction( + .{ .surface = self }, + .prompt_title, + .window, + ), + .set_surface_title => |v| { const title = try self.alloc.dupeZ(u8, v); defer self.alloc.free(title); @@ -5186,6 +5207,16 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ); }, + .set_window_title => |v| { + const title = try self.alloc.dupeZ(u8, v); + defer self.alloc.free(title); + return try self.rt_app.performAction( + .{ .surface = self }, + .set_window_title, + .{ .title = title }, + ); + }, + .clear_screen => { // This is a duplicate of some of the logic in termio.clearScreen // but we need to do this here so we can know the answer before @@ -5325,6 +5356,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .{ .amount = position }, ), + .move_tab_to_new_window => return try self.rt_app.performAction( + .{ .surface = self }, + .move_tab_to_new_window, + {}, + ), + .new_split => |direction| return try self.rt_app.performAction( .{ .surface = self }, .new_split, @@ -5680,7 +5717,8 @@ fn writeScreenFile( ) !void { // Create a temporary directory to store our scrollback. var tmp_dir = try internal_os.TempDir.init(); - errdefer tmp_dir.deinit(); + var retain_tmp_dir = false; + defer if (retain_tmp_dir) tmp_dir.close(.retain) else tmp_dir.deinit(); var filename_buf: [std.fs.max_path_bytes]u8 = undefined; const filename = try std.fmt.bufPrint( @@ -5750,7 +5788,6 @@ fn writeScreenFile( const sel = sel_ orelse { // If we have no selection we have no data so we do nothing. - tmp_dir.deinit(); return; }; @@ -5804,6 +5841,9 @@ fn writeScreenFile( path, ), .unlocked), } + + // The action accepted the path, so retain the file for its consumer. + retain_tmp_dir = true; } /// Call this to complete a clipboard request sent to apprt. This should @@ -5970,8 +6010,8 @@ fn completeClipboardReadOSC52( // This must hold the base64 encoded data PLUS the OSC code surrounding it. const enc = std.base64.standard.Encoder; const size = enc.calcSize(data.len); - var buf = try self.alloc.alloc(u8, size + 9); // const for OSC - defer self.alloc.free(buf); + const buf = try self.alloc.alloc(u8, size + 9); // const for OSC + errdefer self.alloc.free(buf); const kind: u8 = switch (clipboard_type) { .standard => 'c', @@ -5989,10 +6029,10 @@ fn completeClipboardReadOSC52( const encoded = enc.encode(buf[prefix.len..], data); assert(encoded.len == size); - self.queueIo(try termio.Message.writeReq( - self.alloc, - buf, - ), .unlocked); + self.queueIo(.{ .write_alloc = .{ + .alloc = self.alloc, + .data = buf, + } }, .unlocked); } fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const u8) !void { @@ -6064,3 +6104,18 @@ fn presentSurface(self: *Surface) !void { pub fn getProcessInfo(self: *Surface, comptime info: ProcessInfo) ?ProcessInfo.Type(info) { return self.io.getProcessInfo(info); } + +test "queueIo frees allocated writes in readonly mode" { + const testing = std.testing; + + const surface = try testing.allocator.create(Surface); + defer testing.allocator.destroy(surface); + surface.readonly = true; + + // queueIo must free allocated writes in read-only mode. + const data = try testing.allocator.dupe(u8, "\x1b]lGhostty\x1b\\"); + surface.queueIo(.{ .write_alloc = .{ + .alloc = testing.allocator, + .data = data, + } }, .unlocked); +} diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 23bff13d7..7e902e563 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -196,6 +196,9 @@ pub const Action = union(Key) { /// rendered at the next opportunity. render_inspector, + /// Export the Terminal IO inspector event log. + export_terminal_io: ExportTerminalIO, + /// Show a desktop notification. desktop_notification: DesktopNotification, @@ -205,6 +208,9 @@ pub const Action = union(Key) { /// Set the tab title override for the target's tab. set_tab_title: SetTitle, + /// Set the window title override for the target's tab. + set_window_title: SetTitle, + /// Set the title of the target to a prompted value. It is up to /// the apprt to prompt. The value specifies whether to prompt for the /// surface title or the tab title. @@ -228,7 +234,7 @@ pub const Action = union(Key) { /// Open the Ghostty configuration. This is platform-specific about /// what it means; it can mean opening a dedicated UI or just opening /// a file in a text editor. - open_config, + open_config: OpenConfig, /// Called when there are no more surfaces and the app should quit /// after the configured delay. @@ -348,6 +354,9 @@ pub const Action = union(Key) { /// otherwise the terminal-set title. copy_title_to_clipboard, + /// Move a tab to a new window. + move_tab_to_new_window, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -381,9 +390,11 @@ pub const Action = union(Key) { inspector, show_gtk_inspector, render_inspector, + export_terminal_io, desktop_notification, set_title, set_tab_title, + set_window_title, prompt_title, pwd, mouse_shape, @@ -416,6 +427,7 @@ pub const Action = union(Key) { search_selected, readonly, copy_title_to_clipboard, + move_tab_to_new_window, test "ghostty.h Action.Key" { try lib.checkGhosttyHEnum(Key, "GHOSTTY_ACTION_"); @@ -457,7 +469,7 @@ pub const Action = union(Key) { // At the time of writing, we don't promise ABI compatibility // so we can change this but I want to be aware of it. assert(@sizeOf(CValue) == switch (@sizeOf(usize)) { - 4 => 16, + 4 => 24, 8 => 24, else => unreachable, }); @@ -612,6 +624,37 @@ pub const Inspector = enum(c_int) { } }; +/// Terminal IO inspector contents to export. The contents are only valid for +/// the duration of the action callback. +pub const ExportTerminalIO = struct { + contents: []const u8, + + // Sync with: ghostty_action_export_terminal_io_s + pub const C = extern struct { + contents: [*]const u8, + len: usize, + }; + + pub fn cval(self: ExportTerminalIO) C { + return .{ + .contents = self.contents.ptr, + .len = self.contents.len, + }; + } + + pub fn format( + value: @This(), + comptime _: []const u8, + _: std.fmt.Options, + writer: *std.Io.Writer, + ) !void { + try writer.print( + "{s}{{ contents: {d} bytes }}", + .{ @typeName(@This()), value.contents.len }, + ); + } +}; + pub const QuitTimer = enum(c_int) { start, stop, @@ -639,10 +682,11 @@ pub const MouseVisibility = enum(c_int) { } }; -/// Whether to prompt for the surface title or tab title. +/// Whether to prompt for the surface, tab, or window title. pub const PromptTitle = enum(c_int) { surface, tab, + window, test "ghostty.h PromptTitle" { try lib.checkGhosttyHEnum(PromptTitle, "GHOSTTY_PROMPT_TITLE_"); @@ -906,6 +950,11 @@ pub const OpenUrl = struct { /// The URL is known to contain HTML content. html, + /// The URL came from an OSC 8 hyperlink. Application runtimes should + /// treat this as untrusted terminal output and apply a platform-specific + /// safe-opening policy. + osc8, + test "ghostty.h OpenUrl.Kind" { try lib.checkGhosttyHEnum(Kind, "GHOSTTY_ACTION_OPEN_URL_KIND_"); } @@ -1004,6 +1053,19 @@ pub const SearchSelected = struct { } }; +/// sync with ghostty_action_close_tab_mode_e in ghostty.h +pub const OpenConfig = enum(c_int) { + /// Open the config in the OS default editor. + os_open, + + /// Open the config in a new window using $EDITOR or $VISUAL + new_window, + + test "ghostty.h OpenConfig" { + try lib.checkGhosttyHEnum(OpenConfig, "GHOSTTY_ACTION_OPEN_CONFIG_"); + } +}; + test { _ = compat_testing.refAllDeclsRecursive(@This()); } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 1f3b98e24..a380dc50c 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -120,7 +120,13 @@ pub const App = struct { core_app: *CoreApp, opts: Options, - keymap: input.Keymap, + + /// The keyboard layout keymap. This is lazily initialized on first + /// use because creating it requires talking to the text input + /// system (TIS on macOS), and the first such call in a process is + /// slow (multiple milliseconds). It is only needed once keyboard + /// events start flowing, at which point the system is warm. + keymap: ?input.Keymap, /// The configuration for the app. This is owned by this structure. config: Config, @@ -136,19 +142,16 @@ pub const App = struct { var config_clone = try config.clone(alloc); errdefer config_clone.deinit(); - var keymap = try input.Keymap.init(); - errdefer keymap.deinit(); - self.* = .{ .core_app = core_app, .config = config_clone, .opts = opts, - .keymap = keymap, + .keymap = null, }; } pub fn terminate(self: *App) void { - self.keymap.deinit(); + if (self.keymap) |*v| v.deinit(); self.config.deinit(); } @@ -208,8 +211,10 @@ pub const App = struct { /// This should be called whenever the keyboard layout was changed. pub fn reloadKeymap(self: *App) !void { - // Reload the keymap - try self.keymap.reload(); + // Reload the keymap. If it was never initialized we don't need + // to do anything since lazy initialization will pick up the + // current layout. + if (self.keymap) |*v| try v.reload(); } /// Loads the keyboard layout. @@ -217,13 +222,25 @@ pub const App = struct { /// Kind of expensive so this should be avoided if possible. When I say /// "kind of expensive" I mean that its not something you probably want /// to run on every keypress. - pub fn keyboardLayout(self: *const App) input.KeyboardLayout { + pub fn keyboardLayout(self: *App) input.KeyboardLayout { // We only support keyboard layout detection on macOS. if (comptime builtin.os.tag != .macos) return .unknown; + // Lazily initialize the keymap. + const keymap: *input.Keymap = keymap: { + if (self.keymap == null) { + self.keymap = input.Keymap.init() catch |err| { + log.warn("error initializing keymap err={}", .{err}); + return .unknown; + }; + } + + break :keymap &self.keymap.?; + }; + // Any layout larger than this is not something we can handle. var buf: [256]u8 = undefined; - const id = self.keymap.sourceId(&buf) catch |err| { + const id = keymap.sourceId(&buf) catch |err| { comptime assert(@TypeOf(err) == error{OutOfMemory}); return .unknown; }; @@ -337,6 +354,7 @@ pub const App = struct { ) (Allocator.Error || apprt.ipc.Errors)!bool { switch (action) { .new_window => return false, + .new_tab => return false, .toggle_quick_terminal => return false, } } @@ -1489,7 +1507,7 @@ pub const CAPI = struct { /// Open the configuration. export fn ghostty_app_open_config(v: *App) void { - _ = v.performAction(.app, .open_config, {}) catch |err| { + _ = v.performAction(.app, .open_config, .new_window) catch |err| { log.err("error reloading config err={}", .{err}); return; }; diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 8a7b3a8e5..9fbea7c1e 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -13,6 +13,7 @@ const CoreApp = @import("../../App.zig"); const Application = @import("class/application.zig").Application; const Surface = @import("Surface.zig"); const ipcNewWindow = @import("ipc/new_window.zig").newWindow; +const ipcNewTab = @import("ipc/new_tab.zig").newTab; const ipcToggleQuickTerminal = @import("ipc/toggle_quick_terminal.zig").toggleQuickTerminal; const log = std.log.scoped(.gtk); @@ -85,6 +86,7 @@ pub fn performIpc( ) !bool { switch (action) { .new_window => return try ipcNewWindow(alloc, target, value), + .new_tab => return try ipcNewTab(alloc, target, value), .toggle_quick_terminal => return try ipcToggleQuickTerminal(alloc, target), } } diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index f1fa1d960..5b9d3bf2e 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -12,6 +12,7 @@ const gtk = @import("gtk"); const build_config = @import("../../../build_config.zig"); const build_info = @import("../build/info.zig"); +const cli = @import("../../../cli.zig"); const global = @import("../../../global.zig"); const i18n = @import("../../../os/main.zig").i18n; const apprt = @import("../../../apprt.zig"); @@ -26,7 +27,6 @@ const xev = global.xev; const Binding = @import("../../../input.zig").Binding; const CoreConfig = configpkg.Config; const CoreSurface = @import("../../../Surface.zig"); -const lib = @import("../../../lib/main.zig"); const ext = @import("../ext.zig"); const key = @import("../key.zig"); @@ -45,6 +45,7 @@ const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseCo const ConfigErrorsDialog = @import("config_errors_dialog.zig").ConfigErrorsDialog; const GlobalShortcuts = @import("global_shortcuts.zig").GlobalShortcuts; const OpenURI = @import("../portal.zig").OpenURI; +const media = @import("../media.zig"); const log = std.log.scoped(.gtk_ghostty_application); @@ -223,6 +224,12 @@ pub const Application = extern struct { open_uri: OpenURI = undefined, + // The audio bell's MediaFile, reused across bells so we don't leak a + // GStreamer pipeline (and its GL threads) on every ring. Built lazily + // on the first audio bell and rebuilt when `bell-audio-path` changes; + // unref'd on dispose. See ringBell and media.zig. + bell_media: ?*gtk.MediaFile = null, + pub var offset: c_int = 0; }; @@ -703,6 +710,11 @@ pub const Application = extern struct { .initial_size => return Action.initialSize(target, value), .inspector => return Action.controlInspector(target, value), + .export_terminal_io => return try Action.exportTerminalIO( + self, + target, + value, + ), .key_sequence => return Action.keySequence(target, value), .key_table => return Action.keyTable(target, value), @@ -712,10 +724,11 @@ pub const Application = extern struct { .mouse_visibility => Action.mouseVisibility(target, value), .move_tab => return Action.moveTab(target, value), + .move_tab_to_new_window => return Action.moveTabToNewWindow(target), .new_split => return Action.newSplit(target, value), - .new_tab => return Action.newTab(target), + .new_tab => return Action.newTab(target, .none), .new_window => try Action.newWindow( self, @@ -726,7 +739,7 @@ pub const Application = extern struct { .none, ), - .open_config => return Action.openConfig(self), + .open_config => return Action.openConfig(self, value), .open_url => Action.openUrl(self, value), @@ -757,6 +770,7 @@ pub const Application = extern struct { .set_title => Action.setTitle(target, value), .set_tab_title => return Action.setTabTitle(target, value), + .set_window_title => return Action.setWindowTitle(target, value), .show_child_exited => return Action.showChildExited(target, value), @@ -1023,6 +1037,38 @@ pub const Application = extern struct { \\} \\ \\/* + \\ * Drag and Drop Overlay + \\ */ + \\.drop-overlay.drop-left { + \\ background: linear-gradient( + \\ to left, + \\ transparent, 50%, + \\ color-mix(in srgb, var(--accent-bg-color), transparent 80%) 50% + \\ ); + \\} + \\.drop-overlay.drop-right { + \\ background: linear-gradient( + \\ to right, + \\ transparent, 50%, + \\ color-mix(in srgb, var(--accent-bg-color), transparent 80%) 50% + \\ ); + \\} + \\.drop-overlay.drop-top { + \\ background: linear-gradient( + \\ to top, + \\ transparent, 50%, + \\ color-mix(in srgb, var(--accent-bg-color), transparent 80%) 50% + \\ ); + \\} + \\.drop-overlay.drop-bottom { + \\ background: linear-gradient( + \\ to bottom, + \\ transparent, 50%, + \\ color-mix(in srgb, var(--accent-bg-color), transparent 80%) 50% + \\ ); + \\} + \\ + \\/* \\ * Splits \\ */ \\ @@ -1159,7 +1205,8 @@ pub const Application = extern struct { fn syncActionAccelerators(self: *Self) void { self.syncActionAccelerator("app.quit", .{ .quit = {} }); - self.syncActionAccelerator("app.open-config", .{ .open_config = {} }); + self.syncActionAccelerator("app.open-config::os-open", .{ .open_config = .os_open }); + self.syncActionAccelerator("app.open-config::new-window", .{ .open_config = .new_window }); self.syncActionAccelerator("app.reload-config", .{ .reload_config = {} }); self.syncActionAccelerator("win.toggle-inspector", .{ .inspector = .toggle }); self.syncActionAccelerator("app.show-gtk-inspector", .show_gtk_inspector); @@ -1433,14 +1480,22 @@ pub const Application = extern struct { const as_variant_type = glib.VariantType.new("as"); defer as_variant_type.free(); + const tas_variant_type = glib.VariantType.new("(tas)"); + defer tas_variant_type.free(); + + const s_variant_type = glib.ext.VariantType.newFor([:0]const u8); + defer s_variant_type.free(); + const actions = [_]ext.actions.Action(Self){ .init("new-window", actionNewWindow, null), .init("new-window-command", actionNewWindow, as_variant_type), - .init("open-config", actionOpenConfig, null), + .init("new-tab", actionNewTab, tas_variant_type), + .init("open-config", actionOpenConfig, s_variant_type), .init("present-surface", actionPresentSurface, t_variant_type), .init("quit", actionQuit, null), .init("reload-config", actionReloadConfig, null), .init("toggle-quick-terminal", actionToggleQuickTerminal, null), + .init("ring-bell", actionRingBell, null), }; ext.actions.add(Self, self, &actions); @@ -1504,7 +1559,7 @@ pub const Application = extern struct { diag.close(); diag.unref(); // strong ref from get() } - priv.config_errors_dialog.set(null); + priv.config_errors_dialog.deinit(); if (priv.signal_source) |v| { if (glib.Source.remove(v) == 0) { log.warn("unable to remove signal source", .{}); @@ -1512,6 +1567,11 @@ pub const Application = extern struct { priv.signal_source = null; } + if (priv.bell_media) |v| { + v.unref(); + priv.bell_media = null; + } + gobject.Object.virtual_methods.dispose.call( Class.parent, self.as(Parent), @@ -1764,93 +1824,227 @@ pub const Application = extern struct { ) callconv(.c) void { log.debug("received new window action", .{}); - var arena: std.heap.ArenaAllocator = .init(Application.default().allocator()); + var arena: std.heap.ArenaAllocator = .init(self.core().alloc); defer arena.deinit(); const alloc = arena.allocator(); - var working_directory: ?[:0]const u8 = null; - var title: ?[:0]const u8 = null; - var command: ?configpkg.Command = null; - var args: std.ArrayList([:0]const u8) = .empty; - - overrides: { + const overrides = overrides: { // were we given a parameter? - const parameter = parameter_ orelse break :overrides; + const arguments = parameter_ orelse break :overrides null; const as_variant_type = glib.VariantType.new("as"); defer as_variant_type.free(); // ensure that the supplied parameter is an array of strings - if (glib.Variant.isOfType(parameter, as_variant_type) == 0) { + if (glib.Variant.isOfType(arguments, as_variant_type) == 0) { log.warn("parameter is of type '{s}', not '{s}'", .{ - parameter.getTypeString(), + arguments.getTypeString(), as_variant_type.peekString()[0..as_variant_type.getStringLength()], }); - break :overrides; + break :overrides null; } - const s_variant_type = glib.VariantType.new("s"); - defer s_variant_type.free(); + var arguments_it: glib.VariantIter = undefined; + _ = arguments_it.init(arguments); - var it: glib.VariantIter = undefined; - _ = it.init(parameter); + break :overrides parseOverrides(alloc, &arguments_it) catch null; + }; - var e_seen: bool = false; - var i: usize = 0; + Action.newWindow( + self, + null, + if (overrides) |o| .{ + .command = o.command, + .shell_integration = o.shell_integration, + .working_directory = o.working_directory, + .title = o.title, + } else .none, + ) catch |err| { + log.warn("unable to create new window: {t}", .{err}); + }; + } - while (it.nextValue()) |value| : (i += 1) { - defer value.unref(); + /// Handle `app.new-tab` GTK action + pub fn actionNewTab( + _: *gio.SimpleAction, + parameter_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + log.debug("received new tab action", .{}); - // just to be sure - if (value.isOfType(s_variant_type) == 0) continue; + const core_app = self.core(); - var len: usize = undefined; - const buf = value.getString(&len); - const str = buf[0..len]; + var arena: std.heap.ArenaAllocator = .init(core_app.alloc); + defer arena.deinit(); - log.debug("new-window argument: {d} {s}", .{ i, str }); + const alloc = arena.allocator(); - if (e_seen) { - const duplicated = alloc.dupeZ(u8, str) catch |err| { - log.warn("unable to duplicate argument {d} {s}: {t}", .{ i, str, err }); - break :overrides; - }; - args.append(alloc, duplicated) catch |err| { - log.warn("unable to append argument {d} {s}: {t}", .{ i, str, err }); - break :overrides; - }; + const surface_id_, const overrides = result: { + // were we given a parameter? + const parameter = parameter_ orelse { + log.warn("app.new-tab did not receive a parameter", .{}); + return; + }; + + log.warn("got parameter: {s}", .{parameter.getTypeString()}); + + const tas_variant_type = glib.VariantType.new("(tas)"); + defer tas_variant_type.free(); + + // ensure that the supplied parameter is an array of strings + if (glib.Variant.isOfType(parameter, tas_variant_type) == 0) { + log.warn("parameter is of type '{s}', not '{s}'", .{ + parameter.getTypeString(), + tas_variant_type.peekString()[0..tas_variant_type.getStringLength()], + }); + return; + } + + var surface_id: u64 = 0; + var arguments_it_: ?*glib.VariantIter = null; + defer if (arguments_it_) |arguments_it| arguments_it.free(); + + parameter.get("(tas)", &surface_id, &arguments_it_); + + const arguments_it = arguments_it_ orelse return; + + const overrides = parseOverrides(alloc, arguments_it) catch return; + + break :result .{ if (surface_id == 0) null else surface_id, overrides }; + }; + + const surface_ = surface: { + if (surface_id_) |surface_id| find_by_id: { + break :surface core_app.findSurfaceByID(surface_id) orelse { + log.warn("new-tab: unable to find surface 0x{x:0>16}", .{surface_id}); + break :find_by_id; + }; + } + find_focused: { + break :surface core_app.focusedSurface() orelse { + log.warn("new-tab: unable to find surface", .{}); + break :find_focused; + }; + } + break :surface null; + }; + + if (surface_) |surface| { + if (!Action.newTab( + .{ + .surface = surface, + }, + .{ + .command = overrides.command, + .shell_integration = overrides.shell_integration, + .working_directory = overrides.working_directory, + .title = overrides.title, + }, + )) { + log.warn("new-tab: unable to create tab", .{}); + } + } else { + Action.newWindow( + self, + null, + .{ + .command = overrides.command, + .shell_integration = overrides.shell_integration, + .working_directory = overrides.working_directory, + .title = overrides.title, + }, + ) catch |err| { + log.warn("new-tab: unable to create new window: {t}", .{err}); + }; + } + } + + fn parseOverrides(arena_alloc: Allocator, arguments_it: *glib.VariantIter) (Allocator.Error || error{ValueRequired})!struct { + command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + } { + var args: std.ArrayList([:0]const u8) = .empty; + + var working_directory: ?[:0]const u8 = null; + var title: ?[:0]const u8 = null; + var command: ?configpkg.Command = null; + var parsed_shell_integration: struct { + @"shell-integration": ?configpkg.Config.ShellIntegration = null, + } = .{}; + + const s_variant_type = glib.VariantType.new("s"); + defer s_variant_type.free(); + + var e_seen: bool = false; + var i: usize = 0; + + while (arguments_it.nextValue()) |value| : (i += 1) { + defer value.unref(); + + // just to be sure + if (value.isOfType(s_variant_type) == 0) continue; + + var len: usize = undefined; + const buf = value.getString(&len); + const str = buf[0..len]; + + log.debug("argument: {d} {s}", .{ i, str }); + + if (e_seen) { + const copy = arena_alloc.dupeZ(u8, str) catch |err| { + log.warn("unable to duplicate argument {d} {s}: {t}", .{ i, str, err }); + return err; + }; + args.append(arena_alloc, copy) catch |err| { + log.warn("unable to append argument {d} {s}: {t}", .{ i, str, err }); + return err; + }; + continue; + } + + if (std.mem.eql(u8, str, "-e")) { + e_seen = true; + continue; + } + + if (std.mem.cutPrefix(u8, str, "--command=")) |v| { + var cmd: configpkg.Command = undefined; + cmd.parseCLI(arena_alloc, v) catch |err| { + log.warn("unable to parse command: {t}", .{err}); + return err; + }; + command = cmd; + continue; + } + if (std.mem.cutPrefix(u8, str, "--shell-integration=")) |v| { + cli.args.parseIntoField( + @TypeOf(parsed_shell_integration), + arena_alloc, + &parsed_shell_integration, + "shell-integration", + std.mem.trim(u8, v, &std.ascii.whitespace), + ) catch |err| { + log.warn("unable to parse shell integration {s}: {t}", .{ v, err }); continue; - } - - if (std.mem.eql(u8, str, "-e")) { - e_seen = true; - continue; - } - - if (lib.cutPrefix(u8, str, "--command=")) |v| { - var cmd: configpkg.Command = undefined; - cmd.parseCLI(alloc, v) catch |err| { - log.warn("unable to parse command: {t}", .{err}); - continue; - }; - command = cmd; - continue; - } - if (lib.cutPrefix(u8, str, "--working-directory=")) |v| { - working_directory = alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| wd: { - log.warn("unable to duplicate working directory: {t}", .{err}); - break :wd null; - }; - continue; - } - if (lib.cutPrefix(u8, str, "--title=")) |v| { - title = alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| t: { - log.warn("unable to duplicate title: {t}", .{err}); - break :t null; - }; - continue; - } + }; + continue; + } + if (std.mem.cutPrefix(u8, str, "--working-directory=")) |v| { + working_directory = arena_alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| { + log.warn("unable to duplicate working directory: {t}", .{err}); + return err; + }; + continue; + } + if (std.mem.cutPrefix(u8, str, "--title=")) |v| { + title = arena_alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| { + log.warn("unable to duplicate title: {t}", .{err}); + return err; + }; + continue; } } @@ -1860,21 +2054,52 @@ pub const Application = extern struct { }; } - Action.newWindow(self, null, .{ + return .{ .command = command, + .shell_integration = parsed_shell_integration.@"shell-integration", .working_directory = working_directory, .title = title, - }) catch |err| { - log.warn("unable to create new window: {t}", .{err}); }; } pub fn actionOpenConfig( _: *gio.SimpleAction, - _: ?*glib.Variant, + parameter_: ?*glib.Variant, self: *Self, ) callconv(.c) void { - _ = self.core().mailbox.push(global.io(), .open_config, .forever); + const target: CoreApp.Message.OpenConfig = target: { + const target_map: std.StaticStringMap(CoreApp.Message.OpenConfig) = .initComptime(&.{ + .{ "os-open", .os_open }, + .{ "new-window", .new_window }, + }); + + const parameter = parameter_ orelse { + log.warn("no parameter provided to app.open-config action", .{}); + break :target .os_open; + }; + + const s_variant_type = glib.ext.VariantType.newFor([:0]const u8); + defer s_variant_type.free(); + + if (parameter.isOfType(s_variant_type) == 0) { + log.warn("parameter to app.open-config action is of type '{s}' not '{s}'", .{ + parameter.getTypeString(), + s_variant_type.peekString()[0..s_variant_type.getStringLength()], + }); + break :target .os_open; + } + + var len: usize = undefined; + const buf = parameter.getString(&len); + const str = buf[0..len]; + + break :target target_map.get(str) orelse { + log.warn("'{s}' is not configured as a target for the app.open-config action", .{str}); + break :target .os_open; + }; + }; + + _ = self.core().mailbox.push(global.io(), .{ .open_config = target }, .forever); } fn actionPresentSurface( @@ -1911,6 +2136,40 @@ pub const Application = extern struct { ); } + pub fn actionRingBell( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const priv: *Private = self.private(); + const config = priv.config.get(); + + // Do our sound + if (config.@"bell-features".audio) audio: { + const config_path = config.@"bell-audio-path" orelse break :audio; + const path, const required = switch (config_path) { + .optional => |path| .{ path, false }, + .required => |path| .{ path, true }, + }; + + const volume = std.math.clamp( + config.@"bell-audio-volume", + 0.0, + 1.0, + ); + + // Reuse one MediaFile per application (rebuilt only when the path + // changes) so each bell replays the same pipeline instead of + // leaking a fresh one. Assign unconditionally: bellMediaFile frees + // any stale MediaFile and returns the current slot value (possibly + // null if the path is now inaccessible), so priv.bell_media never + // dangles. + priv.bell_media = media.bellMediaFile(priv.bell_media, path, required); + const media_file = priv.bell_media orelse break :audio; + media.playBell(media_file, volume); + } + } + //---------------------------------------------------------------- // Boilerplate/Noise @@ -1997,6 +2256,98 @@ const Action = struct { }; } + pub fn exportTerminalIO( + self: *Application, + target: apprt.Target, + value: apprt.Action.Value(.export_terminal_io), + ) Allocator.Error!bool { + const surface = switch (target) { + .app => return false, + .surface => |v| v.rt_surface.gobj(), + }; + + const alloc = self.allocator(); + const contents = try alloc.dupe(u8, value.contents); + errdefer alloc.free(contents); + const request = try alloc.create(ExportTerminalIORequest); + errdefer alloc.destroy(request); + request.* = .{ + .alloc = alloc, + .contents = contents, + }; + + const parent = ext.getAncestor(gtk.Window, surface.as(gtk.Widget)); + const dialog = gtk.FileChooserNative.new( + i18n._("Export Terminal IO Events"), + parent, + .save, + i18n._("Export"), + i18n._("Cancel"), + ); + const chooser = dialog.as(gtk.FileChooser); + chooser.setCreateFolders(1); + chooser.setCurrentName("ghostty-terminal-io.txt"); + + _ = gtk.NativeDialog.signals.response.connect( + dialog, + *ExportTerminalIORequest, + ExportTerminalIORequest.response, + request, + .{ .destroyData = ExportTerminalIORequest.destroy }, + ); + dialog.as(gtk.NativeDialog).show(); + return true; + } + + const ExportTerminalIORequest = struct { + alloc: Allocator, + contents: []u8, + + fn destroy(self: *ExportTerminalIORequest) callconv(.c) void { + self.alloc.free(self.contents); + self.alloc.destroy(self); + } + + fn response( + dialog: *gtk.FileChooserNative, + response_id: c_int, + self: *ExportTerminalIORequest, + ) callconv(.c) void { + defer dialog.unref(); + + if (response_id != @intFromEnum(gtk.ResponseType.accept)) return; + + const file = dialog.as(gtk.FileChooser).getFile() orelse { + log.warn("inspector export dialog returned no file", .{}); + return; + }; + defer file.unref(); + + var gerr: ?*glib.Error = null; + const replaced = file.replaceContents( + self.contents.ptr, + self.contents.len, + null, + 0, + .{}, + null, + null, + &gerr, + ); + if (gerr) |err| { + defer err.free(); + log.err( + "failed to export terminal IO events err={s}", + .{err.f_message orelse "(unknown)"}, + ); + return; + } + if (replaced == 0) { + log.err("failed to export terminal IO events", .{}); + } + } + }; + pub fn configChange( self: *Application, target: apprt.Target, @@ -2261,6 +2612,26 @@ const Action = struct { } } + pub fn moveTabToNewWindow( + target: apprt.Target, + ) bool { + switch (target) { + .app => return false, + .surface => |core| { + const surface = core.rt_surface.surface; + const window = ext.getAncestor( + Window, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a window, ignoring new_tab_to_new_window", .{}); + return false; + }; + + return window.moveTabToNewWindow(surface); + }, + } + } + pub fn newSplit( target: apprt.Target, direction: apprt.action.SplitDirection, @@ -2283,7 +2654,17 @@ const Action = struct { } } - pub fn newTab(target: apprt.Target) bool { + pub fn newTab( + target: apprt.Target, + overrides: struct { + command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, + ) bool { switch (target) { .app => { log.warn("new tab to app is unexpected", .{}); @@ -2302,7 +2683,12 @@ const Action = struct { log.warn("surface is not in a window, ignoring new_tab", .{}); return false; }; - window.newTab(core); + window.newTab(core, .{ + .command = overrides.command, + .shell_integration = overrides.shell_integration, + .working_directory = overrides.working_directory, + .title = overrides.title, + }); return true; }, } @@ -2313,6 +2699,7 @@ const Action = struct { parent: ?*CoreSurface, overrides: struct { command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, working_directory: ?[:0]const u8 = null, title: ?[:0]const u8 = null, @@ -2335,6 +2722,7 @@ const Action = struct { parent, .{ .command = overrides.command, + .shell_integration = overrides.shell_integration, .working_directory = overrides.working_directory, .title = overrides.title, }, @@ -2347,6 +2735,7 @@ const Action = struct { parent: ?*CoreSurface, overrides: struct { command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, working_directory: ?[:0]const u8 = null, title: ?[:0]const u8 = null, @@ -2367,6 +2756,7 @@ const Action = struct { // Create a new tab with window context (first tab in new window) win.newTabForWindow(parent, .{ .command = overrides.command, + .shell_integration = overrides.shell_integration, .working_directory = overrides.working_directory, .title = overrides.title, }); @@ -2387,18 +2777,55 @@ const Action = struct { gtk.Window.present(win.as(gtk.Window)); } - pub fn openConfig(self: *Application) bool { - // Get the config file path + pub fn openConfig(self: *Application, value: apprt.action.OpenConfig) bool { const alloc = self.allocator(); + + // Get the config file path const path = configpkg.edit.openPath(alloc) catch |err| { - log.warn("error getting config file path: {}", .{err}); + log.warn("error getting config file path: {t}", .{err}); return false; }; defer alloc.free(path); - // Open it using openURL. "path" isn't actually a URL but - // at the time of writing that works just fine for GTK. - openUrl(self, .{ .kind = .text, .url = path }); + switch (value) { + .os_open => { + // Open it using openUrl. "path" isn't actually a URL but + // at the time of writing that works just fine for GTK. + openUrl(self, .{ .kind = .text, .url = path }); + }, + + .new_window => { + const cmd = internal_os.getConfigEditCommand(alloc, path, .default) catch |err| { + log.warn("unable to get command to edit the config: {t}", .{err}); + return false; + }; + defer alloc.free(cmd); + + const command: configpkg.Command = .{ + .direct = &.{ "/bin/sh", "-c", cmd }, + }; + + const title = std.fmt.allocPrintSentinel( + alloc, + "{s} {s}", + .{ i18n._("Editing configuration file"), path }, + 0, + ) catch |err| t: { + log.warn("unable to format title: {t}", .{err}); + break :t null; + }; + defer if (title) |t| alloc.free(t); + + Action.newWindow(self, null, .{ + .command = command, + .title = title, + }) catch |err| { + log.warn("unable to create new window: {t}", .{err}); + return false; + }; + }, + } + return true; } @@ -2493,6 +2920,23 @@ const Action = struct { }, } }, + .window => { + switch (target) { + .app => return false, + .surface => |v| { + const surface = v.rt_surface.surface; + const win = ext.getAncestor( + Window, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a window, ignoring prompt_window_title", .{}); + return false; + }; + win.promptWindowTitle(); + return true; + }, + } + }, } } @@ -2664,6 +3108,30 @@ const Action = struct { } } + pub fn setWindowTitle( + target: apprt.Target, + value: apprt.action.SetTitle, + ) bool { + switch (target) { + .app => { + log.warn("set_tab_title to app is unexpected", .{}); + return false; + }, + .surface => |core| { + const surface = core.rt_surface.surface; + const window = ext.getAncestor( + Window, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a window, ignoring set_window_title", .{}); + return false; + }; + window.setTitleOverride(if (value.title.len == 0) null else value.title); + return true; + }, + } + } + pub fn showChildExited( target: apprt.Target, value: apprt.surface.Message.ChildExited, diff --git a/src/apprt/gtk/class/command_palette.zig b/src/apprt/gtk/class/command_palette.zig index 0d66bbe94..aaec00587 100644 --- a/src/apprt/gtk/class/command_palette.zig +++ b/src/apprt/gtk/class/command_palette.zig @@ -581,10 +581,8 @@ const Command = extern struct { const priv = self.private(); priv.data = .{ - .jump = .{ - // TODO: Replace with surface id whenever Ghostty adds one - .sort_key = @intFromPtr(surface), - }, + // Surface should be initialized at this point. + .jump = .{ .sort_key = surface.core().?.id }, }; priv.data.jump.surface.set(surface); @@ -611,7 +609,7 @@ const Command = extern struct { switch (priv.data) { .regular => {}, .jump => |*j| { - j.surface.set(null); + j.surface.deinit(); }, } diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 7a6b66def..ca7437126 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -214,6 +214,7 @@ pub const SplitTree = extern struct { parent_: ?*Surface, overrides: struct { command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, working_directory: ?[:0]const u8 = null, title: ?[:0]const u8 = null, @@ -225,6 +226,7 @@ pub const SplitTree = extern struct { // Create our new surface. const surface: *Surface = .new(.{ .command = overrides.command, + .shell_integration = overrides.shell_integration, .working_directory = overrides.working_directory, .title = overrides.title, }); @@ -239,12 +241,7 @@ pub const SplitTree = extern struct { } // Bind is-split property for new surface - _ = self.as(gobject.Object).bindProperty( - "is-split", - surface.as(gobject.Object), - "is-split", - .{ .sync_create = true }, - ); + surface.bindIsSplit(self); // Create our tree var single_tree = try Surface.Tree.init(alloc, surface); @@ -387,6 +384,77 @@ pub const SplitTree = extern struct { return true; } + /// Move the source split onto the target split in a given direction. + /// The target split must be located within this tree. + pub fn moveSplit( + self: *Self, + source: *Surface, + target: *Surface, + dir: Surface.Tree.Split.Direction, + ) Allocator.Error!void { + const alloc = Application.default().allocator(); + const target_tree = self.getTree() orelse return; + + // This really shouldn't fail, but just in case + const target_handle = target_tree.locate(target) orelse { + log.warn("target is not placed in a split tree", .{}); + return; + }; + + // Try to find the source within the current tree. + // If it exists, then it's a local move; otherwise the logic gets more + // complicated + const source_handle = target_tree.locate(source); + + // First add the source into this tree. + // We have to do this no matter what + var branch = try Surface.Tree.init(alloc, source); + defer branch.deinit(); + + var after_split = try target_tree.split( + alloc, + target_handle, + dir, + 0.5, + &branch, + ); + defer after_split.deinit(); + + if (source_handle) |handle| { + // Happy path: source and target are located within the same tree. + // In that case, we just remove the existing source from this tree + + var after_remove = try after_split.remove(alloc, handle); + defer after_remove.deinit(); + + self.setTree(&after_remove); + } else { + // :( Cross-tree moves are a bit more complicated. + + // TODO: Find a better way to access the split tree from here + const source_tree_widget = ext.getAncestor( + SplitTree, + source.as(gtk.Widget), + ) orelse { + log.warn("source is not placed in a split tree", .{}); + return; + }; + const source_tree = source_tree_widget.getTree() orelse return; + + // Remove the source from its own tree + const handle = source_tree.locate(source) orelse return; + var new_source_tree = try source_tree.remove(alloc, handle); + defer new_source_tree.deinit(); + + // Finally, set the final tree structures for both tree widgets + source_tree_widget.setTree(&new_source_tree); + self.setTree(&after_split); + + // Re-bind vital properties like `is-split` + source.bindIsSplit(self); + } + } + fn disconnectSurfaceHandlers(self: *Self) void { const tree = self.getTree() orelse return; var it = tree.iterator(); @@ -579,7 +647,7 @@ pub const SplitTree = extern struct { fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); - priv.last_focused.set(null); + priv.last_focused.deinit(); if (priv.rebuild_source) |v| { if (glib.Source.remove(v) == 0) { log.warn("unable to remove rebuild source", .{}); @@ -705,17 +773,8 @@ pub const SplitTree = extern struct { // Find the surface in the tree to verify this is valid and // set our pending close handle. - priv.pending_close = handle: { - const tree = self.getTree() orelse return; - var it = tree.iterator(); - while (it.next()) |entry| { - if (entry.view == surface) { - break :handle entry.handle; - } - } - - return; - }; + const tree = self.getTree() orelse return; + priv.pending_close = tree.locate(surface) orelse return; // If we don't need to confirm then just close immediately. if (!core.needsConfirmQuit()) { @@ -1115,12 +1174,17 @@ const SplitTreeSplit = extern struct { /// Assumed to be correct. handle: Surface.Tree.Node.Handle, + /// Cache the split ratio. Looking it up is relatively expensive + /// because we have to first walk the widget tree to find the + /// SplitTree widget. + ratio: f64, + /// Source to handle repositioning the split when properties change. idle: ?c_uint = null, /// Whether the max-position/position property of the gtk.Paned widget /// changed. We use these to distinguish between a resize and the user - /// manually moving the split divider. See the "on-idle" function. + /// manually moving the split divider. See the "onIdle" function. max_changed: bool = false, pos_changed: bool = false, @@ -1144,6 +1208,7 @@ const SplitTreeSplit = extern struct { const self = gobject.ext.newInstance(Self, .{}); const priv = self.private(); priv.handle = handle; + priv.ratio = split.ratio; // Setup our paned fields const paned = priv.paned; @@ -1154,6 +1219,17 @@ const SplitTreeSplit = extern struct { .vertical => .vertical, }); + // Request min width/height 1 for surfaces to prevent them from + // becoming temporarily invisible in nested layouts. Otherwise + // gtk.Paned might mark a surface as not visible and unmap it + // for a single frame. See the comments below in propMaxPosition. + if (gobject.ext.isA(start_child, SurfaceScrolledWindow)) { + start_child.setSizeRequest(1, 1); + } + if (gobject.ext.isA(end_child, SurfaceScrolledWindow)) { + end_child.setSizeRequest(1, 1); + } + // Signals and so on are setup in the template. return self; @@ -1163,49 +1239,18 @@ const SplitTreeSplit = extern struct { gtk.Widget.initTemplate(self.as(gtk.Widget)); } - // We need to keep the split ratios from the tree datastructure and - // widget tree in sync. Using the max-position and position properties - // of the gtk.Paned widget, we can distinguish a resize from a manual - // update (e.g. the user dragging the divider).If max-position changes, - // we always have a widget resize. Usually position will change as well - // but it might not if the size change is small enough. If only position - // changes, we have a manual human update. - // - // This is a hack, it relies on the timing of property notifcations. - // From looking at the GTK source code, it should not be possible that - // we interpret a position change from a resize as a manual update. - // When a gtk.Paned is resized, internally the gtk_paned_calc_position - // function will change both max-position and position and synchronously - // call our propMaxPosition and propPosition functions. I.e. when the - // widget is resized, it should not be possible for onIdle to run before - // we have been notified of both property changes. - fn onIdle(ud: ?*anyopaque) callconv(.c) c_int { - const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + const SyncDirection = enum { + // Update gtk.Paned widget to match ratio from split tree node. + tree_to_widget, + // Update split tree node to match ratio from gtk.Paned widget. + widget_to_tree, + }; + + // Sync split ratio between the gtk.Paned widget and the split tree. + fn syncSplitRatio(self: *Self, direction: SyncDirection) void { const priv = self.private(); const paned = priv.paned; - // Clear source and fields at the end. Otherwise if setPosition is - // called below, propPosition is triggered and would add another - // idle callback before this one is finished. - defer priv.idle = null; - defer priv.max_changed = false; - defer priv.pos_changed = false; - - if (!priv.max_changed and !priv.pos_changed) { - return 0; - } - - // Get our split. This is the most dangerous part of this entire - // widget. We assume that this widget is always a child of a - // SplitTree, we assume that our handle is valid, and we assume - // the handle is always a split node. - const split_tree = ext.getAncestor( - SplitTree, - self.as(gtk.Widget), - ) orelse return 0; - const tree = split_tree.getTree() orelse return 0; - const split: *const Surface.Tree.Split = &tree.nodes[priv.handle.idx()].split; - // Current, min, and max positions as pixels. const pos = paned.getPosition(); const min = min: { @@ -1236,7 +1281,7 @@ const SplitTreeSplit = extern struct { // If our max is zero then we can't do any math. I don't know // if this is possible but I suspect it can be if you make a nested // split completely minimized. - if (max == 0) return 0; + if (max == 0) return; // Determine our current ratio. const current_ratio: f64 = ratio: { @@ -1244,7 +1289,7 @@ const SplitTreeSplit = extern struct { const max_f64: f64 = @floatFromInt(max); break :ratio pos_f64 / max_f64; }; - const desired_ratio: f64 = @floatCast(split.ratio); + const desired_ratio: f64 = priv.ratio; // If our ratio is close enough to our desired ratio, then // we ignore the update. This is to avoid constant split updates @@ -1255,24 +1300,85 @@ const SplitTreeSplit = extern struct { desired_ratio, 0.001, )) { - return 0; + return; + } + + switch (direction) { + .tree_to_widget => { + // Update position in gtk.Paned to match desired ratio. Note that if + // max-position is small, it might not be possible to accurately set + // the desired ratio. E.g. with max-position=2 you can only have + // ratios 0, 0.5 and 1. + const desired_pos: c_int = desired_pos: { + const max_f64: f64 = @floatFromInt(max); + break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio)); + }; + paned.setPosition(desired_pos); + }, + .widget_to_tree => { + // Update ratio of the split tree node. This is the most dangerous + // part of this widget. We assume that this widget is always a child + // of a SplitTree, we assume that our handle is valid, and we assume + // the handle is always a split node. + const split_tree = ext.getAncestor( + SplitTree, + self.as(gtk.Widget), + ) orelse { + log.warn("SplitTree ancestor not found", .{}); + return; + }; + const tree = split_tree.getTree() orelse { + log.warn("no tree data model set", .{}); + return; + }; + assert(priv.handle.idx() < tree.nodes.len); + assert(tree.nodes[priv.handle.idx()] == .split); + tree.resizeInPlace(priv.handle, @floatCast(current_ratio)); + priv.ratio = current_ratio; + }, + } + } + + // We need to keep the split ratios from the tree datastructure and + // widget tree in sync. Using the max-position and position properties + // of the gtk.Paned widget, we can distinguish a resize from a manual + // update (e.g. the user dragging the divider). If max-position changes, + // we always have a widget resize and we sync the ratio directly from + // the propMaxPosition function. Usually position will change as well + // but it might not if the size change is small enough. If only position + // changes, we have a manual human update and we sync the ratio here. + // + // This is a hack, it relies on the timing of property notifcations. + // From looking at the GTK source code, it should not be possible that + // we interpret a position change from a resize as a manual update. + // When a gtk.Paned is resized, internally the gtk_paned_calc_position + // function will change both max-position and position and synchronously + // call our propMaxPosition and propPosition functions. I.e. when the + // widget is resized, it should not be possible for onIdle to run before + // we have been notified of both property changes. + fn onIdle(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + const priv = self.private(); + + // Clear source and fields. + defer { + priv.idle = null; + priv.max_changed = false; + priv.pos_changed = false; } if (priv.max_changed) { - // Widget got resized, update position to match desired ratio. - // Note that if max-position is small, it might not be possible - // to accurately set the desired ratio. E.g. with max-position=2 - // you can only have ratios 0, 0.5 and 1. - const desired_pos: c_int = desired_pos: { - const max_f64: f64 = @floatFromInt(max); - break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio)); - }; - paned.setPosition(desired_pos); - } else { - // If only position changed, this is a manual human update and - // we need to write our update back to the tree. - tree.resizeInPlace(priv.handle, @floatCast(current_ratio)); + // Nothing to do, syncSplitRatio was already called + // directly from propMaxPosition. + return 0; } + if (!priv.pos_changed) { + return 0; + } + + // If only position changed, this is a manual human update and + // we need to write our update back to the tree. + self.syncSplitRatio(.widget_to_tree); return 0; } @@ -1286,6 +1392,41 @@ const SplitTreeSplit = extern struct { ) callconv(.c) void { const priv = self.private(); priv.max_changed = true; + + // Widget got resized, update position to match desired ratio. + // + // Syncing the split ratio directly here ensures that every surface + // gets the correct size allocated immediately. This works because + // this callback runs during size allocation, so that we update the + // divider position right after the gtk.Paned widget computes it and + // right before it will use the position to compute the sizes of its + // children. See the size_allocate and calc_position functions of the + // gtk.Paned widget. + // + // If we synced the ratio in an idle callback, there might be some + // flickering as surfaces are shown with the wrong size for a few + // frames until our idle callback runs. Depending on the split layout + // it might take multiple rounds of size allocation and idle callbacks + // for every widget to get the correct size. + // + // Note that gtk.Paned calls Widget.setChildVisible(true/false) for + // both children based on the initial divider position it computes. + // If we then update the position here, we might sometimes have to + // call setChildVisible again. This would be necessary if we updated + // the position from initially 0 (max), where start (end) child is + // invisible, to a value > 0 (< max). However, we can skip this because + // set_position already queues another round of size allocation, during + // which gtk.Paned will keep the correct position we set manually and + // then call setChildVisible again based on that. The only problematic + // situation is when we update the position from 0 to a value > 0, + // because setChildVisible(false) will unmap the start child widget. + // It will not be visible for a single frame which introduces some + // flickering. To prevent this from happening we set a min size request + // of 1 for all surfaces (see the "new" function above). + self.syncSplitRatio(.tree_to_widget); + + // We still need the idle callback to clear the max_changed field + // later, because propPosition might or might not still get called. if (priv.idle == null) priv.idle = glib.idleAdd( onIdle, self, diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index c902f0fe3..b5e8b0b2c 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -34,9 +34,10 @@ const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig") const TitleDialog = @import("title_dialog.zig").TitleDialog; const Window = @import("window.zig").Window; const InspectorWindow = @import("inspector_window.zig").InspectorWindow; +const SplitTree = @import("split_tree.zig").SplitTree; const i18n = @import("../../../os/i18n.zig"); -const media = @import("../media.zig"); const global = @import("../../../global.zig"); +const gtk_version = @import("../gtk_version.zig"); const log = std.log.scoped(.gtk_ghostty_surface); @@ -675,12 +676,6 @@ pub const Surface = extern struct { // false by a parent widget. bell_ringing: bool = false, - // The audio bell's MediaFile, reused across bells so we don't leak a - // GStreamer pipeline (and its GL threads) on every ring. Built lazily - // on the first audio bell and rebuilt when `bell-audio-path` changes; - // unref'd on dispose. See ringBell and media.zig. - bell_media: ?*gtk.MediaFile = null, - /// True if this surface is in an error state. This is currently /// a simple boolean with no additional information on WHAT the /// error state is, because we don't yet need it or use it. For now, @@ -696,6 +691,7 @@ pub const Surface = extern struct { // True if the current surface is a split, this is used to apply // unfocused-split-* options is_split: bool = false, + is_split_binding: ?*gobject.Binding = null, action_group: ?*gio.SimpleActionGroup = null, @@ -714,6 +710,9 @@ pub const Surface = extern struct { child_exited_overlay: *ChildExited, context_menu: *gtk.PopoverMenu, drop_target: *gtk.DropTarget, + surface_drop_target: *gtk.DropTarget, + drag_handle: *gtk.Widget, + drop_overlay: *gtk.Widget, progress_bar_overlay: *gtk.ProgressBar, error_page: *adw.StatusPage, terminal_page: *gtk.Overlay, @@ -737,6 +736,7 @@ pub const Surface = extern struct { overrides: struct { command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, working_directory: ?[:0]const u8 = null, pub const none: @This() = .{}; @@ -747,6 +747,7 @@ pub const Surface = extern struct { pub fn new(overrides: struct { command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, working_directory: ?[:0]const u8 = null, title: ?[:0]const u8 = null, @@ -759,6 +760,7 @@ pub const Surface = extern struct { const priv: *Private = self.private(); priv.overrides = .{ .command = if (overrides.command) |c| c.clone(alloc) catch null else null, + .shell_integration = overrides.shell_integration, .working_directory = if (overrides.working_directory) |wd| alloc.dupeZ(u8, wd) catch null else null, }; return self; @@ -849,6 +851,18 @@ pub const Surface = extern struct { return @intFromBool(config.@"bell-features".border); } + pub fn bindIsSplit(self: *Self, tree: *SplitTree) void { + const priv = self.private(); + if (priv.is_split_binding) |bind| bind.unbind(); + + priv.is_split_binding = tree.as(gobject.Object).bindProperty( + "is-split", + self.as(gobject.Object), + "is-split", + .{ .sync_create = true }, + ); + } + /// Callback used to determine whether unfocused-split-fill / unfocused-split-opacity /// should be applied to the surface fn closureShouldUnfocusedSplitBeShown( @@ -1829,8 +1843,19 @@ pub const Surface = extern struct { }; priv.drop_target.setGtypes(&drop_target_types, drop_target_types.len); + // Also have to set up the surface drop target to accept other surfaces + // (in particular, their surface IDs) + var surface_drop_target_types = [_]gobject.Type{ + gobject.ext.types.uint64, + }; + priv.surface_drop_target.setGtypes( + &surface_drop_target_types, + surface_drop_target_types.len, + ); + // Setup properties we can't set from our Blueprint file. self.as(gtk.Widget).setCursorFromName("text"); + priv.drag_handle.setCursorFromName("grab"); // Initialize our config self.propConfig(undefined, null); @@ -1864,11 +1889,6 @@ pub const Surface = extern struct { priv.config = null; } - if (priv.bell_media) |v| { - v.unref(); - priv.bell_media = null; - } - if (priv.vadj_signal_group) |group| { group.setTarget(null); group.as(gobject.Object).unref(); @@ -1991,6 +2011,58 @@ pub const Surface = extern struct { ); } + fn snapshot(self: *Self, snap: *gtk.Snapshot) callconv(.c) void { + const priv = self.private(); + + const blur = blur: { + // Native GTK blur is only available since GTK 4.23.3 + if (gtk_version.runtimeUntil(4, 23, 3)) break :blur null; + const config = priv.config orelse break :blur null; + + break :blur switch (config.get().@"background-blur") { + .radius => |v| @as(f32, v), + .true, .@"macos-glass-regular", .@"macos-glass-clear" => 20.0, + .false => null, + }; + }; + + if (blur) |b| blur: { + // pushCopy and appendPaste are only supported since 4.22. + // These two functions are crucial for the blur function + // and it cannot be implemented otherwise, so if you compile + // Ghostty on an older GTK version, you won't get blur. + // Sorry. + if (comptime !gtk_version.atLeast(4, 22, 0)) break :blur; + + const width = self.as(gtk.Widget).getWidth(); + const height = self.as(gtk.Widget).getHeight(); + + // Push the current render state (background) + // to be appended below + snap.pushCopy(); + defer snap.pop(); + + // Apply blur to the copied background + snap.pushBlur(b); + defer snap.pop(); + + snap.appendPaste(&.{ + .f_origin = .{ .f_x = 0, .f_y = 0 }, + .f_size = .{ + .f_width = @floatFromInt(width), + .f_height = @floatFromInt(height), + }, + }, 0); + } + + // Draw the children normally + gtk.Widget.virtual_methods.snapshot.call( + Class.parent, + self.as(Parent), + snap, + ); + } + //--------------------------------------------------------------- // Properties @@ -2217,7 +2289,12 @@ pub const Surface = extern struct { // Logic around bell reaction happens on every event even if we're // already in the ringing state. - if (ringing) self.ringBell(); + if (ringing) { + self.ringBell(); + // focus clears ringing state, so we should not change state to + // ringing if we're already focused + if (self.getFocused()) return; + } // Property change only happens on actual state change const priv = self.private(); @@ -2471,8 +2548,6 @@ pub const Surface = extern struct { /// Handle bell features that need to happen every time a BEL is received /// Currently this is audio and system but this could change in the future. fn ringBell(self: *Self) void { - const priv = self.private(); - // Emit the signal signals.bell.impl.emit( self, @@ -2484,33 +2559,7 @@ pub const Surface = extern struct { // Activate actions if they exist _ = self.as(gtk.Widget).activateAction("tab.ring-bell", null); _ = self.as(gtk.Widget).activateAction("win.ring-bell", null); - - const config = if (priv.config) |c| c.get() else return; - - // Do our sound - if (config.@"bell-features".audio) audio: { - const config_path = config.@"bell-audio-path" orelse break :audio; - const path, const required = switch (config_path) { - .optional => |path| .{ path, false }, - .required => |path| .{ path, true }, - }; - - const volume = std.math.clamp( - config.@"bell-audio-volume", - 0.0, - 1.0, - ); - - // Reuse one MediaFile per surface (rebuilt only when the path - // changes) so each bell replays the same pipeline instead of - // leaking a fresh one. Assign unconditionally: bellMediaFile frees - // any stale MediaFile and returns the current slot value (possibly - // null if the path is now inaccessible), so priv.bell_media never - // dangles. - priv.bell_media = media.bellMediaFile(priv.bell_media, path, required); - const media_file = priv.bell_media orelse break :audio; - media.playBell(media_file, volume); - } + _ = self.as(gtk.Widget).activateAction("app.ring-bell", null); } //--------------------------------------------------------------- @@ -2819,7 +2868,10 @@ pub const Surface = extern struct { return; } - if (button == .middle and !priv.gtk_enable_primary_paste) { + if (button == .middle and + !priv.gtk_enable_primary_paste and + !core_surface.mouseReportingActive()) + { return; } @@ -2879,7 +2931,10 @@ pub const Surface = extern struct { return; } - if (button == .middle and !priv.gtk_enable_primary_paste) { + if (button == .middle and + !priv.gtk_enable_primary_paste and + !surface.mouseReportingActive()) + { return; } @@ -3475,9 +3530,11 @@ pub const Surface = extern struct { ); defer config.deinit(); - if (priv.overrides.command) |c| { - config.command = try c.clone(config._arena.?.allocator()); - } + try applyCommandOverrides( + &config, + priv.overrides.command, + priv.overrides.shell_integration, + ); if (priv.overrides.working_directory) |wd| { const config_alloc = config.arenaAlloc(); var wd_val: configpkg.WorkingDirectory = .{ .path = try config_alloc.dupe(u8, wd) }; @@ -3611,6 +3668,178 @@ pub const Surface = extern struct { }; } + fn closureShouldDragHandleBeShown( + _: *Self, + config_: ?*Config, + is_split: c_int, + ) callconv(.c) c_int { + const config = config_ orelse return @intFromBool(false); + + const shown = switch (config.get().@"drag-handle") { + .always => true, + .auto => is_split != 0, + .never => false, + }; + return @intFromBool(shown); + } + + fn surfaceDragPrepare( + src: *gtk.DragSource, + x: f64, + y: f64, + self: *Self, + ) callconv(.c) *gdk.ContentProvider { + // TODO: Use a static content provider once we make `self.core()` + // available immediately when constructing the surface widget + _ = src; + _ = x; + _ = y; + var val = gobject.ext.Value.newFrom(self.core().?.id); + return gdk.ContentProvider.newForValue(&val); + } + + fn surfaceDragBegin( + src: *gtk.DragSource, + _: *gdk.Drag, + self: *Self, + ) callconv(.c) void { + // The scale of the preview + const preview_scale: f32 = 0.2; + + // Snapshot the entire surface widget as the icon preview + const paintable = gtk.WidgetPaintable.new(self.as(gtk.Widget)); + defer paintable.unref(); + + // Center the preview + const width = self.as(gtk.Widget).getWidth(); + const height = self.as(gtk.Widget).getHeight(); + const mid_x = @as(f32, @floatFromInt(width)) / 2; + const mid_y = @as(f32, @floatFromInt(height)) / 2; + + // Create a snapshot to render the scaled paintable + const snap = gtk.Snapshot.new(); + snap.scale(preview_scale, preview_scale); + paintable.as(gdk.Paintable).snapshot( + snap.as(gdk.Snapshot), + @floatFromInt(width), + @floatFromInt(height), + ); + + if (snap.freeToPaintable(null)) |scaled| { + defer scaled.unref(); + src.setIcon( + scaled, + @intFromFloat(mid_x * preview_scale), + @intFromFloat(mid_y * preview_scale), + ); + } else { + // The scaling process somehow failed. + // Use the original paintable as a fail-safe + log.warn("preview scaling failed - falling back to original paintable", .{}); + src.setIcon( + paintable.as(gdk.Paintable), + @intFromFloat(mid_x), + @intFromFloat(mid_y), + ); + } + } + + fn surfaceDrop( + _: *gtk.DropTarget, + v: *const gobject.Value, + x: f64, + y: f64, + self: *Self, + ) callconv(.c) void { + const dropped_id = v.getUint64(); + const dropped = self.core().?.app.findSurfaceByID(dropped_id) orelse return; + const from = dropped.rt_surface.gobj(); + + const st = ext.getAncestor( + SplitTree, + self.as(gtk.Widget), + ) orelse { + log.warn("surface is not placed in a split tree", .{}); + return; + }; + + const dir = self.calcDropDirection(x, y); + + // The only error that could happen here is an OOM, + // and in that case we're already milliseconds away from crashing, so... + st.moveSplit(from, self, dir) catch return; + + // Clean up overlay state + self.setDropOverlayDirection(null); + } + + fn surfaceDropLeave( + _: *gtk.DropTarget, + self: *Self, + ) callconv(.c) void { + // Hide overlay + self.setDropOverlayDirection(null); + } + + fn surfaceDropMotion( + _: *gtk.DropTarget, + x: f64, + y: f64, + self: *Self, + ) callconv(.c) gdk.DragAction { + // Recalculate the drop region + const dir = self.calcDropDirection(x, y); + self.setDropOverlayDirection(dir); + return .{ .move = true }; + } + + fn propDropValue( + tgt: *gtk.DropTarget, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + // Reject the drop if we're dropping a surface onto itself. + // Note that we cannot implement this via the `accept` signal, + // since the decision of whether to accept or deny a drop is dependent + // on the payload (i.e. the surface being dropped). This is + // well-documented in GTK docs. + + const core_surface = self.core() orelse return; + const value = tgt.getValue() orelse return; + const surface_id = value.getUint64(); + if (core_surface.id == surface_id) tgt.reject(); + } + + fn setDropOverlayDirection(self: *Self, dir: ?Tree.Split.Direction) void { + const priv = self.private(); + inline for (&.{ "drop-top", "drop-left", "drop-right", "drop-bottom" }) |c| { + priv.drop_overlay.removeCssClass(c); + } + + if (dir) |d| priv.drop_overlay.addCssClass(switch (d) { + .up => "drop-top", + .left => "drop-left", + .right => "drop-right", + .down => "drop-bottom", + }); + } + + fn calcDropDirection(self: *Self, x: f64, y: f64) Tree.Split.Direction { + const width: f64 = @floatFromInt(self.as(gtk.Widget).getWidth()); + const height: f64 = @floatFromInt(self.as(gtk.Widget).getHeight()); + + const l_dist = x / width; + const t_dist = y / height; + const r_dist = 1 - l_dist; + const b_dist = 1 - t_dist; + const min = @min(l_dist, t_dist, r_dist, b_dist); + + if (min == l_dist) return .left; + if (min == r_dist) return .right; + if (min == t_dist) return .up; + return .down; + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -3650,6 +3879,9 @@ pub const Surface = extern struct { class.bindTemplateChildPrivate("key_state_overlay", .{}); class.bindTemplateChildPrivate("terminal_page", .{}); class.bindTemplateChildPrivate("drop_target", .{}); + class.bindTemplateChildPrivate("surface_drop_target", .{}); + class.bindTemplateChildPrivate("drag_handle", .{}); + class.bindTemplateChildPrivate("drop_overlay", .{}); class.bindTemplateChildPrivate("im_context", .{}); // Template Callbacks @@ -3692,6 +3924,13 @@ pub const Surface = extern struct { class.bindTemplateCallback("search_changed", &searchChanged); class.bindTemplateCallback("search_next_match", &searchNextMatch); class.bindTemplateCallback("search_previous_match", &searchPreviousMatch); + class.bindTemplateCallback("should_drag_handle_be_shown", &closureShouldDragHandleBeShown); + class.bindTemplateCallback("surface_drag_prepare", &surfaceDragPrepare); + class.bindTemplateCallback("surface_drag_begin", &surfaceDragBegin); + class.bindTemplateCallback("surface_drop", &surfaceDrop); + class.bindTemplateCallback("surface_drop_leave", &surfaceDropLeave); + class.bindTemplateCallback("surface_drop_motion", &surfaceDropMotion); + class.bindTemplateCallback("notify_drop_value", &propDropValue); // Properties gobject.ext.registerProperties(class, &.{ @@ -3737,6 +3976,7 @@ pub const Surface = extern struct { // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); gobject.Object.virtual_methods.finalize.implement(class, &finalize); + gtk.Widget.virtual_methods.snapshot.implement(class, &snapshot); } pub const as = C.Class.as; @@ -4158,3 +4398,57 @@ test "computeFraction" { try std.testing.expectEqual(0.0, computeFraction(0)); try std.testing.expectEqual(0.5, computeFraction(50)); } + +/// Apply command and shell integration overrides received from the CLI. +/// Explicit commands should only receive shell integration when their +/// executable can be detected as a supported shell. An explicit shell +/// integration override is also valid without a command. +fn applyCommandOverrides( + config: *configpkg.Config, + command: ?configpkg.Command, + shell_integration: ?configpkg.Config.ShellIntegration, +) Allocator.Error!void { + if (command) |value| { + config.command = try value.clone(config.arenaAlloc()); + + if (shell_integration) |integration| { + config.@"shell-integration" = integration; + } else if (config.@"shell-integration" != .none) { + config.@"shell-integration" = .detect; + } + } else if (shell_integration) |value| { + config.@"shell-integration" = value; + } +} + +test "command and shell integration overrides" { + const testing = std.testing; + + var config = try configpkg.Config.default(testing.allocator); + defer config.deinit(); + + config.@"shell-integration" = .nushell; + try applyCommandOverrides(&config, .{ .shell = "vim" }, null); + try testing.expectEqual(.detect, config.@"shell-integration"); + + config.@"shell-integration" = .none; + try applyCommandOverrides(&config, .{ .shell = "vim" }, null); + try testing.expectEqual(.none, config.@"shell-integration"); + + try applyCommandOverrides(&config, .{ .shell = "nu" }, .nushell); + try testing.expectEqual(.nushell, config.@"shell-integration"); + + try applyCommandOverrides(&config, .{ .shell = "vim" }, .none); + try testing.expectEqual(.none, config.@"shell-integration"); + + config.@"shell-integration" = .nushell; + try applyCommandOverrides(&config, null, null); + try testing.expectEqual(.nushell, config.@"shell-integration"); + + config.@"shell-integration" = .none; + try applyCommandOverrides(&config, null, .nushell); + try testing.expectEqual(.nushell, config.@"shell-integration"); + + try applyCommandOverrides(&config, null, .none); + try testing.expectEqual(.none, config.@"shell-integration"); +} diff --git a/src/apprt/gtk/class/surface_child_exited.zig b/src/apprt/gtk/class/surface_child_exited.zig index d7dd41bcb..3c103d59d 100644 --- a/src/apprt/gtk/class/surface_child_exited.zig +++ b/src/apprt/gtk/class/surface_child_exited.zig @@ -106,11 +106,11 @@ const SurfaceChildExitedBanner = extern struct { return; }; if (data.exit_code == 0) { - banner.as(adw.Banner).setTitle(i18n._("Command succeeded")); + banner.as(adw.Banner).setTitle(i18n._("Command Succeeded")); self.as(gtk.Widget).addCssClass("normal"); self.as(gtk.Widget).removeCssClass("abnormal"); } else { - banner.as(adw.Banner).setTitle(i18n._("Command failed")); + banner.as(adw.Banner).setTitle(i18n._("Command Failed")); self.as(gtk.Widget).removeCssClass("normal"); self.as(gtk.Widget).addCssClass("abnormal"); } diff --git a/src/apprt/gtk/class/tab.zig b/src/apprt/gtk/class/tab.zig index 0c60c8ccc..204be58f1 100644 --- a/src/apprt/gtk/class/tab.zig +++ b/src/apprt/gtk/class/tab.zig @@ -162,7 +162,7 @@ pub const Tab = extern struct { /// The title of this tab. This is usually bound to the active surface. title: ?[:0]const u8 = null, - /// The manually overridden title from `promptTabTitle`. + /// The manually overridden title. title_override: ?[:0]const u8 = null, /// The tooltip of this tab. This is usually bound to the active surface. @@ -189,6 +189,7 @@ pub const Tab = extern struct { pub fn new(config: ?*Config, overrides: struct { command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, working_directory: ?[:0]const u8 = null, title: ?[:0]const u8 = null, @@ -212,6 +213,7 @@ pub const Tab = extern struct { // Create our initial surface in the split tree. priv.split_tree.newSplit(.right, null, .{ .command = overrides.command, + .shell_integration = overrides.shell_integration, .working_directory = overrides.working_directory, .title = overrides.title, }) catch |err| switch (err) { diff --git a/src/apprt/gtk/class/title_dialog.zig b/src/apprt/gtk/class/title_dialog.zig index ac95ae4b6..057e9dee9 100644 --- a/src/apprt/gtk/class/title_dialog.zig +++ b/src/apprt/gtk/class/title_dialog.zig @@ -220,10 +220,13 @@ pub const TitleDialog = extern struct { pub const Target = enum(c_int) { surface, tab, + window, + pub fn title(self: Target) [*:0]const u8 { return switch (self) { .surface => i18n._("Change Terminal Title"), .tab => i18n._("Change Tab Title"), + .window => i18n._("Change Window Title"), }; } diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index f9e3b9841..e2fe6403c 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -29,6 +29,7 @@ const Tab = @import("tab.zig").Tab; const DebugWarning = @import("debug_warning.zig").DebugWarning; const CommandPalette = @import("command_palette.zig").CommandPalette; const WeakRef = @import("../weak_ref.zig").WeakRef; +const TitleDialog = @import("title_dialog.zig").TitleDialog; const log = std.log.scoped(.gtk_ghostty_window); @@ -213,6 +214,19 @@ pub const Window = extern struct { }, ); }; + + pub const @"title-override" = struct { + pub const name = "title-override"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("title_override"), + }, + ); + }; }; const Private = struct { @@ -259,6 +273,9 @@ pub const Window = extern struct { /// setup by `setup-menu`. context_menu_page: ?*adw.TabPage = null, + /// The manually overridden title. + title_override: ?[:0]const u8 = null, + // Template bindings tab_overview: *adw.TabOverview, tab_bar: *adw.TabBar, @@ -365,6 +382,7 @@ pub const Window = extern struct { .init("prompt-surface-title", actionPromptSurfaceTitle, null), .init("prompt-tab-title", actionPromptTabTitle, null), .init("prompt-context-tab-title", actionPromptContextTabTitle, null), + .init("prompt-window-title", actionPromptWindowTitle, null), .init("ring-bell", actionRingBell, null), .init("split-right", actionSplitRight, null), .init("split-left", actionSplitLeft, null), @@ -390,8 +408,20 @@ pub const Window = extern struct { /// Create a new tab with the given parent. The tab will be inserted /// at the position dictated by the `window-new-tab-position` config. /// The new tab will be selected. - pub fn newTab(self: *Self, parent_: ?*CoreSurface) void { - _ = self.newTabPage(parent_, .tab, .none); + pub fn newTab(self: *Self, parent_: ?*CoreSurface, overrides: struct { + command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }) void { + _ = self.newTabPage(parent_, .tab, .{ + .command = overrides.command, + .shell_integration = overrides.shell_integration, + .working_directory = overrides.working_directory, + .title = overrides.title, + }); } pub fn newTabForWindow( @@ -399,6 +429,7 @@ pub const Window = extern struct { parent_: ?*CoreSurface, overrides: struct { command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, working_directory: ?[:0]const u8 = null, title: ?[:0]const u8 = null, @@ -410,6 +441,7 @@ pub const Window = extern struct { .window, .{ .command = overrides.command, + .shell_integration = overrides.shell_integration, .working_directory = overrides.working_directory, .title = overrides.title, }, @@ -422,6 +454,7 @@ pub const Window = extern struct { context: apprt.surface.NewSurfaceContext, overrides: struct { command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, working_directory: ?[:0]const u8 = null, title: ?[:0]const u8 = null, @@ -436,6 +469,7 @@ pub const Window = extern struct { priv.config, .{ .command = overrides.command, + .shell_integration = overrides.shell_integration, .working_directory = overrides.working_directory, .title = overrides.title, }, @@ -604,6 +638,29 @@ pub const Window = extern struct { return tab_view.reorderPage(page, desired_pos) != 0; } + pub fn moveTabToNewWindow(self: *Self, surface: *Surface) bool { + const tab_view = self.private().tab_view; + const tab = ext.getAncestor( + Tab, + surface.as(gtk.Widget), + ) orelse return false; + const page = tab_view.getPage(tab.as(gtk.Widget)); + + // Skip if this is the only tab in the existing window + if (tab_view.getNPages() <= 1) return false; + + const app = Application.default(); + const window = Window.new(app, .none); + + tab_view.transferPage( + page, + window.private().tab_view, + 0, + ); + window.as(gtk.Window).present(); + return true; + } + pub fn toggleTabOverview(self: *Self) void { const priv = self.private(); const tab_overview = priv.tab_overview; @@ -1268,6 +1325,57 @@ pub const Window = extern struct { }); } + pub fn setTitleOverride(self: *Self, title: ?[]const u8) void { + const priv: *Private = self.private(); + + if (priv.title_override) |v| glib.free(@ptrCast(@constCast(v))); + priv.title_override = null; + + if (title) |v| priv.title_override = glib.ext.dupeZ(u8, v); + + self.as(gobject.Object).notifyByPspec(properties.@"title-override".impl.param_spec); + } + + fn titleDialogSet(_: *TitleDialog, title_: [*:0]const u8, self: *Self) callconv(.c) void { + const title = std.mem.span(title_); + self.setTitleOverride(if (title.len == 0) null else title); + } + + pub fn promptWindowTitle(self: *Self) void { + const priv: *Private = self.private(); + + var value = std.mem.zeroes(gobject.Value); + _ = value.init(gobject.ext.types.string); + defer value.unset(); + + self.as(gobject.Object).getProperty("title", &value); + + const title_: ?[*:0]const u8 = value.getString(); + const title: ?[:0]const u8 = if (title_) |v| std.mem.span(v) else null; + + const dialog = TitleDialog.new(.window, priv.title_override orelse title); + _ = TitleDialog.signals.set.connect( + dialog, + *Self, + titleDialogSet, + self, + .{}, + ); + + dialog.present(self.as(gtk.Widget)); + } + + fn closureTitle( + _: *Self, + _: ?*Config, + title_: ?[*:0]const u8, + title_override_: ?[*:0]const u8, + ) callconv(.c) ?[*:0]const u8 { + if (title_override_) |v| return glib.ext.dupeZ(u8, std.mem.span(v)); + if (title_) |v| return glib.ext.dupeZ(u8, std.mem.span(v)); + return glib.ext.dupeZ(u8, "Ghostty"); + } + fn closureSubtitle( _: *Self, config_: ?*Config, @@ -1296,7 +1404,7 @@ pub const Window = extern struct { priv.handle_active_state_source = null; } - priv.command_palette.set(null); + priv.command_palette.deinit(); if (priv.config) |v| { v.unref(); @@ -1320,6 +1428,7 @@ pub const Window = extern struct { const priv = self.private(); priv.tab_bindings.unref(); priv.winproto.deinit(); + if (priv.title_override) |v| glib.free(@ptrCast(@constCast(v))); gobject.Object.virtual_methods.finalize.call( Class.parent, @@ -1976,6 +2085,14 @@ pub const Window = extern struct { self.performBindingAction(.prompt_tab_title); } + fn actionPromptWindowTitle( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.prompt_window_title); + } + fn actionSplitRight( _: *gio.SimpleAction, _: ?*glib.Variant, @@ -2183,6 +2300,7 @@ pub const Window = extern struct { properties.@"tabs-wide".impl, properties.@"toolbar-style".impl, properties.@"titlebar-style".impl, + properties.@"title-override".impl, }); // Bindings @@ -2213,6 +2331,7 @@ pub const Window = extern struct { class.bindTemplateCallback("notify_quick_terminal", &propQuickTerminal); class.bindTemplateCallback("notify_scale_factor", &propScaleFactor); class.bindTemplateCallback("titlebar_style_is_tabs", &closureTitlebarStyleIsTab); + class.bindTemplateCallback("computed_title", &closureTitle); class.bindTemplateCallback("computed_subtitle", &closureSubtitle); // Virtual methods diff --git a/src/apprt/gtk/css/style.css b/src/apprt/gtk/css/style.css index 9c0f115f1..045d9906f 100644 --- a/src/apprt/gtk/css/style.css +++ b/src/apprt/gtk/css/style.css @@ -127,7 +127,7 @@ label.resize-overlay { } .surface .bell-overlay { - border-color: rgba(58, 148, 74, 0.5); + border-color: rgba(53, 132, 228, 0.5); /* after GTK 4.16 is a requirement, switch to the following: */ /* background-color: color-mix(in srgb, var(--accent-color), transparent 50%); */ border-width: 3px; @@ -144,6 +144,58 @@ label.resize-overlay { outline-style: solid; outline-width: 1px; } +/* + * Drag and drop overlay + */ +.drag-handle { + background-color: rgba(0, 0, 0, 0.15); + opacity: 0; + transition: 0.1s opacity; +} +.drag-handle:hover { + opacity: 1; +} +.drop-overlay.drop-left { + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background: linear-gradient(to left, transparent, 50%, color-mix(in srgb, var(--accent-color), transparent 50%) 50%); */ + background: linear-gradient( + to left, + transparent, + 50%, + rgba(53, 132, 228, 0.2) 50% + ); +} +.drop-overlay.drop-right { + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background: linear-gradient(to right, transparent, 50%, color-mix(in srgb, var(--accent-color), transparent 50%) 50%); */ + background: linear-gradient( + to right, + transparent, + 50%, + rgba(53, 132, 228, 0.2) 50% + ); +} +.drop-overlay.drop-top { + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background: linear-gradient(to top, transparent, 50%, color-mix(in srgb, var(--accent-color), transparent 50%) 50%); */ + background: linear-gradient( + to top, + transparent, + 50%, + rgba(53, 132, 228, 0.2) 50% + ); +} +.drop-overlay.drop-bottom { + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background: linear-gradient(to bottom, transparent, 50%, color-mix(in srgb, var(--accent-color), transparent 50%) 50%); */ + background: linear-gradient( + to bottom, + transparent, + 50%, + rgba(53, 132, 228, 0.2) 50% + ); +} + /* * Command Palette */ diff --git a/src/apprt/gtk/ipc/new_tab.zig b/src/apprt/gtk/ipc/new_tab.zig new file mode 100644 index 000000000..c5e538950 --- /dev/null +++ b/src/apprt/gtk/ipc/new_tab.zig @@ -0,0 +1,71 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const glib = @import("glib"); + +const apprt = @import("../../../apprt.zig"); +const DBus = @import("DBus.zig"); + +// Use a D-Bus method call to open a new tab on GTK. +// See: https://wiki.gnome.org/Projects/GLib/GApplication/DBusAPI +// +// `ghostty +new-tab` is equivalent to the following command (on a release build): +// +// ``` +// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-tab '[<@(tas) (0, [])>]' [] +// ``` +// +// `ghostty +new-tab -e echo hello` would be equivalent to the following command (on a release build): +// +// ``` +// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-tab '[<@(tas) (0, ["-e" "echo" "hello"])>]' [] +// ``` +pub fn newTab(alloc: Allocator, target: apprt.ipc.Target, value: apprt.ipc.Action.NewTab) (Allocator.Error || std.Io.Writer.Error || apprt.ipc.Errors)!bool { + var dbus = try DBus.init(alloc, target, "new-tab"); + defer dbus.deinit(alloc); + + const tas_variant_type = glib.VariantType.new("(tas)"); + defer tas_variant_type.free(); + + var parameter: glib.VariantBuilder = undefined; + parameter.init(tas_variant_type); + errdefer parameter.clear(); + + { + // Add the target surface ID to the parameter. + const t = glib.Variant.newUint64(value.surface_id); + parameter.addValue(t); + } + + { + // If any arguments were specified on the command line, this value is an + // array of strings that contain the arguments. They will be sent to the + // main Ghostty instance and interpreted as CLI arguments. + const as_variant_type = glib.VariantType.new("as"); + defer as_variant_type.free(); + + const s_variant_type = glib.VariantType.new("s"); + defer s_variant_type.free(); + + var command: glib.VariantBuilder = undefined; + command.init(as_variant_type); + errdefer command.clear(); + + if (value.arguments) |arguments| { + for (arguments) |argument| { + const bytes = glib.Bytes.new(argument.ptr, argument.len + 1); + defer bytes.unref(); + const string = glib.Variant.newFromBytes(s_variant_type, bytes, @intFromBool(true)); + command.addValue(string); + } + } + + parameter.addValue(command.end()); + } + + dbus.addParameter(parameter.end()); + + try dbus.send(); + + return true; +} diff --git a/src/apprt/gtk/ui/1.2/config-errors-dialog.blp b/src/apprt/gtk/ui/1.2/config-errors-dialog.blp index 845909eb3..77c71edee 100644 --- a/src/apprt/gtk/ui/1.2/config-errors-dialog.blp +++ b/src/apprt/gtk/ui/1.2/config-errors-dialog.blp @@ -4,7 +4,9 @@ using Adw 1; template $GhosttyConfigErrorsDialog: $GhosttyDialog { heading: _("Configuration Errors"); - body: _("One or more configuration errors were found. Please review the errors below, and either reload your configuration or ignore these errors."); + body: _( + "One or more configuration errors were found. Please review the errors below, and either reload your configuration or ignore these errors." + ); responses [ ignore: _("Ignore"), diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index 6523d4149..8ab2e3109 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -46,7 +46,12 @@ template $GhosttySearchOverlay: Adw.Bin { "dim-label", ] - label: bind $match_label_closure(template.has-search-selected, template.search-selected, template.has-search-total, template.search-total) as ; + label: bind $match_label_closure( + template.has-search-selected, + template.search-selected, + template.has-search-total, + template.search-total + ) as ; width-chars: 6; xalign: 1.0; } diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 2ae0a347e..010798ae5 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -1,4 +1,5 @@ using Gtk 4.0; +using Gdk 4.0; using Adw 1; Adw.StatusPage error_page { @@ -96,7 +97,9 @@ Overlay terminal_page { // TODO: the tooltip doesn't actually work, but keep it here for now so // that we can get the tooltip text translated. has-tooltip: true; - tooltip-text: _("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application."); + tooltip-text: _( + "This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application." + ); halign: end; valign: start; spacing: 6; @@ -205,7 +208,11 @@ Overlay terminal_page { // Apply unfocused-split-fill and unfocused-split-opacity to current surface // this is only applied when a tab has more than one surface Revealer { - reveal-child: bind $should_unfocused_split_be_shown(search_overlay.active, template.focused, template.is-split) as ; + reveal-child: bind $should_unfocused_split_be_shown( + search_overlay.active, + template.focused, + template.is-split + ) as ; transition-duration: 0; // This is all necessary so that the Revealer itself doesn't override // any input events from the other overlays. Namely, if you don't have @@ -221,10 +228,57 @@ Overlay terminal_page { } } + [overlay] + CenterBox drag_handle { + halign: fill; + valign: start; + visible: bind $should_drag_handle_be_shown(template.config, template.is-split) as ; + + styles [ + "drag-handle", + ] + + [center] + Image { + icon-name: "view-more-horizontal-symbolic"; + } + + DragSource surface_drag_source { + actions: move; + prepare => $surface_drag_prepare(); + drag-begin => $surface_drag_begin(); + } + } + + [overlay] + DrawingArea drop_overlay { + halign: fill; + valign: fill; + can-focus: false; + can-target: false; + focusable: false; + + styles [ + "drop-overlay", + ] + } + DropTarget drop_target { drop => $drop(); actions: copy | move; } + + DropTarget surface_drop_target { + // Enabling preload is required since that lets us know + // which surface is currently being dragged, and to turn off + // the drop overlay if the source and target are the same + preload: true; + leave => $surface_drop_leave(); + motion => $surface_drop_motion(); + drop => $surface_drop(); + notify::value => $notify_drop_value(); + actions: move; + } } template $GhosttySurface: Adw.Bin { @@ -348,6 +402,11 @@ menu context_menu_model { submenu { label: _("Window"); + item { + label: _("Change Window Title…"); + action: "win.prompt-window-title"; + } + item { label: _("New Window"); action: "win.new-window"; @@ -365,8 +424,15 @@ menu context_menu_model { label: _("Config"); item { - label: _("Open Configuration"); + label: _("Open Configuration in OS Editor"); action: "app.open-config"; + target: "os-open"; + } + + item { + label: _("Open Configuration in New Window"); + action: "app.open-config"; + target: "new-window"; } item { diff --git a/src/apprt/gtk/ui/1.5/tab.blp b/src/apprt/gtk/ui/1.5/tab.blp index 55f2e7ef4..21a30bec7 100644 --- a/src/apprt/gtk/ui/1.5/tab.blp +++ b/src/apprt/gtk/ui/1.5/tab.blp @@ -8,7 +8,14 @@ template $GhosttyTab: Box { orientation: vertical; hexpand: true; vexpand: true; - title: bind $computed_title(template.config, split_tree.active-surface as <$GhosttySurface>.title, split_tree.active-surface as <$GhosttySurface>.title-override, template.title-override, split_tree.is-zoomed, split_tree.active-surface as <$GhosttySurface>.bell-ringing) as ; + title: bind $computed_title( + template.config, + split_tree.active-surface as <$GhosttySurface>.title, + split_tree.active-surface as <$GhosttySurface>.title-override, + template.title-override, + split_tree.is-zoomed, + split_tree.active-surface as <$GhosttySurface>.bell-ringing + ) as ; tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd; $GhosttySplitTree split_tree { diff --git a/src/apprt/gtk/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp index b66a93093..32d1bf370 100644 --- a/src/apprt/gtk/ui/1.5/window.blp +++ b/src/apprt/gtk/ui/1.5/window.blp @@ -40,14 +40,17 @@ template $GhosttyWindow: Adw.ApplicationWindow { visible: bind template.headerbar-visible; title-widget: Adw.WindowTitle { - title: bind template.title; + title: bind $computed_title(template.config, template.title, template.title-override) as ; // Blueprint auto-formatter won't let me split this into multiple // lines. Let me explain myself. All parameters to a closure are used // as notifications to recompute the value of the closure. All // elements of a property chain are also subscribed to for changes. // This one long, ugly line saves us from manually building up this // massive notify chain in code. - subtitle: bind $computed_subtitle(template.config, tab_view.selected-page.child as <$GhosttyTab>.active-surface as <$GhosttySurface>.pwd) as ; + subtitle: bind $computed_subtitle( + template.config, + tab_view.selected-page.child as <$GhosttyTab>.active-surface as <$GhosttySurface>.pwd + ) as ; }; [start] @@ -208,6 +211,11 @@ menu main_menu { } section { + item { + label: _("Change Window Title…"); + action: "win.prompt-window-title"; + } + item { label: _("New Window"); action: "win.new-window"; @@ -291,14 +299,25 @@ menu main_menu { action: "win.toggle-inspector"; } - item { - label: _("Open Configuration"); - action: "app.open-config"; - } + submenu { + label: _("Config"); - item { - label: _("Reload Configuration"); - action: "app.reload-config"; + item { + label: _("Open Configuration in OS Editor"); + action: "app.open-config"; + target: "os-open"; + } + + item { + label: _("Open Configuration in New Window"); + action: "app.open-config"; + target: "new-window"; + } + + item { + label: _("Reload Configuration"); + action: "app.reload-config"; + } } } diff --git a/src/apprt/gtk/weak_ref.zig b/src/apprt/gtk/weak_ref.zig index f689e45fa..73cc6e511 100644 --- a/src/apprt/gtk/weak_ref.zig +++ b/src/apprt/gtk/weak_ref.zig @@ -22,6 +22,23 @@ pub fn WeakRef(comptime T: type) type { } } + /// Release this weak reference. + /// + /// You MUST call this before the memory holding this struct is freed, + /// which in practice means from the owner's `dispose`. The target keeps + /// a pointer to this `GWeakRef` so that it can clear it when the target + /// is finalized; if this memory is gone by then, the target walks into + /// whatever now occupies it. That is an invalid read at best, and can + /// hang: the target takes a lock inside each registered weak ref, and + /// reused memory with the low bit set is a lock nothing will release. + /// + /// `set(null)` also unregisters and remains valid. This exists so the + /// requirement has a name at the use site rather than looking like an + /// ordinary assignment. + pub fn deinit(self: *Self) void { + self.ref.clear(); + } + /// Get a strong reference to the object, or null if the object /// has been finalized. This increases the reference count by one. pub fn get(self: *Self) ?*T { diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index dc3647679..81481514e 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -22,6 +22,7 @@ const input = @import("../../../input.zig"); const ApprtWindow = @import("../class/window.zig").Window; const GlobalShortcuts = @import("../class/global_shortcuts.zig").GlobalShortcuts; const BlurRegion = @import("BlurRegion.zig"); +const gtk_version = @import("../gtk_version.zig"); const log = std.log.scoped(.winproto_wayland); @@ -174,6 +175,12 @@ pub const Window = struct { }; const bg_effect: ?*ext.BackgroundEffectSurfaceV1 = bg: { + if (gtk_version.runtimeAtLeast(4, 23, 3)) { + // GTK 4.23.3 added an official way to do background blur, + // so we don't need to do anything on our own. + break :bg null; + } + const mgr = app.globals.get(.ext_background_effect) orelse break :bg null; @@ -299,7 +306,7 @@ pub const Window = struct { } const wl_region = try compositor.createRegion(); - errdefer if (wl_region) |r| r.destroy(); + defer wl_region.destroy(); for (region.slices.items) |s| wl_region.add( @intCast(s.x), @intCast(s.y), @@ -308,6 +315,7 @@ pub const Window = struct { ); bg.setBlurRegion(wl_region); + self.blur_region.deinit(self.globals.alloc); self.blur_region = region; } diff --git a/src/apprt/gtk/winproto/wayland/Hotkeys.zig b/src/apprt/gtk/winproto/wayland/Hotkeys.zig index 4c5fda357..91e546465 100644 --- a/src/apprt/gtk/winproto/wayland/Hotkeys.zig +++ b/src/apprt/gtk/winproto/wayland/Hotkeys.zig @@ -82,7 +82,8 @@ pub fn init(alloc: Allocator, app_id: [:0]const u8) Allocator.Error!Hotkeys { /// Must leave the entries in a valid empty state: clear may still be /// called after deinit during application teardown. pub fn deinit(self: *Hotkeys) void { - self.clear(); + for (self.entries.items) |*entry| entry.deinit(); + self.entries.clearAndFree(self.alloc); self.alloc.free(self.app_id); } diff --git a/src/apprt/ipc.zig b/src/apprt/ipc.zig index ade44da19..d5d860a73 100644 --- a/src/apprt/ipc.zig +++ b/src/apprt/ipc.zig @@ -70,23 +70,82 @@ pub const Action = union(enum) { // entry. If the value type is void then only the key needs to be // added. Ensure the order matches exactly with the Zig code. - /// The arguments to pass to Ghostty as the command. + /// Create a new window. new_window: NewWindow, + /// Create a new tab. + new_tab: NewTab, + /// Toggle the quick terminal. toggle_quick_terminal: void, pub const NewWindow = struct { - /// A list of command arguments to launch in the new window. If this is - /// `null` the command configured in the config or the user's default - /// shell should be launched. + /// A list of configuration entries that will override the configuration + /// of the first surface of the new window. If this is `null` there + /// are no overrides. Note that not all configuration entries may be + /// overridden. /// /// It is an error for this to be non-`null`, but zero length. arguments: ?[][:0]const u8, pub const C = extern struct { - /// null terminated list of arguments - /// it will be null itself if there are no arguments + /// A list of configuration entries that will override the + /// configuration of the first surface of the new tab. If this is + /// `null` there are no overrides. Note that not all configuration + /// entries may be overridden. + /// + /// It is an error for this to be non-`null`, but zero length. + arguments: ?[*]?[*:0]const u8, + + pub fn deinit(self: *NewWindow.C, alloc: Allocator) void { + if (self.arguments) |arguments| alloc.free(arguments); + } + }; + + pub fn cval(self: *NewWindow, alloc: Allocator) Allocator.Error!NewWindow.C { + var result: NewWindow.C = undefined; + + if (self.arguments) |arguments| { + result.arguments = try alloc.alloc([*:0]const u8, arguments.len + 1); + + for (arguments, 0..) |argument, i| + result.arguments[i] = argument.ptr; + + // add null terminator + result.arguments[arguments.len] = null; + } else { + result.arguments = null; + } + + return result; + } + }; + + pub const NewTab = struct { + /// The unique ID of a surface. If the ID is zero the currently focused + /// surface is assumed. This will be used to identify the window to add + /// the tab to. + surface_id: u64, + + /// A list of configuration entries that will override the configuration + /// of the first surface of the new tab. If this is `null` there + /// are no overrides. Note that not all configuration entries may be + /// overridden. + /// + /// It is an error for this to be non-`null`, but zero length. + arguments: ?[][:0]const u8, + + pub const C = extern struct { + /// The unique ID of a surface. If the ID is zero the currently + /// focused surface is assumed. This will be used to identify the + /// window to add the tab to. + surface_id: u64, + /// A list of configuration entries that will override the + /// configuration of the first surface of the new tab. If this is + /// `null` there are no overrides. Note that not all configuration + /// entries may be overridden. + /// + /// It is an error for this to be non-`null`, but zero length. arguments: ?[*]?[*:0]const u8, pub fn deinit(self: *NewWindow.C, alloc: Allocator) void { @@ -116,6 +175,7 @@ pub const Action = union(enum) { /// Sync with: ghostty_ipc_action_tag_e pub const Key = enum(c_int) { new_window, + new_tab, toggle_quick_terminal, test "ghostty.h Action.Key" { @@ -158,8 +218,8 @@ pub const Action = union(enum) { // At the time of writing, we don't promise ABI compatibility // so we can change this but I want to be aware of it. assert(@sizeOf(CValue) == switch (@sizeOf(usize)) { - 4 => 4, - 8 => 8, + 4 => 12, + 8 => 16, else => unreachable, }); } diff --git a/src/benchmark/ApcParser.zig b/src/benchmark/ApcParser.zig index 8b5f883fd..9efdc4fbf 100644 --- a/src/benchmark/ApcParser.zig +++ b/src/benchmark/ApcParser.zig @@ -52,10 +52,10 @@ const Handler = struct { .apc_start => self.apc.start(), .apc_put => self.apc.feed(self.alloc, value), .apc_put_slice => self.apc.feedSlice(self.alloc, value.bytes), - .apc_end => if (self.apc.end()) |cmd| { - var c = cmd; - std.mem.doNotOptimizeAway(&c); - c.deinit(self.alloc); + .apc_end => if (self.apc.end()) |result| { + var r = result; + std.mem.doNotOptimizeAway(&r); + r.deinit(self.alloc); }, else => {}, } @@ -71,7 +71,10 @@ pub fn create( errdefer alloc.destroy(ptr); ptr.* = .{ .opts = opts, - .stream = .init(.{ .alloc = alloc }), + .stream = .init(.{ + .allocator = alloc, + .handler = .{ .alloc = alloc }, + }), }; return ptr; } diff --git a/src/benchmark/TerminalFormatter.zig b/src/benchmark/TerminalFormatter.zig new file mode 100644 index 000000000..fb763ab52 --- /dev/null +++ b/src/benchmark/TerminalFormatter.zig @@ -0,0 +1,482 @@ +//! Benchmarks the terminal formatter (`terminal/formatter.zig`). +//! +//! The formatter is the hot path for clipboard copy (plain/VT/HTML), +//! `write_screen_file`, `selectionString`, terminal search window +//! encoding, and the libghostty-vt formatter C API. This benchmark +//! measures formatting terminal contents that were built during setup +//! (outside the timed region) from a pre-generated VT stream. +//! +//! ## Input +//! +//! `--data` names a pre-generated VT byte stream (for example from +//! `ghostty-gen styled`). The stream is fed to a terminal of the +//! requested dimensions with unlimited scrollback during setup. The +//! resulting screen contents (scrollback included) are what each step +//! formats. +//! +//! ## Modes +//! +//! * `noop` performs no formatting and establishes loop/setup overhead. +//! Subtract this from `format` timings when using hyperfine. +//! * `format` formats the configured region once per loop into a +//! reusable buffer. Buffer growth happens on the first iteration only. +//! * `report` formats once and prints content/output sizes. It is for +//! computing throughput (cells/s, bytes/s), not timing comparisons. +//! +//! ## Examples +//! +//! Build benchmarks in ReleaseFast mode: +//! +//! zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false +//! +//! Generate a deterministic corpus, then measure: +//! +//! ghostty-gen styled --seed=42 | head -c 640000 > /tmp/plain.vt +//! hyperfine --warmup 3 \ +//! 'ghostty-bench +terminal-formatter --mode=noop --data=/tmp/plain.vt' \ +//! 'ghostty-bench +terminal-formatter --emit=plain --loops=50 --data=/tmp/plain.vt' \ +//! 'ghostty-bench +terminal-formatter --emit=vt --loops=50 --data=/tmp/plain.vt' +const TerminalFormatter = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const terminalpkg = @import("../terminal/main.zig"); +const formatterpkg = terminalpkg.formatter; +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const Terminal = terminalpkg.Terminal; +const Selection = terminalpkg.Selection; +const global = @import("../global.zig"); + +const log = std.log.scoped(.@"terminal-formatter-bench"); + +alloc: Allocator, +opts: Options, +terminal: ?Terminal = null, + +/// Reused across steps so buffer growth is a one-time setup cost. +output: std.Io.Writer.Allocating, + +/// Reused pin map storage for `--pin-map=true`. +pins: formatterpkg.PinMap.Map = .empty, + +pub const Options = struct { + /// Set by the shared CLI parser for string option ownership. + _arena: ?std.heap.ArenaAllocator = null, + + /// Select the operation performed inside the timed benchmark step. + mode: Mode = .format, + + /// The output format to emit. + emit: Emit = .vt, + + /// The region of the screen to format. + region: Region = .screen, + + /// Unwrap soft-wrapped lines. + unwrap: bool = false, + + /// Track the source pin of every emitted byte. This exercises the + /// (documented as expensive) pin_map path used by selectionString + /// and search. + @"pin-map": bool = false, + + /// Number of format operations per benchmark step. Increase this + /// when the content is too small for stable hyperfine measurements. + loops: u32 = 25, + + /// The size of the terminal. This affects wrapping and page sizes. + @"terminal-rows": u16 = 24, + @"terminal-cols": u16 = 80, + + /// Pre-generated VT stream fed to the terminal during setup. `-` + /// reads stdin, although a regular file is recommended so identical + /// state can be reused across runs. When unset, the terminal is + /// empty. + data: ?[]const u8 = null, + + pub fn deinit(self: *Options) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } +}; + +pub const Mode = enum { + /// Establish the benchmark loop and setup overhead. + noop, + + /// Format the configured region once per loop. + format, + + /// Print content and output sizes. Not a timing benchmark. + report, + + /// Semantic verification, not a timing benchmark: format the + /// terminal (must be `--emit=vt`), feed the output into a fresh + /// terminal of the same dimensions, format that, and verify the + /// two outputs converge to identical bytes. This proves the VT + /// output faithfully reconstructs the terminal content even when + /// the exact byte encoding changes. + roundtrip, +}; + +pub const Emit = enum { + plain, + vt, + html, + + fn format(self: Emit) formatterpkg.Format { + return switch (self) { + .plain => .plain, + .vt => .vt, + .html => .html, + }; + } +}; + +pub const Region = enum { + /// Everything: scrollback and active screen. + screen, + + /// Only the active screen (bottom rows). + active, + + /// Only the scrollback. + history, +}; + +pub fn create( + alloc: Allocator, + opts: Options, +) !*TerminalFormatter { + const ptr = try alloc.create(TerminalFormatter); + errdefer alloc.destroy(ptr); + ptr.* = .{ + .alloc = alloc, + .opts = opts, + .output = .init(alloc), + }; + return ptr; +} + +pub fn destroy(self: *TerminalFormatter, alloc: Allocator) void { + if (self.terminal) |*t| t.deinit(self.alloc); + self.output.deinit(); + self.pins.deinit(self.alloc); + alloc.destroy(self); +} + +pub fn benchmark(self: *TerminalFormatter) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .noop => stepNoop, + .format => stepFormat, + .report => stepReport, + .roundtrip => stepRoundtrip, + }, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +/// Build the terminal state every mode shares. All of this is outside +/// the timed region for `Benchmark`, but is included in whole-process +/// timings, hence the `noop` mode. +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalFormatter = @ptrCast(@alignCast(ptr)); + self.setupImpl() catch |err| { + log.warn("failed to prepare formatter benchmark err={}", .{err}); + return error.BenchmarkFailed; + }; +} + +fn setupImpl(self: *TerminalFormatter) !void { + if (self.terminal) |*t| t.deinit(self.alloc); + self.terminal = null; + self.terminal = try Terminal.init(global.io(), self.alloc, .{ + .cols = self.opts.@"terminal-cols", + .rows = self.opts.@"terminal-rows", + .max_scrollback_bytes = null, + .max_scrollback_lines = null, + }); + const terminal = &self.terminal.?; + + // Feed the input corpus through the standard VT stream. + if (try options.dataFile(self.opts.data)) |data_f| { + defer data_f.close(global.io()); + + var stream = terminal.vtStream(); + defer stream.deinit(); + + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var f_reader = data_f.reader(global.io(), &read_buf); + const r = &f_reader.interface; + + var buf: [4096]u8 = undefined; + while (true) { + const n = try r.readSliceShort(&buf); + if (n == 0) break; // EOF reached + stream.nextSlice(buf[0..n]); + } + } +} + +fn teardown(ptr: *anyopaque) void { + const self: *TerminalFormatter = @ptrCast(@alignCast(ptr)); + if (self.terminal) |*t| t.deinit(self.alloc); + self.terminal = null; + self.output.shrinkRetainingCapacity(0); + self.pins.clearRetainingCapacity(); +} + +/// Build the screen formatter matching our options. This mirrors how +/// Surface clipboard copy and write_screen_file construct formatters. +fn formatter(self: *TerminalFormatter) ?formatterpkg.ScreenFormatter { + const screen = self.terminal.?.screens.active; + + var f: formatterpkg.ScreenFormatter = .init(screen, .{ + .emit = self.opts.emit.format(), + .unwrap = self.opts.unwrap, + }); + + f.content = switch (self.opts.region) { + .screen => .{ .selection = null }, + + inline .active, .history => |region| content: { + const tag: terminalpkg.point.Tag = switch (region) { + .active => .active, + .history => .history, + .screen => unreachable, + }; + const tl = screen.pages.getTopLeft(tag); + const br = screen.pages.getBottomRight(tag) orelse return null; + break :content .{ .selection = Selection.init(tl, br, false) }; + }, + }; + + return f; +} + +fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalFormatter = @ptrCast(@alignCast(ptr)); + for (0..self.opts.loops) |_| { + std.mem.doNotOptimizeAway(self.output.written()); + } +} + +fn stepFormat(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalFormatter = @ptrCast(@alignCast(ptr)); + for (0..self.opts.loops) |_| { + self.output.shrinkRetainingCapacity(0); + + var f = self.formatter() orelse continue; + if (self.opts.@"pin-map") { + self.pins.clearRetainingCapacity(); + f.pin_map = .{ .alloc = self.alloc, .map = &self.pins }; + } + + f.format(&self.output.writer) catch |err| { + log.warn("formatting failed err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(self.output.written()); + } +} + +/// Print the content dimensions and emitted output size. This shares +/// the formatting code with format mode but deliberately makes no +/// timing claims. Use it to compute cells/s and bytes/s from timings. +fn stepReport(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalFormatter = @ptrCast(@alignCast(ptr)); + + self.output.shrinkRetainingCapacity(0); + if (self.formatter()) |f_init| { + var f = f_init; + if (self.opts.@"pin-map") { + self.pins.clearRetainingCapacity(); + f.pin_map = .{ .alloc = self.alloc, .map = &self.pins }; + } + f.format(&self.output.writer) catch |err| { + log.warn("formatting failed err={}", .{err}); + return error.BenchmarkFailed; + }; + } + + // Count the total pages and rows in the pagelist. + const screen = self.terminal.?.screens.active; + var pages: usize = 0; + var rows: usize = 0; + { + var node = screen.pages.pages.first; + while (node) |n| : (node = n.next) { + pages += 1; + rows += n.page().size.rows; + } + } + + // Hash the output (and the pin coordinates, which are stable across + // runs unlike the node pointers) so different implementations can be + // checked for identical output. + const out_hash = std.hash.Wyhash.hash(0, self.output.written()); + var pin_hasher = std.hash.Wyhash.init(0); + for (self.pins.points.items) |coord| { + const x: u16 = coord.x; + const y: u16 = @intCast(coord.y); + pin_hasher.update(std.mem.asBytes(&x)); + pin_hasher.update(std.mem.asBytes(&y)); + } + + std.debug.print( + "terminal-formatter emit={s} region={s} pages={d} rows={d} " ++ + "cols={d} cells={d} out_bytes={d} pin_bytes={d} " ++ + "out_hash={x} pin_hash={x}\n", + .{ + @tagName(self.opts.emit), + @tagName(self.opts.region), + pages, + rows, + self.opts.@"terminal-cols", + rows * self.opts.@"terminal-cols", + self.output.written().len, + self.pins.count(), + out_hash, + pin_hasher.final(), + }, + ); + + // Pin map storage details on a separate line so the main report + // line remains comparable across implementations. + if (self.opts.@"pin-map") { + std.debug.print( + "terminal-formatter-pins points={d} nodes={d}\n", + .{ self.pins.points.items.len, self.pins.nodes.items.len }, + ); + } +} + +fn stepRoundtrip(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalFormatter = @ptrCast(@alignCast(ptr)); + self.stepRoundtripImpl() catch |err| { + log.warn("roundtrip failed err={}", .{err}); + return error.BenchmarkFailed; + }; +} + +/// Format the terminal, replay the output into a fresh terminal of the +/// same dimensions, format that, and require both outputs to be +/// identical. This verifies that the emitted VT sequences faithfully +/// reconstruct the terminal contents (text, styles, wrapping) without +/// requiring any specific byte encoding of the first output. +fn stepRoundtripImpl(self: *TerminalFormatter) !void { + // Format the original terminal. + self.output.shrinkRetainingCapacity(0); + if (self.formatter()) |f_init| { + var f = f_init; + try f.format(&self.output.writer); + } + const first = self.output.written(); + + // Replay into a fresh terminal. + var t2 = try Terminal.init(global.io(), self.alloc, .{ + .cols = self.opts.@"terminal-cols", + .rows = self.opts.@"terminal-rows", + .max_scrollback_bytes = null, + .max_scrollback_lines = null, + }); + defer t2.deinit(self.alloc); + { + var stream = t2.vtStream(); + defer stream.deinit(); + stream.nextSlice(first); + } + + // Format the replayed terminal identically. + var out2: std.Io.Writer.Allocating = .init(self.alloc); + defer out2.deinit(); + var f2: formatterpkg.ScreenFormatter = .init(t2.screens.active, .{ + .emit = self.opts.emit.format(), + .unwrap = self.opts.unwrap, + }); + try f2.format(&out2.writer); + const second = out2.written(); + + const equal = std.mem.eql(u8, first, second); + std.debug.print( + "terminal-formatter roundtrip emit={s} bytes={d} replay_bytes={d} equal={}\n", + .{ @tagName(self.opts.emit), first.len, second.len, equal }, + ); + + if (!equal) { + // Find the first differing offset to ease debugging. + const n = @min(first.len, second.len); + var i: usize = 0; + while (i < n and first[i] == second[i]) i += 1; + std.debug.print( + "terminal-formatter roundtrip mismatch offset={d} " ++ + "first={f} second={f}\n", + .{ + i, + std.zig.fmtString(first[i -| 32..@min(first.len, i + 32)]), + std.zig.fmtString(second[i -| 32..@min(second.len, i + 32)]), + }, + ); + return error.RoundtripMismatch; + } +} + +test TerminalFormatter { + const testing = std.testing; + const impl: *TerminalFormatter = try .create(testing.allocator, .{}); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} + +test "TerminalFormatter roundtrip" { + const testing = std.testing; + const impl: *TerminalFormatter = try .create(testing.allocator, .{ + .mode = .roundtrip, + .@"terminal-rows" = 4, + .@"terminal-cols" = 8, + }); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} + +test "TerminalFormatter formats all emit formats and regions" { + const testing = std.testing; + + inline for (.{ Emit.plain, Emit.vt, Emit.html }) |emit| { + inline for (.{ Region.screen, Region.active, Region.history }) |region| { + const impl: *TerminalFormatter = try .create(testing.allocator, .{ + .emit = emit, + .region = region, + .loops = 1, + .@"terminal-rows" = 4, + .@"terminal-cols" = 8, + }); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); + } + } +} + +test "TerminalFormatter pin map" { + const testing = std.testing; + const impl: *TerminalFormatter = try .create(testing.allocator, .{ + .@"pin-map" = true, + .loops = 1, + .@"terminal-rows" = 4, + .@"terminal-cols" = 8, + }); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/src/benchmark/TerminalResize.zig b/src/benchmark/TerminalResize.zig new file mode 100644 index 000000000..61b908c83 --- /dev/null +++ b/src/benchmark/TerminalResize.zig @@ -0,0 +1,357 @@ +//! This benchmark tests the performance of Terminal.resize, with a +//! primary focus on column resizes that reflow soft-wrapped text. +//! Resize happens on the IO thread while holding the terminal lock, +//! so a slow resize directly translates into dropped input and a +//! frozen-feeling UI while the user drags the window edge (which +//! produces a rapid stream of resizes). +//! +//! The terminal is populated once during setup (synthetic fill and/or +//! a data file replayed through the VT stream) and then each step +//! ping-pongs the terminal between two sizes. A full cycle returns the +//! terminal to its original dimensions so the state reaches a steady +//! state after the first cycle and every iteration performs +//! equivalent work. +const TerminalResize = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const terminalpkg = @import("../terminal/main.zig"); +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const Terminal = terminalpkg.Terminal; +const global = @import("../global.zig"); + +const log = std.log.scoped(.@"terminal-resize-bench"); + +opts: Options, +alloc: Allocator, +terminal: Terminal, + +pub const Options = struct { + /// The resize pattern to benchmark. See Mode. + mode: Mode = .cols, + + /// Multiplier on the number of resize cycles each step runs. This + /// is useful to make a benchmark run long enough for profiling. + loops: u32 = 1, + + /// The initial size of the terminal. This is also the size that + /// every resize cycle returns to. + @"terminal-rows": u16 = 80, + @"terminal-cols": u16 = 120, + + /// The dimensions to resize to for the cols/rows/both modes. If + /// unset, they default to half of the respective terminal + /// dimension, which forces every soft-wrapped line to rewrap. + @"resize-cols": ?u16 = null, + @"resize-rows": ?u16 = null, + + /// The number of synthetic lines written to the terminal during + /// setup. The content is deterministic: a mix of short lines, + /// soft-wrapped long lines, blank lines, styled cells, and wide + /// characters, since all of these hit different reflow paths. + /// Set to 0 to only use `data`. + @"fill-lines": u32 = 10_000, + + /// The maximum scrollback size in bytes. Defaults to the Ghostty + /// application default. Reflow cost scales with the amount of + /// scrollback, not just the visible screen. + @"scrollback-bytes": usize = 50_000_000, + + /// The data to read as a filepath. If this is "-" then + /// we will read stdin. If this is unset, only the synthetic fill + /// is used. The data is streamed into the terminal during setup + /// (not part of the benchmark) to build the screen contents that + /// get resized. + data: ?[]const u8 = null, +}; + +pub const Mode = enum { + /// Resize to the current dimensions. This measures the early-exit + /// path (mode updates, pixel geometry) and acts as a baseline. + noop, + + /// Alternate the column count between `terminal-cols` and + /// `resize-cols`. With wraparound enabled (the default), this + /// reflows text in both directions: shrinking wraps long lines + /// and growing unwraps them. This is the primary reflow benchmark. + cols, + + /// Like `cols`, but with wraparound mode disabled so the resize + /// does not reflow. Useful as a baseline to isolate the cost of + /// reflow itself from the rest of the resize. + @"cols-no-reflow", + + /// Alternate the row count between `terminal-rows` and + /// `resize-rows`. Column count is unchanged so no text reflows; + /// this measures growing/trimming rows against scrollback. + rows, + + /// Alternate both dimensions at once, like a diagonal window drag. + both, +}; + +pub fn create( + alloc: Allocator, + opts: Options, +) !*TerminalResize { + const ptr = try alloc.create(TerminalResize); + errdefer alloc.destroy(ptr); + + ptr.* = .{ + .opts = opts, + .alloc = alloc, + .terminal = try .init(global.io(), alloc, .{ + .rows = opts.@"terminal-rows", + .cols = opts.@"terminal-cols", + .max_scrollback_bytes = opts.@"scrollback-bytes", + }), + }; + + return ptr; +} + +pub fn destroy(self: *TerminalResize, alloc: Allocator) void { + self.terminal.deinit(alloc); + alloc.destroy(self); +} + +pub fn benchmark(self: *TerminalResize) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .noop => stepNoop, + .cols, .@"cols-no-reflow" => stepCols, + .rows => stepRows, + .both => stepBoth, + }, + .setupFn = setup, + }); +} + +/// The column count used by the cols/both modes. +fn targetCols(self: *const TerminalResize) u16 { + return self.opts.@"resize-cols" orelse + @max(1, self.opts.@"terminal-cols" / 2); +} + +/// The row count used by the rows/both modes. +fn targetRows(self: *const TerminalResize) u16 { + return self.opts.@"resize-rows" orelse + @max(1, self.opts.@"terminal-rows" / 2); +} + +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalResize = @ptrCast(@alignCast(ptr)); + + // Always reset our terminal state. Note this doesn't resize, but + // create initializes (and steps return) the terminal to the + // requested dimensions so we're always at terminal-rows/cols here. + self.terminal.fullReset(); + assert(self.terminal.cols == self.opts.@"terminal-cols"); + assert(self.terminal.rows == self.opts.@"terminal-rows"); + + // Fill with synthetic content first, then replay the data file on + // top if given. Both go through the VT stream so soft wraps, + // styles, etc. are all set exactly as they would be in a real + // session. + self.fill(); + try self.replayData(); + + // Reflow only happens when wraparound mode is set (it is by + // default). We only disable it after filling so the fill itself + // still soft-wraps identically in every mode. + if (self.opts.mode == .@"cols-no-reflow") { + self.terminal.modes.set(.wraparound, false); + } +} + +/// Write deterministic synthetic content to the terminal. The goal is +/// content that is representative of a real session so that reflow +/// touches its interesting paths: soft-wrapped lines (must rewrap), +/// short lines (copied as-is), blank lines, styled cells (styles must +/// be moved across pages), and wide characters (can't be split at the +/// wrap column). +fn fill(self: *TerminalResize) void { + if (self.opts.@"fill-lines" == 0) return; + + var s = self.terminal.vtStream(); + defer s.deinit(); + + var prng: std.Random.DefaultPrng = .init(0xB3); + const rand = prng.random(); + + const cols: usize = self.terminal.cols; + var line_buf: [8192]u8 = undefined; + + for (0..self.opts.@"fill-lines") |i| { + // Periodically toggle a background style so reflow has to + // carry styled cells into new pages. + if (i % 64 == 0) s.nextSlice("\x1b[48;2;20;40;60m"); + if (i % 64 == 32) s.nextSlice("\x1b[m"); + + // A small portion of lines are blank. + if (i % 16 == 15) { + s.nextSlice("\r\n"); + continue; + } + + // Line lengths between ~25% and ~250% of the terminal width + // so we get a mix of short lines and soft-wrapped lines. + const min = @max(1, cols / 4); + const max = @min(line_buf.len, cols * 5 / 2); + const len = min + rand.uintLessThan(usize, max - min); + + var j: usize = 0; + while (j < len) { + // Sprinkle wide characters into every 8th line. + if (i % 8 == 7 and j % 16 == 8 and j + 3 <= len) { + line_buf[j..][0..3].* = "漢".*; + j += 3; + continue; + } + + // Words of ASCII separated by spaces. + line_buf[j] = if (j % 8 == 7) + ' ' + else + rand.intRangeAtMost(u8, 'a', 'z'); + j += 1; + } + + s.nextSlice(line_buf[0..len]); + s.nextSlice("\r\n"); + } +} + +/// Stream the data file (if any) into the terminal. +fn replayData(self: *TerminalResize) Benchmark.Error!void { + const data_f: std.Io.File = (options.dataFile( + self.opts.data, + ) catch |err| { + log.warn("error opening data file err={}", .{err}); + return error.BenchmarkFailed; + }) orelse return; + defer data_f.close(global.io()); + + var stream = self.terminal.vtStream(); + defer stream.deinit(); + + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var f_reader = data_f.reader(global.io(), &read_buf); + const r = &f_reader.interface; + + var buf: [4096]u8 = undefined; + while (true) { + const n = r.readSliceShort(&buf) catch { + log.warn("error reading data file err={?}", .{f_reader.err}); + return error.BenchmarkFailed; + }; + if (n == 0) break; // EOF reached + stream.nextSlice(buf[0..n]); + } +} + +fn resizeTerminal( + self: *TerminalResize, + cols: u16, + rows: u16, +) Benchmark.Error!void { + self.terminal.resize(self.alloc, .{ + .cols = cols, + .rows = rows, + // Realistic cell pixel geometry: real apprt resizes always + // carry it, and it exercises the pixel dimension updates. + .cell_size_px = .{ .width = 10, .height = 20 }, + }) catch |err| { + log.warn("error resizing terminal err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(&self.terminal); +} + +fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalResize = @ptrCast(@alignCast(ptr)); + + const cols = self.opts.@"terminal-cols"; + const rows = self.opts.@"terminal-rows"; + + // We loop because it's so fast (a few ns) that a single resize + // doesn't properly capture our speeds. + for (0..50_000_000 * @as(u64, self.opts.loops)) |_| { + try self.resizeTerminal(cols, rows); + } +} + +fn stepCols(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalResize = @ptrCast(@alignCast(ptr)); + + const cols = self.opts.@"terminal-cols"; + const rows = self.opts.@"terminal-rows"; + const target = self.targetCols(); + + // Per-cycle cost differs by orders of magnitude between the two + // modes (a reflow resize walks the entire scrollback; a non-reflow + // resize doesn't), so pick a cycle count that makes each run long + // enough to measure well above process startup and setup cost. + const cycles: u64 = switch (self.opts.mode) { + .cols => 25, + .@"cols-no-reflow" => 1_500, + else => unreachable, + }; + + // Each cycle shrinks (rewrapping long lines) and grows back + // (unwrapping them), ending at the original size. + for (0..cycles * @as(u64, self.opts.loops)) |_| { + try self.resizeTerminal(target, rows); + try self.resizeTerminal(cols, rows); + } +} + +fn stepRows(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalResize = @ptrCast(@alignCast(ptr)); + + const cols = self.opts.@"terminal-cols"; + const rows = self.opts.@"terminal-rows"; + const target = self.targetRows(); + + // Row-only resizes don't reflow so they're much cheaper (tens of + // ns); loop a lot more so the measurement isn't dominated by + // process overhead. + for (0..2_500_000 * @as(u64, self.opts.loops)) |_| { + try self.resizeTerminal(cols, target); + try self.resizeTerminal(cols, rows); + } +} + +fn stepBoth(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalResize = @ptrCast(@alignCast(ptr)); + + const cols = self.opts.@"terminal-cols"; + const rows = self.opts.@"terminal-rows"; + const target_cols = self.targetCols(); + const target_rows = self.targetRows(); + + for (0..25 * @as(u64, self.opts.loops)) |_| { + try self.resizeTerminal(target_cols, target_rows); + try self.resizeTerminal(cols, rows); + } +} + +test TerminalResize { + const testing = std.testing; + const alloc = testing.allocator; + + // Small dimensions and fill so this is fast in debug builds while + // still exercising real reflow in both directions. + const impl: *TerminalResize = try .create(alloc, .{ + .mode = .cols, + .@"terminal-rows" = 10, + .@"terminal-cols" = 20, + .@"fill-lines" = 50, + }); + defer impl.destroy(alloc); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/src/benchmark/TerminalSnapshot.zig b/src/benchmark/TerminalSnapshot.zig new file mode 100644 index 000000000..aea5e1f96 --- /dev/null +++ b/src/benchmark/TerminalSnapshot.zig @@ -0,0 +1,350 @@ +//! Benchmarks the terminal binary snapshot codecs (`terminal/snapshot`). +//! +//! Encoding and decoding a snapshot are the hot paths for terminal +//! persistence and handoff, so both directions are measured against the +//! same terminal state. +//! +//! ## Input +//! +//! `--data` names a pre-generated VT byte stream (for example from +//! `ghostty-gen ascii`). The stream is fed to a terminal of the requested +//! dimensions during setup, outside the timed region. The resulting screen +//! and scrollback contents are what each step encodes or decodes. +//! +//! ## Modes +//! +//! * `noop` performs no codec work and establishes loop overhead. +//! * `encode` encodes one complete snapshot per loop into a reusable +//! buffer. Buffer growth happens on the first iteration only. +//! * `decode` restores one complete snapshot per loop from bytes prepared +//! during setup. The restored terminal is destroyed inside the step, so +//! this measures the complete restore lifecycle. +//! * `decode-ready` restores only the renderable prefix per loop using the +//! incremental decoder and stops at READY, skipping history. Compared +//! against `decode`, this is the time-to-interactive win of applying +//! history off the critical path. +//! * `report` encodes once and prints total and per-record-tag sizes. It +//! is for inspecting the wire shape, not timing comparisons. +//! +//! ## Examples +//! +//! Build benchmarks in ReleaseFast mode: +//! +//! zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false +//! +//! Generate a deterministic corpus, then measure both directions: +//! +//! ghostty-gen ascii | head -c 1000000 > /tmp/ascii.vt +//! hyperfine --warmup 3 \ +//! 'ghostty-bench +terminal-snapshot --mode=encode --loops=20 --data=/tmp/ascii.vt' \ +//! 'ghostty-bench +terminal-snapshot --mode=decode --loops=20 --data=/tmp/ascii.vt' +const TerminalSnapshot = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const terminalpkg = @import("../terminal/main.zig"); +const snapshot = terminalpkg.snapshot; +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const Terminal = terminalpkg.Terminal; +const global = @import("../global.zig"); + +const log = std.log.scoped(.@"terminal-snapshot-bench"); + +alloc: Allocator, +opts: Options, +terminal: ?Terminal = null, + +/// Reused across encode steps so buffer growth is a one-time setup cost. +encoded: std.Io.Writer.Allocating, + +pub const Options = struct { + /// Set by the shared CLI parser for string option ownership. + _arena: ?std.heap.ArenaAllocator = null, + + /// Select the operation performed inside the timed benchmark step. + mode: Mode = .encode, + + /// Number of codec operations per benchmark step. Increase this when + /// the state is too small for stable `hyperfine` measurements. + loops: u32 = 1, + + /// The size of the terminal. This affects wrapping, page sizes, and + /// the amount of state per encoded page. + @"terminal-rows": u16 = 24, + @"terminal-cols": u16 = 80, + + /// Pre-generated VT stream fed to the terminal during setup. `-` reads + /// stdin, although a regular file is recommended so identical state can + /// be reused across runs. When unset, the terminal is empty. + data: ?[]const u8 = null, + + pub fn deinit(self: *Options) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } +}; + +pub const Mode = enum { + /// Establish the benchmark loop overhead. + noop, + + /// Encode one complete snapshot per loop. + encode, + + /// Restore one complete snapshot per loop, including its teardown. + decode, + + /// Restore one renderable READY prefix per loop, including teardown. + @"decode-ready", + + /// Print encoded sizes by record tag. Not a timing benchmark. + report, +}; + +pub fn create( + alloc: Allocator, + opts: Options, +) !*TerminalSnapshot { + const ptr = try alloc.create(TerminalSnapshot); + errdefer alloc.destroy(ptr); + ptr.* = .{ + .alloc = alloc, + .opts = opts, + .encoded = .init(alloc), + }; + return ptr; +} + +pub fn destroy(self: *TerminalSnapshot, alloc: Allocator) void { + if (self.terminal) |*t| t.deinit(self.alloc); + self.encoded.deinit(); + alloc.destroy(self); +} + +pub fn benchmark(self: *TerminalSnapshot) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .noop => stepNoop, + .encode => stepEncode, + .decode => stepDecode, + .@"decode-ready" => stepDecodeReady, + .report => stepReport, + }, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +/// Build the terminal state every mode shares and, for decode mode, the +/// encoded snapshot it restores. All of this is outside the timed region. +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr)); + self.setupImpl() catch |err| { + log.warn("failed to prepare snapshot benchmark err={}", .{err}); + return error.BenchmarkFailed; + }; +} + +fn setupImpl(self: *TerminalSnapshot) !void { + if (self.terminal) |*t| t.deinit(self.alloc); + self.terminal = null; + self.terminal = try Terminal.init(global.io(), self.alloc, .{ + .cols = self.opts.@"terminal-cols", + .rows = self.opts.@"terminal-rows", + .max_scrollback_bytes = null, + .max_scrollback_lines = null, + }); + const terminal = &self.terminal.?; + + // Feed the input corpus through the standard VT stream. + if (try options.dataFile(self.opts.data)) |data_f| { + defer data_f.close(global.io()); + + var stream = terminal.vtStream(); + defer stream.deinit(); + + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var f_reader = data_f.reader(global.io(), &read_buf); + const r = &f_reader.interface; + + var buf: [4096]u8 = undefined; + while (true) { + const n = try r.readSliceShort(&buf); + if (n == 0) break; // EOF reached + stream.nextSlice(buf[0..n]); + } + } + + // Decode restores the same bytes every step. Encode mode also uses + // this buffer, in which case this is its one-time growth. + self.encoded.shrinkRetainingCapacity(0); + try snapshot.encode( + self.alloc, + &self.encoded.writer, + terminal, + .{ .continuation = .ground }, + ); +} + +fn teardown(ptr: *anyopaque) void { + const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr)); + if (self.terminal) |*t| t.deinit(self.alloc); + self.terminal = null; + self.encoded.shrinkRetainingCapacity(0); +} + +fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr)); + for (0..self.opts.loops) |_| { + std.mem.doNotOptimizeAway(self.encoded.written()); + } +} + +fn stepEncode(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr)); + const terminal = &self.terminal.?; + for (0..self.opts.loops) |_| { + self.encoded.shrinkRetainingCapacity(0); + snapshot.encode( + self.alloc, + &self.encoded.writer, + terminal, + .{ .continuation = .ground }, + ) catch |err| { + log.warn("snapshot encoding failed err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(self.encoded.written()); + } +} + +fn stepDecode(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr)); + const bytes = self.encoded.written(); + for (0..self.opts.loops) |_| { + var reader: std.Io.Reader = .fixed(bytes); + var decoded = snapshot.decode( + self.alloc, + global.io(), + &reader, + .{ .max_continuation_bytes = 1024 * 1024 }, + ) catch |err| { + log.warn("snapshot decoding failed err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(&decoded); + decoded.deinit(self.alloc); + } +} + +/// The renderable-prefix half of decode: everything through READY, leaving +/// all history unread. The gap between this and `stepDecode` is the latency +/// class the incremental decoder removes from time-to-interactive. +fn stepDecodeReady(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr)); + const bytes = self.encoded.written(); + for (0..self.opts.loops) |_| { + var reader: std.Io.Reader = .fixed(bytes); + var decoder: snapshot.Decoder = .init(&reader); + var decoded = decoder.ready( + self.alloc, + global.io(), + .{ .max_continuation_bytes = 1024 * 1024 }, + ) catch |err| { + log.warn("snapshot READY decoding failed err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(&decoded); + decoded.deinit(self.alloc); + } +} + +/// Print the encoded size grouped by record tag. This shares the encoder +/// with encode mode but deliberately makes no timing claims. +fn stepReport(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr)); + const bytes = self.encoded.written(); + + var payload_totals = std.enums.EnumArray( + snapshot.record.Tag, + u64, + ).initFill(0); + var record_counts = std.enums.EnumArray( + snapshot.record.Tag, + u64, + ).initFill(0); + var record_total: u64 = 0; + + var offset: usize = snapshot.envelope.encoded_len; + while (offset + snapshot.record.Header.len <= bytes.len) { + const tag_raw = std.mem.readInt(u16, bytes[offset..][0..2], .little); + const payload_len = std.mem.readInt( + u32, + bytes[offset + 2 ..][0..4], + .little, + ); + const tag = std.enums.fromInt(snapshot.record.Tag, tag_raw) orelse { + log.warn("unknown record tag {}", .{tag_raw}); + return error.BenchmarkFailed; + }; + payload_totals.getPtr(tag).* += payload_len; + record_counts.getPtr(tag).* += 1; + record_total += 1; + offset += snapshot.record.Header.len + payload_len; + } + + var it = payload_totals.iterator(); + while (it.next()) |entry| { + std.debug.print("terminal-snapshot tag={s} records={d} payload={d}\n", .{ + @tagName(entry.key), + record_counts.get(entry.key), + entry.value.*, + }); + } + std.debug.print( + "terminal-snapshot total records={d} framing={d} encoded={d}\n", + .{ + record_total, + record_total * snapshot.record.Header.len + + snapshot.envelope.encoded_len, + bytes.len, + }, + ); +} + +test TerminalSnapshot { + const testing = std.testing; + const impl: *TerminalSnapshot = try .create(testing.allocator, .{}); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} + +test "TerminalSnapshot decode round trip" { + const testing = std.testing; + const impl: *TerminalSnapshot = try .create(testing.allocator, .{ + .mode = .decode, + .@"terminal-rows" = 4, + .@"terminal-cols" = 8, + }); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} + +test "TerminalSnapshot decode READY prefix" { + const testing = std.testing; + const impl: *TerminalSnapshot = try .create(testing.allocator, .{ + .mode = .@"decode-ready", + .@"terminal-rows" = 4, + .@"terminal-cols" = 8, + }); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/src/benchmark/TerminalStream.zig b/src/benchmark/TerminalStream.zig index a521c5b7a..a16e36e07 100644 --- a/src/benchmark/TerminalStream.zig +++ b/src/benchmark/TerminalStream.zig @@ -37,7 +37,13 @@ pub const Options = struct { @"terminal-rows": u16 = 80, @"terminal-cols": u16 = 120, - /// The data to read as a filepath. If this is "-" then + /// Enable opt-in continuation tracking on the stream. + @"continuation-enabled": bool = false, + + /// Maximum continuation suffix retained when tracking is enabled. + @"continuation-max-bytes": usize = 1024 * 1024, + + /// Pre-generated data from ghostty-gen. If this is "-" then /// we will read stdin. If this is unset, then we will /// do nothing (benchmark is a noop). It'd be more unixy to /// use stdin by default but I find that a hanging CLI command @@ -61,7 +67,15 @@ pub fn create( }), .stream = undefined, }; - ptr.stream = .initAlloc(alloc, .init(&ptr.terminal)); + errdefer ptr.terminal.deinit(alloc); + ptr.stream = .init(.{ + .allocator = alloc, + .handler = .init(&ptr.terminal), + .continuation_max_bytes = if (opts.@"continuation-enabled") + opts.@"continuation-max-bytes" + else + null, + }); return ptr; } @@ -86,8 +100,8 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { // Always reset our terminal state self.terminal.fullReset(); - // Open our data file to prepare for reading. We can do more - // validation here eventually. + // Open our data file to prepare for reading. We can do more validation + // here eventually. assert(self.data_f == null); self.data_f = options.dataFile(self.opts.data) catch |err| { log.warn("error opening data file err={}", .{err}); @@ -142,4 +156,12 @@ test TerminalStream { const bench = impl.benchmark(); _ = try bench.run(.once); + + const tracked: *TerminalStream = try .create(alloc, .{ + .@"continuation-enabled" = true, + }); + defer tracked.destroy(alloc); + + const tracked_bench = tracked.benchmark(); + _ = try tracked_bench.run(.once); } diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index 19db37a3d..f76dfc4b7 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -14,7 +14,10 @@ pub const Action = enum { @"page-compression", @"scrollback-compression", @"screen-clone", + @"terminal-formatter", @"terminal-parser", + @"terminal-resize", + @"terminal-snapshot", @"terminal-stream", @"is-symbol", @"osc-parser", @@ -37,7 +40,10 @@ pub const Action = enum { .@"terminal-stream" => @import("TerminalStream.zig"), .@"codepoint-width" => @import("CodepointWidth.zig"), .@"grapheme-break" => @import("GraphemeBreak.zig"), + .@"terminal-formatter" => @import("TerminalFormatter.zig"), .@"terminal-parser" => @import("TerminalParser.zig"), + .@"terminal-resize" => @import("TerminalResize.zig"), + .@"terminal-snapshot" => @import("TerminalSnapshot.zig"), .@"is-symbol" => @import("IsSymbol.zig"), .@"osc-parser" => @import("OscParser.zig"), }; diff --git a/src/benchmark/main.zig b/src/benchmark/main.zig index f22891c71..2c1f31f52 100644 --- a/src/benchmark/main.zig +++ b/src/benchmark/main.zig @@ -6,7 +6,10 @@ pub const CodepointWidth = @import("CodepointWidth.zig"); pub const GraphemeBreak = @import("GraphemeBreak.zig"); pub const HyperlinkMap = @import("HyperlinkMap.zig"); pub const ScreenClone = @import("ScreenClone.zig"); +pub const TerminalFormatter = @import("TerminalFormatter.zig"); pub const TerminalParser = @import("TerminalParser.zig"); +pub const TerminalResize = @import("TerminalResize.zig"); +pub const TerminalSnapshot = @import("TerminalSnapshot.zig"); pub const IsSymbol = @import("IsSymbol.zig"); pub const PageCompression = @import("PageCompression.zig"); pub const ScrollbackCompression = @import("ScrollbackCompression.zig"); diff --git a/src/build/Config.zig b/src/build/Config.zig index 43db20971..07e047ef5 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -73,6 +73,15 @@ pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Conf // Setup our standard Zig target and optimize options, i.e. // `-Doptimize` and `-Dtarget`. const optimize = b.standardOptimizeOption(.{}); + + // Default dependency builds to libghostty-vt-only mode. Consumers can + // still explicitly disable this to request the full Ghostty build. + const is_dep = b.dep_prefix.len > 0; + const emit_lib_vt = b.option( + bool, + "emit-lib-vt", + "Set defaults for a libghostty-vt-only build (disables xcframework, macOS app, and docs).", + ) orelse is_dep; const target = target: { var result = b.standardTargetOptions(.{}); @@ -97,19 +106,29 @@ pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Conf result = b.resolveTargetQuery(query); } + // The full Ghostty build no longer supports iOS; Fail early + // with a clear message rather than partway through the build. + if (result.result.os.tag == .ios and !emit_lib_vt) { + std.log.err( + "iOS is not a supported target for the full Ghostty build; " ++ + "only libghostty-vt supports iOS (-Demit-lib-vt)", + .{}, + ); + return error.UnsupportedTarget; + } + // If we have no minimum OS version, we set the default based on // our tag. Not all tags have a minimum so this may be null. if (result.query.os_version_min == null) { - result.query.os_version_min = osVersionMin(result.result.os.tag); + result.query.os_version_min = if (emit_lib_vt) + osVersionMinLibVt(result.result.os.tag) + else + osVersionMin(result.result.os.tag); } break :target result; }; - // Detect if Ghostty is a dependency of another project. - // dep_prefix is non-empty when this build is running as a dependency. - const is_dep = b.dep_prefix.len > 0; - // This is set to true when we're building a system package. For now // this is trivially detected using the "system_package_mode" bool // but we may want to make this more sophisticated in the future. @@ -325,22 +344,39 @@ pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Conf // may be fixed in 0.17.0. We may want to revisit this afterwards; although // I'm not too sure if that helps to clean up rpath, this may just be the // better option. See https://codeberg.org/ziglang/zig/issues/31760. - if ((target.result.os.tag == .linux) and target.query.isNativeCpu()) { - const in_nix_shell = env.get("IN_NIX_SHELL") != null; - if (b.option( - bool, - "patchelf", - "Patch interpreter and rpath in the built binary (default if IN_NIX_SHELL is set)", - ) orelse in_nix_shell) { - var patchelf: PatchElf = .{}; - if (b.findProgram(&.{"ld.so"}, &.{})) |ld_so| { - patchelf.interp = std.Io.Dir.realPathFileAbsoluteAlloc(b.graph.io, ld_so, b.allocator) catch null; - } else |_| {} - if (env.get("LD_LIBRARY_PATH")) |ld_library_path| { - patchelf.rpath = if (ld_library_path.len > 0) ld_library_path else null; - } - if (patchelf.interp != null or patchelf.rpath != null) { - config.patchelf = patchelf; + if (b.option( + []const u8, + "patch-interp", + "Inject the supplied path as the dynamic linker in the built binary. " ++ + "Under Nix, this defaults to the dynamic linker found in PATH.", + )) |interp| { + PatchElf.setInterp(&config, interp); + } else patch_interp: { + if (!(target.result.os.tag == .linux) or !target.query.isNativeCpu()) break :patch_interp; + if (env.get("IN_NIX_SHELL") == null) break :patch_interp; + + if (b.findProgram(&.{"ld.so"}, &.{})) |ld_so| { + PatchElf.setInterp( + &config, + std.Io.Dir.realPathFileAbsoluteAlloc(b.graph.io, ld_so, b.allocator) catch break :patch_interp, + ); + } else |_| {} + } + + if (b.option( + []const u8, + "patch-rpath", + "Inject the supplied colon-delimited search path as the rpath in the built binary. " ++ + "This defaults to LD_LIBRARY_PATH if we're in a Nix shell environment.", + )) |rpath| { + PatchElf.setRpath(&config, rpath); + } else patch_rpath: { + if (!(target.result.os.tag == .linux) or !target.query.isNativeCpu()) break :patch_rpath; + if (env.get("IN_NIX_SHELL") == null) break :patch_rpath; + + if (env.get("LD_LIBRARY_PATH")) |ld_library_path| { + if (ld_library_path.len > 0) { + PatchElf.setRpath(&config, ld_library_path); } } } @@ -364,11 +400,7 @@ pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Conf //--------------------------------------------------------------- // Artifacts to Emit - config.emit_lib_vt = b.option( - bool, - "emit-lib-vt", - "Set defaults for a libghostty-vt-only build (disables xcframework, macOS app, and docs).", - ) orelse false; + config.emit_lib_vt = emit_lib_vt; config.emit_exe = b.option( bool, @@ -539,6 +571,24 @@ pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Conf const PatchElf = struct { interp: ?[]const u8 = null, rpath: ?[]const u8 = null, + + fn setInterp(config: *Config, interp: []const u8) void { + if (config.patchelf) |*patchelf| { + patchelf.interp = interp; + return; + } + + config.patchelf = .{ .interp = interp }; + } + + fn setRpath(config: *Config, rpath: []const u8) void { + if (config.patchelf) |*patchelf| { + patchelf.rpath = rpath; + return; + } + + config.patchelf = .{ .rpath = rpath }; + } }; /// Add a patchelf step for the supplied `artifact`, depending on the supplied @@ -692,19 +742,22 @@ pub fn osVersionMin(tag: std.Target.Os.Tag) ?std.Target.Query.OsVersion { .patch = 0, } }, - // iOS 17 picked arbitrarily - .ios => .{ .semver = .{ - .major = 17, - .minor = 0, - .patch = 0, - } }, - // This should never happen currently. If we add a new target then // we should add a new case here. else => null, }; } +/// Returns the minimum OS version for lib-vt build. +/// +/// This should only be used for Darwin targets. +pub fn osVersionMinLibVt(tag: std.Target.Os.Tag) ?std.Target.Query.OsVersion { + // lib-vt is the only thing we still build for iOS, so its deployment + // target lives here rather than in osVersionMin. + if (tag == .ios) return .{ .semver = .{ .major = 13, .minor = 0, .patch = 0 } }; + return osVersionMin(tag); +} + // Returns a ResolvedTarget for a mac with a `target.result.cpu.model.name` of `generic`. // `b.standardTargetOptions()` returns a more specific cpu like `apple_a15`. // diff --git a/src/build/GhosttyI18n.zig b/src/build/GhosttyI18n.zig index dba72fd89..eb6ee5ee2 100644 --- a/src/build/GhosttyI18n.zig +++ b/src/build/GhosttyI18n.zig @@ -66,6 +66,7 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step { "--language=C", // Silence the "unknown extension" errors "--from-code=UTF-8", "--keyword=_", + "--keyword=N_", "--keyword=C_:1c,2", }); @@ -148,6 +149,11 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step { } } + // For localization of command palette + const command_palette_path = "src/input/command.zig"; + xgettext.addArg(command_palette_path); + xgettext.addFileInput(b.path(command_palette_path)); + // Add support for localizing our `nautilus` integration const xgettext_py = b.addSystemCommand(&.{ "xgettext", diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index 0ef7d6937..7010a7837 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -183,7 +183,7 @@ pub fn initStaticAppleUniversal( const target_query: std.Target.Query = .{ .cpu_arch = .aarch64, .os_tag = p.os_tag, - .os_version_min = Config.osVersionMin(p.os_tag), + .os_version_min = Config.osVersionMinLibVt(p.os_tag), }; if (detectAppleSDK( b.graph.io, @@ -197,7 +197,7 @@ pub fn initStaticAppleUniversal( const sim_zig = try zig.retarget(b, cfg, deps, b.resolveTargetQuery(.{ .cpu_arch = .aarch64, .os_tag = p.os_tag, - .os_version_min = Config.osVersionMin(p.os_tag), + .os_version_min = Config.osVersionMinLibVt(p.os_tag), .abi = .simulator, .cpu_model = .{ .explicit = &std.Target.aarch64.cpu.apple_a17 }, })); diff --git a/src/build/GhosttyXCFramework.zig b/src/build/GhosttyXCFramework.zig index a19dd18af..3f3b97eb2 100644 --- a/src/build/GhosttyXCFramework.zig +++ b/src/build/GhosttyXCFramework.zig @@ -24,35 +24,6 @@ pub fn init( Config.genericMacOSTarget(b, null), )); - // iOS - const ios = try GhosttyLib.initStatic(b, &try deps.retarget( - b, - b.resolveTargetQuery(.{ - .cpu_arch = .aarch64, - .os_tag = .ios, - .os_version_min = Config.osVersionMin(.ios), - .abi = null, - }), - )); - - // iOS Simulator - const ios_sim = try GhosttyLib.initStatic(b, &try deps.retarget( - b, - b.resolveTargetQuery(.{ - .cpu_arch = .aarch64, - .os_tag = .ios, - .os_version_min = Config.osVersionMin(.ios), - .abi = .simulator, - - // We force the Apple CPU model because the simulator - // doesn't support the generic CPU model as of Zig 0.14 due - // to missing "altnzcv" instructions, which is false. This - // surely can't be right but we can fix this if/when we get - // back to running simulator builds. - .cpu_model = .{ .explicit = &std.Target.aarch64.cpu.apple_a17 }, - }), - )); - // Generate a headers directory with only ghostty.h and the module // map. We can't use include/ directly because it also contains the // libghostty-vt headers under include/ghostty/, which would trigger @@ -75,16 +46,6 @@ pub fn init( .headers = headers, .dsym = macos_universal.dsym, }, - .{ - .library = ios.output, - .headers = headers, - .dsym = ios.dsym, - }, - .{ - .library = ios_sim.output, - .headers = headers, - .dsym = ios_sim.dsym, - }, }, .native => &.{.{ diff --git a/src/build/MetallibStep.zig b/src/build/MetallibStep.zig index fcf3055f8..81c7a4a9f 100644 --- a/src/build/MetallibStep.zig +++ b/src/build/MetallibStep.zig @@ -24,20 +24,10 @@ output: LazyPath, pub fn create(b: *std.Build, opts: Options) ?*MetallibStep { const sdk = switch (opts.target.result.os.tag) { .macos => "macosx", - .ios => switch (opts.target.result.abi) { - // The iOS simulator uses the same SDK for Metal as the device, - // but the minimum version tag causes different behaviors. - .simulator => "iphoneos", - else => "iphoneos", - }, else => return null, }; const platform_version_arg = switch (opts.target.result.os.tag) { .macos => "-mmacos-version-min", - .ios => switch (opts.target.result.abi) { - .simulator => "-mios-simulator-version-min", - else => "-mios-version-min", - }, else => null, }; @@ -47,7 +37,6 @@ pub fn create(b: *std.Build, opts: Options) ?*MetallibStep { b.fmt("{f}", .{v.semver}) else switch (opts.target.result.os.tag) { .macos => "10.14", - .ios => "11.0", else => unreachable, }; diff --git a/src/cli/args.zig b/src/cli/args.zig index d73277fda..38616fc06 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -1454,8 +1454,15 @@ pub const LineIterator = struct { entry = entry[0..trim.len]; } - // Ignore blank lines and comments - if (entry.len == 0 or entry[0] == '#') continue; + // Ignore blank lines and comments. If this line consumed the + // remaining buffer, refill before the loop condition checks for + // more data. + if (entry.len == 0 or entry[0] == '#') { + if (self.r.seek == self.r.end) { + self.r.fillMore() catch {}; + } + continue; + } break entry; } else return null; @@ -1623,3 +1630,27 @@ test "LineIterator with buffered and primed reader" { try testing.expectEqual(@as(?[]const u8, null), iter.next()); try testing.expectEqual(@as(?[]const u8, null), iter.next()); } + +test "LineIterator refills after ignored line at buffer boundary" { + const testing = std.testing; + + { + var f: std.Io.Reader = .fixed("#\nA\n"); + var buf: [2]u8 = undefined; + var r = f.limited(.unlimited, &buf); + var iter: LineIterator = .init(&r.interface); + + try testing.expectEqualStrings("--A", iter.next().?); + try testing.expectEqual(@as(?[]const u8, null), iter.next()); + } + + { + var f: std.Io.Reader = .fixed("\nA\n"); + var buf: [1]u8 = undefined; + var r = f.limited(.unlimited, &buf); + var iter: LineIterator = .init(&r.interface); + + try testing.expectEqualStrings("--A", iter.next().?); + try testing.expectEqual(@as(?[]const u8, null), iter.next()); + } +} diff --git a/src/cli/crash_report.zig b/src/cli/crash_report.zig index 2c0484e96..e189ddae9 100644 --- a/src/cli/crash_report.zig +++ b/src/cli/crash_report.zig @@ -44,22 +44,26 @@ pub fn run(alloc_gpa: Allocator) !u8 { var stdout_writer = stdout_file.writer(global.io(), &buffer); const stdout = &stdout_writer.interface; - const result = runInner(alloc, &stdout_file, stdout); + var environ_map = try global.environMap(); + defer environ_map.deinit(); + const result = runInner(global.io(), alloc, &environ_map, &stdout_file, stdout); stdout.flush() catch {}; return result; } fn runInner( + io: std.Io, alloc: Allocator, + environ_map: *const std.process.Environ.Map, stdout_file: *std.Io.File, stdout: *std.Io.Writer, ) !u8 { - const crash_dir = try crash.defaultDir(alloc); + const crash_dir = try crash.defaultDir(io, alloc, environ_map); var reports: std.ArrayList(crash.Report) = .empty; errdefer reports.deinit(alloc); - var it = try crash_dir.iterator(); - while (try it.next()) |report| try reports.append(alloc, .{ + var it = try crash_dir.iterator(io); + while (try it.next(io)) |report| try reports.append(alloc, .{ .name = try alloc.dupe(u8, report.name), .mtime = report.mtime, }); diff --git a/src/cli/edit_config.zig b/src/cli/edit_config.zig index 9662ea444..cc6aec6ba 100644 --- a/src/cli/edit_config.zig +++ b/src/cli/edit_config.zig @@ -89,71 +89,44 @@ fn runInner(alloc: Allocator, stderr: *std.Io.Writer) !u8 { \\The `ghostty +edit-config` command is not supported on Windows. \\Please edit the configuration file manually at the following path: \\ - \\{s} - \\ - , - .{path}, - ); - return 1; - } - - // Get our editor - const editor = env: { - // VISUAL vs. EDITOR: https://unix.stackexchange.com/questions/4859/visual-vs-editor-what-s-the-difference - if (try global.environ().containsUnempty(alloc, "VISUAL")) { - break :env try global.environ().getAlloc(alloc, "VISUAL"); - } - - if (try global.environ().containsUnempty(alloc, "EDITOR")) { - break :env try global.environ().getAlloc(alloc, "EDITOR"); - } - - break :env ""; - }; - defer alloc.free(editor); - - // If we don't have `$EDITOR` set then we can't do anything - // but we can still print a helpful message. - if (editor.len == 0) { - try stderr.print( - \\The $EDITOR or $VISUAL environment variable is not set or is empty. - \\This environment variable is required to edit the Ghostty configuration - \\via this CLI command. - \\ - \\Please set the environment variable to your preferred terminal - \\text editor and try again. - \\ - \\If you prefer to edit the configuration file another way, - \\you can find the configuration file at the following path: - \\ \\ , .{}, ); - - // Output the path using the OSC8 sequence so that it is linked. - try stderr.print( - "\x1b]8;;file://{s}\x1b\\{s}\x1b]8;;\x1b\\\n", - .{ path, path }, - ); - return 1; } - // Build the command - const command = command: { - var buffer: std.Io.Writer.Allocating = .init(alloc); - defer buffer.deinit(); - const writer = &buffer.writer; - try writer.writeAll(editor); - try writer.writeByte(' '); - { - var sh: internal_os.ShellEscapeWriter = .init(writer); - try sh.writer.writeAll(path); - try sh.writer.flush(); + const command = internal_os.getConfigEditCommand(alloc, path, .{ .default_editor = .failure }) catch |err| { + switch (err) { + error.NoEditorConfigured => { + try stderr.print( + \\The $EDITOR or $VISUAL environment variable is not set or is empty. + \\This environment variable is required to edit the Ghostty configuration + \\via this CLI command. + \\ + \\Please set the environment variable to your preferred terminal + \\text editor and try again. + \\ + \\If you prefer to edit the configuration file another way, + \\you can find the configuration file at the following path: + \\ + \\ + , + .{}, + ); + + // Output the path using the OSC8 sequence so that it is linked. + try stderr.print( + "\x1b]8;;file://{s}\x1b\\{s}\x1b]8;;\x1b\\\n", + .{ path, path }, + ); + + return 1; + }, + error.WriteFailed, + error.OutOfMemory, + => |e| return e, } - try writer.flush(); - break :command try buffer.toOwnedSliceSentinel(0); }; defer alloc.free(command); @@ -168,15 +141,22 @@ fn runInner(alloc: Allocator, stderr: *std.Io.Writer) !u8 { // If we reached this point then exec failed. try stderr.print( - \\Failed to execute the editor (E{s}). + \\Failed to execute the editor (E{t}). \\ \\This is usually due to the executable path not existing, invalid \\permissions, or the shell environment not being set up \\correctly. \\ - \\Editor: {s} - \\Path: {s} + \\Command: {s} \\ - , .{ @tagName(err), editor, path }); + , .{ + err, + command, + }); + // Output the path using the OSC8 sequence so that it is linked. + try stderr.print( + "Path: \x1b]8;;file://{s}\x1b\\{s}\x1b]8;;\x1b\\\n", + .{ path, path }, + ); return 1; } diff --git a/src/cli/ghostty.zig b/src/cli/ghostty.zig index 77dbe5ab5..572aa8adc 100644 --- a/src/cli/ghostty.zig +++ b/src/cli/ghostty.zig @@ -21,6 +21,7 @@ const crash_report = @import("crash_report.zig"); const show_face = @import("show_face.zig"); const boo = @import("boo.zig"); const new_window = @import("new_window.zig"); +const new_tab = @import("new_tab.zig"); const toggle_quick_terminal = @import("toggle_quick_terminal.zig"); const global = @import("../global.zig"); @@ -79,6 +80,9 @@ pub const Action = enum { // Use IPC to tell the running Ghostty to open a new window. @"new-window", + // Use IPC to tell the running Ghostty to open a new tab. + @"new-tab", + // Use IPC to tell the running Ghostty to toggle the quick terminal. @"toggle-quick-terminal", @@ -165,6 +169,7 @@ pub const Action = enum { .@"show-face" => try show_face.run(alloc), .boo => try boo.run(alloc), .@"new-window" => try new_window.run(alloc), + .@"new-tab" => try new_tab.run(alloc), .@"toggle-quick-terminal" => try toggle_quick_terminal.run(alloc), }; } @@ -207,6 +212,7 @@ pub const Action = enum { .@"show-face" => show_face.Options, .boo => boo.Options, .@"new-window" => new_window.Options, + .@"new-tab" => new_tab.Options, .@"toggle-quick-terminal" => toggle_quick_terminal.Options, }; } diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index c47e6499d..f2643f841 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -464,9 +464,9 @@ const Preview = struct { self.text_input.buf.clearRetainingCapacity(); try self.updateFiltered(); } - if (key.matchesAny(&.{ vaxis.Key.home, vaxis.Key.kp_home }, .{})) + if (key.matchesAny(&.{ vaxis.Key.home, vaxis.Key.kp_home, 'g' }, .{})) self.current = 0; - if (key.matchesAny(&.{ vaxis.Key.end, vaxis.Key.kp_end }, .{})) + if (key.matchesAny(&.{ vaxis.Key.end, vaxis.Key.kp_end, 'G' }, .{})) self.current = self.filtered.items.len - 1; if (key.matchesAny(&.{ 'j', '+', vaxis.Key.down, vaxis.Key.kp_down, vaxis.Key.kp_add }, .{})) self.down(1); @@ -779,8 +779,8 @@ const Preview = struct { .{ .keys = "d", .help = "Show palette numbers in decimal." }, .{ .keys = "c", .help = "Copy theme name to the clipboard." }, .{ .keys = "C", .help = "Copy theme path to the clipboard." }, - .{ .keys = "Home", .help = "Go to the start of the list." }, - .{ .keys = "End", .help = "Go to the end of the list." }, + .{ .keys = "Home, g", .help = "Go to the start of the list." }, + .{ .keys = "End, G", .help = "Go to the end of the list." }, .{ .keys = "/", .help = "Start search." }, .{ .keys = "^X, ^/", .help = "Clear search." }, .{ .keys = "⏎", .help = "Save theme or close search window." }, diff --git a/src/cli/new_tab.zig b/src/cli/new_tab.zig new file mode 100644 index 000000000..31176c9bb --- /dev/null +++ b/src/cli/new_tab.zig @@ -0,0 +1,277 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const Action = @import("../cli.zig").ghostty.Action; +const apprt = @import("../apprt.zig"); +const args = @import("args.zig"); +const diagnostics = @import("diagnostics.zig"); +const homedir = @import("../os/homedir.zig"); +const internal_os = @import("../os/main.zig"); +const global = @import("../global.zig"); + +pub const Options = struct { + /// We store an I/O implementation to make maintenance easier. + _io: std.Io, + + /// This is set by the CLI parser for deinit. + _arena: ?ArenaAllocator = null, + + /// If set, open up a new tab in a custom instance of Ghostty. + class: ?[:0]const u8 = null, + + /// The surface to target. + @"surface-id": ?u64 = null, + + /// Did the user specify a `--working-directory` argument on the command line? + _working_directory_seen: bool = false, + + /// All of the arguments after `+new-tab`. They will be sent to Ghosttty + /// for processing. + _arguments: std.ArrayList([:0]const u8) = .empty, + + /// Enable arg parsing diagnostics so that we don't get an error if + /// there is a "normal" config setting on the cli. + _diagnostics: diagnostics.DiagnosticList = .{}, + + pub const ParseManuallyHookError = error{InvalidValue} || error{InvalidCharacter} || error{Overflow} || + homedir.ExpandError || + std.Io.Dir.RealPathFileAllocError || + Allocator.Error; + + /// Manual parse hook, collect all of the arguments after `+new-tab`. + pub fn parseManuallyHook(self: *Options, alloc: Allocator, arg: []const u8, iter: anytype) ParseManuallyHookError!bool { + var e_seen: bool = std.mem.eql(u8, arg, "-e"); + + // Include the argument that triggered the manual parse hook. + if (try self.checkArg(alloc, arg)) |a| try self._arguments.append(alloc, a); + + // Gather up the rest of the arguments to use as the command. + while (iter.next()) |param| { + if (e_seen) { + try self._arguments.append(alloc, try alloc.dupeZ(u8, param)); + continue; + } + if (std.mem.eql(u8, param, "-e")) { + e_seen = true; + try self._arguments.append(alloc, try alloc.dupeZ(u8, param)); + continue; + } + if (try self.checkArg(alloc, param)) |a| try self._arguments.append(alloc, a); + } + + return false; + } + + pub const CheckArgError = error{InvalidValue} || error{Unexpected} || std.fmt.ParseIntError || homedir.ExpandError || std.Io.Dir.RealPathFileError || Allocator.Error; + + fn checkArg(self: *Options, alloc: Allocator, arg: []const u8) CheckArgError!?[:0]const u8 { + if (std.mem.cutPrefix(u8, arg, "--surface-id=")) |rest| { + self.@"surface-id" = try std.fmt.parseUnsigned(u64, std.mem.trim(u8, rest, &std.ascii.whitespace), 0); + return null; + } + + if (std.mem.cutPrefix(u8, arg, "--class=")) |rest| { + self.class = try alloc.dupeZ(u8, std.mem.trim(u8, rest, &std.ascii.whitespace)); + return null; + } + + if (std.mem.cutPrefix(u8, arg, "--working-directory=")) |rest| { + const stripped = std.mem.trim(u8, rest, &std.ascii.whitespace); + if (std.mem.eql(u8, stripped, "home")) return try alloc.dupeZ(u8, arg); + if (std.mem.eql(u8, stripped, "inherit")) return try alloc.dupeZ(u8, arg); + const cwd: std.Io.Dir = .cwd(); + var expandhome_buf: [std.fs.max_path_bytes]u8 = undefined; + const expanded = expanded: { + var env = try global.environMap(); + defer env.deinit(); + break :expanded try homedir.expandHome(global.io(), &env, stripped, &expandhome_buf); + }; + var realpath_buf: [std.fs.max_path_bytes]u8 = undefined; + const realpath = realpath_buf[0..try cwd.realPathFile(global.io(), expanded, &realpath_buf)]; + self._working_directory_seen = true; + return try std.fmt.allocPrintSentinel(alloc, "--working-directory={s}", .{realpath}, 0); + } + + return try alloc.dupeZ(u8, arg); + } + + pub fn deinit(self: *Options) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } + + /// Enables "-h" and "--help" to work. + pub fn help(self: Options) !void { + _ = self; + return Action.help_error; + } +}; + +/// The `new-tab` will use native platform IPC to open up a new tab in a running +/// instance of Ghostty. +/// +/// If the `--class` flag is not set, the `new-tab` command will try and connect +/// to a running instance of Ghostty based on what optimizations the Ghostty +/// CLI was compiled with. Otherwise the `new-tab` command will try and contact +/// a running Ghostty instance that was configured with the same `class` as was +/// given on the command line. +/// +/// All of the arguments after the `+new-tab` argument (except for the `--class` +/// and `--surface-id` flag) will be sent to the remote Ghostty instance and +/// will be parsed as command line flags. These flags will override certain +/// settings when creating the first surface in the new tab. Currently, only +/// `--working-directory`, `--command`, `--shell-integration=`, and +/// `--title` are supported. `-e` will also work as an alias for `--command`, except that if +/// `-e` is found on the command line all following arguments will become part +/// of the command and no more arguments will be parsed for configuration +/// settings. +/// +/// If `--working-directory` is found on the command line and is a relative +/// path (i.e. doesn't start with `/`) it will be resolved to an absolute +/// path relative to the current working directory that the `ghostty +new-tab` +/// command is run from. `~/` prefixes will also be expanded to the user's home +/// directory. +/// +/// If `--working-directory` is _not_ found on the command line, the working +/// directory that `ghostty +new-tab` is run from will be passed to Ghostty. +/// +/// GTK uses an application ID to identify instances of applications. If Ghostty +/// is compiled with release optimizations, the default application ID will be +/// `com.mitchellh.ghostty`. If Ghostty is compiled with debug optimizations, +/// the default application ID will be `com.mitchellh.ghostty-debug`. The +/// `class` configuration entry can be used to set up a custom application +/// ID. The class name must follow the requirements defined [in the GTK +/// documentation](https://docs.gtk.org/gio/type_func.Application.id_is_valid.html) +/// or it will be ignored and Ghostty will use the default as defined above. +/// +/// On GTK, D-Bus activation must be properly configured. Ghostty does not need +/// to be running for this to open a new tab, making it suitable for binding +/// to keys in your window manager (if other methods for configuring global +/// shortcuts are unavailable). D-Bus will handle launching a new instance +/// of Ghostty if it is not already running. See the Ghostty website for +/// information on properly configuring D-Bus activation. +/// +/// Only supported on GTK. +/// +/// Flags: +/// +/// * `--class=`: If set, open up a new window in a custom instance of +/// Ghostty. The class must be a valid GTK application ID. +/// +/// * `--surface-id=`: If set, specifies a Ghostty surface ID. +/// This is used to identify the window that the new tab will be added to. +/// If the surface ID is not specified or is `0` the currently focused +/// surface will be used. +/// +/// * `--command`: The command to be executed in the first surface of the new tab. +/// +/// * `--shell-integration=`: Whether to enable shell integration and +/// which shell to use. See the main configuration documentation for +/// supported values. +/// +/// * `--working-directory=`: The working directory to pass to Ghostty. +/// +/// * `--title`: A title that will override the title of the first surface in +/// the new tab. The title override may be edited or removed later. +/// +/// * `-e`: Any arguments after this will be interpreted as a command to +/// execute inside the first surface of the new tab instead of the +/// default command. +/// +/// Available since: 1.4.0 +pub fn run(alloc: Allocator) !u8 { + var iter = try args.argsIterator(alloc, global.args()); + defer iter.deinit(); + + var buffer: [1024]u8 = undefined; + var stderr_writer = std.Io.File.stderr().writer(global.io(), &buffer); + const stderr = &stderr_writer.interface; + + const result = runArgs(alloc, &iter, stderr); + stderr.flush() catch {}; + return result; +} + +fn runArgs( + alloc_gpa: Allocator, + argsIter: anytype, + stderr: *std.Io.Writer, +) !u8 { + var opts: Options = .{ ._io = global.io() }; + defer opts.deinit(); + + args.parse(Options, alloc_gpa, &opts, argsIter) catch |err| switch (err) { + error.ActionHelpRequested => return err, + else => { + try stderr.print("Error parsing args: {}\n", .{err}); + return 1; + }, + }; + + // Print out any diagnostics, unless it's likely that the diagnostic was + // generated trying to parse a "normal" configuration setting. Exit with an + // error code if any diagnostics were printed. + if (!opts._diagnostics.empty()) { + var exit: bool = false; + outer: for (opts._diagnostics.items()) |diagnostic| { + if (diagnostic.location != .cli) continue :outer; + inner: inline for (@typeInfo(Options).@"struct".fields) |field| { + if (field.name[0] == '_') continue :inner; + if (std.mem.eql(u8, field.name, diagnostic.key)) { + try stderr.print("config error: {f}\n", .{diagnostic}); + exit = true; + } + } + } + if (exit) return 1; + } + + if (!opts._working_directory_seen) { + const alloc = opts._arena.?.allocator(); + const cwd: std.Io.Dir = .cwd(); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const wd = buf[0..try cwd.realPathFile(global.io(), ".", &buf)]; + // This should be inserted at the beginning of the list, just in case `-e` was used. + try opts._arguments.insert(alloc, 0, try std.fmt.allocPrintSentinel( + alloc, + "--working-directory={s}", + .{wd}, + 0, + )); + } + + var arena = ArenaAllocator.init(alloc_gpa); + defer arena.deinit(); + const alloc = arena.allocator(); + + const surface_id = opts.@"surface-id" orelse surface: { + var env = global.environMap() catch break :surface 0; + defer env.deinit(); + const e = env.get("GHOSTTY_SURFACE_ID") orelse break :surface 0; + break :surface std.fmt.parseUnsigned(u64, e, 0) catch break :surface 0; + }; + + if (apprt.App.performIpc( + alloc, + if (opts.class) |class| .{ .class = class } else .detect, + .new_tab, + .{ + .surface_id = surface_id, + .arguments = if (opts._arguments.items.len == 0) null else opts._arguments.items, + }, + ) catch |err| switch (err) { + error.IPCFailed => { + // The apprt should have printed a more specific error message + // already. + return 1; + }, + else => { + try stderr.print("Sending the IPC failed: {}", .{err}); + return 1; + }, + }) return 0; + + // If we get here, the platform is not supported. + try stderr.print("+new-tab is not supported on this platform.\n", .{}); + return 1; +} diff --git a/src/cli/new_window.zig b/src/cli/new_window.zig index 68a1c14e0..29115a2e1 100644 --- a/src/cli/new_window.zig +++ b/src/cli/new_window.zig @@ -5,7 +5,6 @@ const Action = @import("../cli.zig").ghostty.Action; const apprt = @import("../apprt.zig"); const args = @import("args.zig"); const diagnostics = @import("diagnostics.zig"); -const lib = @import("../lib/main.zig"); const homedir = @import("../os/homedir.zig"); const global = @import("../global.zig"); @@ -70,12 +69,12 @@ pub const Options = struct { Allocator.Error; fn checkArg(self: *Options, alloc: Allocator, arg: []const u8) CheckArgError!?[:0]const u8 { - if (lib.cutPrefix(u8, arg, "--class=")) |rest| { + if (std.mem.cutPrefix(u8, arg, "--class=")) |rest| { self.class = try alloc.dupeZ(u8, std.mem.trim(u8, rest, &std.ascii.whitespace)); return null; } - if (lib.cutPrefix(u8, arg, "--working-directory=")) |rest| { + if (std.mem.cutPrefix(u8, arg, "--working-directory=")) |rest| { const stripped = std.mem.trim(u8, rest, &std.ascii.whitespace); if (std.mem.eql(u8, stripped, "home")) return try alloc.dupeZ(u8, arg); if (std.mem.eql(u8, stripped, "inherit")) return try alloc.dupeZ(u8, arg); @@ -84,7 +83,7 @@ pub const Options = struct { const expanded = expanded: { var environ_map = try global.environMap(); defer environ_map.deinit(); - break :expanded try homedir.expandHome(&environ_map, stripped, &expandhome_buf); + break :expanded try homedir.expandHome(global.io(), &environ_map, stripped, &expandhome_buf); }; var realpath_buf: [std.fs.max_path_bytes]u8 = undefined; const realpath = realpath_buf[0..try cwd.realPathFile(self._io, expanded, &realpath_buf)]; @@ -120,10 +119,11 @@ pub const Options = struct { /// `--class` flag) will be sent to the remote Ghostty instance and will be /// parsed as command line flags. These flags will override certain settings /// when creating the first surface in the new window. Currently, only -/// `--working-directory`, `--command`, and `--title` are supported. `-e` will -/// also work as an alias for `--command`, except that if `-e` is found on the -/// command line all following arguments will become part of the command and no -/// more arguments will be parsed for configuration settings. +/// `--working-directory`, `--command`, `--shell-integration=`, and +/// `--title` are supported. `-e` will also work as an alias for `--command`, except that if +/// `-e` is found on the command line all following arguments will become part +/// of the command and no more arguments will be parsed for configuration +/// settings. /// /// If `--working-directory` is found on the command line and is a relative /// path (i.e. doesn't start with `/`) it will be resolved to an absolute path @@ -159,6 +159,10 @@ pub const Options = struct { /// /// * `--command`: The command to be executed in the first surface of the new window. /// +/// * `--shell-integration=`: Whether to enable shell integration and +/// which shell to use. See the main configuration documentation for +/// supported values. +/// /// * `--working-directory=`: The working directory to pass to Ghostty. /// /// * `--title`: A title that will override the title of the first surface in diff --git a/src/cli/ssh-cache/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig index 29f97efb1..bc5619d71 100644 --- a/src/cli/ssh-cache/DiskCache.zig +++ b/src/cli/ssh-cache/DiskCache.zig @@ -30,6 +30,7 @@ pub fn defaultPath( var environ_map = try global.environMap(); defer environ_map.deinit(); const state_dir: []const u8 = xdg.state( + global.io(), alloc, &environ_map, .{ .subdir = program }, diff --git a/src/config/Config.zig b/src/config/Config.zig index 9b5982229..00b305486 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -908,10 +908,10 @@ palette: Palette = .{}, /// Enables the ability to move the cursor at prompts by clicking on a /// location in the prompt text. /// -/// This feature requires shell integration, specifically prompt marking -/// via `OSC 133`. Some shells like Fish (v4) and Nu (0.111+) natively -/// support this while others may require additional configuration or -/// Ghostty's shell integration features to be enabled. +/// This feature requires prompt marking via `OSC 133`. Some shells like Fish +/// (v4.1+) and Nu (0.111+) natively support this while others may require +/// additional configuration or Ghostty's shell integration features to be +/// enabled. /// /// Depending on the shell, this works either by translating your click /// position into a series of synthetic arrow key movements or by sending @@ -1438,9 +1438,9 @@ input: RepeatableReadableIO = .{}, /// Valid values: /// /// * `system` - Respect the system settings for when to show scrollbars. -/// For example, on macOS, this will respect the "Scrollbar behavior" -/// system setting which by default usually only shows scrollbars while -/// actively scrolling or hovering the gutter. +/// On macOS, we only show scrollbars while actively scrolling or hovering +/// the gutter. If the system setting is set to "Always", the scrollbar +/// will be shown when the mouse is over the gutter area. /// /// * `never` - Never show a scrollbar. You can still scroll using the mouse, /// keybind actions, etc. but you will not have a visual UI widget showing @@ -1469,6 +1469,14 @@ link: RepeatableLink = .{}, /// `link`). If you want to customize URL matching, use `link` and disable this. @"link-url": bool = true, +/// Enable hyperlinks created with the OSC 8 escape sequence. When disabled, +/// OSC 8 hyperlinks are not highlighted, previewed, copied, or opened. +/// +/// This does not affect URL matching controlled by `link-url`. +/// +/// Available since: 1.4.0 +@"link-osc8": bool = true, + /// Show link previews for a matched URL. /// /// When true, link previews are shown for all matched URLs. When false, link @@ -2337,6 +2345,33 @@ keybind: Keybinds = .{}, /// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"window-titlebar-foreground": ?Color = null, +/// Controls when drag handles are shown over splits, +/// allowing splits to be rearranged with mouse controls. +/// +/// Valid values: +/// +/// - `always` +/// +/// Always display the drag handle, even when there's only one split. +/// +/// - `auto` *(default)* +/// +/// Automatically show and hide the drag handle. +/// +/// On Linux, the handle is only shown when there are two or +/// more splits present. +/// +/// On macOS, the handle is only hidden when there's one split +/// in **fullscreen** mode, otherwise it's shown when hovered. +/// +/// - `never` +/// +/// Never show the drag handle. Splits then cannot be rearranged with +/// mouse controls. +/// +/// Available since: 1.4.0. +@"drag-handle": DragHandle = .auto, + /// This controls when resize overlays are shown. Resize overlays are a /// transient popup that shows the size of the terminal while the surfaces are /// being resized. The possible options are: @@ -4684,7 +4719,7 @@ pub fn finalize(self: *Config) !void { var environ_map = try global.environMap(); defer environ_map.deinit(); var buf: [std.fs.max_path_bytes]u8 = undefined; - if (try internal_os.home(&environ_map, &buf)) |home| { + if (try internal_os.home(global.io(), &environ_map, &buf)) |home| { wd = .{ .path = try alloc.dupe(u8, home) }; } else { wd = .inherit; @@ -5414,7 +5449,7 @@ pub const WorkingDirectory = union(enum) { const expanded = expanded: { var environ_map = global.environMap() catch |err| break :expanded err; defer environ_map.deinit(); - break :expanded internal_os.expandHome(&environ_map, path, &buf); + break :expanded internal_os.expandHome(global.io(), &environ_map, path, &buf); } catch |err| { log.warn( "error expanding home directory for working-directory path={s}: {}", @@ -5483,6 +5518,7 @@ pub const WorkingDirectory = union(enum) { var buf: [std.fs.max_path_bytes]u8 = undefined; const expected = internal_os.expandHome( + testing.io, &environ_map, "~/projects/ghostty", &buf, @@ -6483,7 +6519,7 @@ pub const Keybinds = struct { try self.set.put( alloc, .{ .key = .{ .unicode = ',' }, .mods = inputpkg.ctrlOrSuper(.{}) }, - .{ .open_config = {} }, + .{ .open_config = .default }, ); { @@ -8745,8 +8781,20 @@ pub const RepeatableCommand = struct { self.value.deinit(alloc); self.value_c.deinit(alloc); } - try self.value.appendSlice(alloc, inputpkg.command.defaults); - try self.value_c.appendSlice(alloc, inputpkg.command.defaultsC); + try self.value.ensureUnusedCapacity(alloc, inputpkg.command.defaults.len); + try self.value_c.ensureUnusedCapacity(alloc, inputpkg.command.defaults.len); + for (inputpkg.command.defaults) |cmd| { + // Translation is currently a GTK-only feature. In particular, + // translating these shared strings for the embedded runtime gives + // the macOS app a localized command palette in an otherwise + // unlocalized UI. + const localized = if (comptime build_config.app_runtime == .gtk) + cmd.translated() + else + cmd; + self.value.appendAssumeCapacity(localized); + self.value_c.appendAssumeCapacity(try localized.cval(alloc)); + } } pub fn parseCLI( @@ -9276,6 +9324,13 @@ pub const WindowShowTabBar = enum { never, }; +/// See drag-handle +pub const DragHandle = enum { + always, + auto, + never, +}; + /// See resize-overlay pub const ResizeOverlay = enum { always, @@ -10549,6 +10604,7 @@ test "clone preserves conditional set" { test "working-directory expands tilde" { const testing = std.testing; + const io = testing.io; const alloc = testing.allocator; var environ_map = try testing.environ.createMap(testing.allocator); defer environ_map.deinit(); @@ -10563,6 +10619,7 @@ test "working-directory expands tilde" { var buf: [std.fs.max_path_bytes]u8 = undefined; const expected = internal_os.expandHome( + io, &environ_map, "~/projects/ghostty", &buf, diff --git a/src/config/edit.zig b/src/config/edit.zig index 24097498e..113ec8b2a 100644 --- a/src/config/edit.zig +++ b/src/config/edit.zig @@ -27,31 +27,52 @@ pub fn openPath(alloc_gpa: Allocator) ![:0]const u8 { // Get the path we should open const config_path = try configPath(alloc_arena); - // Create config directory recursively. - if (std.fs.path.dirname(config_path)) |config_dir| { - try std.Io.Dir.cwd().createDirPath(global.io(), config_dir); + if (!config_path.exists) { + if (std.fs.path.dirname(config_path.name)) |config_dir| check_dir: { + // Check to see if dir exists. + const dir = std.Io.Dir.cwd().openDir(global.io(), config_dir, .{ .follow_symlinks = true }) catch |err| { + switch (err) { + error.FileNotFound => { + // Create config directory recursively. Note that this does not + // allow intermediate symlinks by design, see + // std.Io.Threaded.dirCreateDirPath for why. If some sort of + // complex symlink structure is needed, it will need to be created + // manually. + try std.Io.Dir.cwd().createDirPath(global.io(), config_dir); + break :check_dir; + }, + else => return err, + } + }; + dir.close(global.io()); + } + + // Try to create file and go on if it already exists + _ = std.Io.Dir.createFileAbsolute( + global.io(), + config_path.name, + .{ .exclusive = true }, + ) catch |err| { + switch (err) { + error.PathAlreadyExists => {}, + else => return err, + } + }; } - // Try to create file and go on if it already exists - _ = std.Io.Dir.createFileAbsolute( - global.io(), - config_path, - .{ .exclusive = true }, - ) catch |err| { - switch (err) { - error.PathAlreadyExists => {}, - else => return err, - } - }; - - return try alloc_gpa.dupeZ(u8, config_path); + return try alloc_gpa.dupeZ(u8, config_path.name); } +const ConfigPathResult = struct { + name: []const u8, + exists: bool, +}; + /// Returns the config path to use for open for the current OS. /// /// The allocator must be an arena allocator. No memory is freed by this /// function and the resulting path is not all the memory that is allocated. -fn configPath(alloc_arena: Allocator) ![]const u8 { +fn configPath(alloc_arena: Allocator) !ConfigPathResult { const paths: []const []const u8 = try configPathCandidates(alloc_arena); assert(paths.len > 0); @@ -75,17 +96,26 @@ fn configPath(alloc_arena: Allocator) ![]const u8 { const stat = try f.stat(global.io()); // If the file is non-empty, return it. - if (stat.size > 0) return path; + if (stat.size > 0) return .{ + .name = path, + .exists = true, + }; // If the file is empty, remember it exists. if (exists == null) exists = path; } // No paths are non-empty, return the first path that exists. - if (exists) |v| return v; + if (exists) |v| return .{ + .name = v, + .exists = true, + }; // No paths are non-empty or exist, return the first path. - return paths[0]; + return .{ + .name = paths[0], + .exists = false, + }; } /// Returns a const list of possible paths the main config file could be diff --git a/src/config/file_load.zig b/src/config/file_load.zig index ac5ddff6a..7495e899f 100644 --- a/src/config/file_load.zig +++ b/src/config/file_load.zig @@ -13,6 +13,7 @@ pub fn defaultXdgPath(alloc: Allocator) ![]const u8 { var environ_map = try global.environMap(); defer environ_map.deinit(); return try internal_os.xdg.config( + global.io(), alloc, &environ_map, .{ .subdir = "ghostty/config.ghostty" }, @@ -25,6 +26,7 @@ pub fn legacyDefaultXdgPath(alloc: Allocator) ![]const u8 { var environ_map = try global.environMap(); defer environ_map.deinit(); return try internal_os.xdg.config( + global.io(), alloc, &environ_map, .{ .subdir = "ghostty/config" }, diff --git a/src/config/path.zig b/src/config/path.zig index 4d9c8586a..5fd88f332 100644 --- a/src/config/path.zig +++ b/src/config/path.zig @@ -168,6 +168,7 @@ pub const Path = union(enum) { defer environ_map.deinit(); const expanded: []const u8 = internal_os.expandHome( + global.io(), &environ_map, path, &buf, diff --git a/src/config/theme.zig b/src/config/theme.zig index e7609a165..a8c39cad9 100644 --- a/src/config/theme.zig +++ b/src/config/theme.zig @@ -33,6 +33,7 @@ pub const Location = enum { }) catch return error.OutOfMemory; break :user internal_os.xdg.config( + global.io(), arena_alloc, &environ_map, .{ .subdir = subdir }, diff --git a/src/crash/dir.zig b/src/crash/dir.zig index 46e3ba2ac..0f877ee32 100644 --- a/src/crash/dir.zig +++ b/src/crash/dir.zig @@ -1,14 +1,11 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const internal_os = @import("../os/main.zig"); -const global = @import("../global.zig"); /// Returns a Dir for the default directory. The Dir.path field must be /// freed with the given allocator. -pub fn defaultDir(alloc: Allocator) !Dir { - var environ_map = try global.environMap(); - defer environ_map.deinit(); - const crash_dir = try internal_os.xdg.state(alloc, &environ_map, .{ .subdir = "ghostty/crash" }); +pub fn defaultDir(io: std.Io, alloc: Allocator, environ_map: *const std.process.Environ.Map) !Dir { + const crash_dir = try internal_os.xdg.state(io, alloc, environ_map, .{ .subdir = "ghostty/crash" }); errdefer alloc.free(crash_dir); return .{ .path = crash_dir }; } @@ -21,13 +18,13 @@ pub const Dir = struct { /// Returns an iterator over the crash reports in this directory. This /// iterator must be freed with `ReportIterator.deinit`. The iterator /// may have no reports. - pub fn iterator(self: *const Dir) !ReportIterator { + pub fn iterator(self: *const Dir, io: std.Io) !ReportIterator { var dir = std.Io.Dir.openDirAbsolute( - global.io(), + io, self.path, .{ .iterate = true }, ) catch return .{}; - errdefer dir.close(global.io()); + errdefer dir.close(io); return .{ .dir = dir, @@ -40,22 +37,22 @@ pub const ReportIterator = struct { dir: ?std.Io.Dir = null, it: std.Io.Dir.Iterator = undefined, - pub fn deinit(self: *ReportIterator) void { - if (self.dir) |dir| dir.close(global.io()); + pub fn deinit(self: *ReportIterator, io: std.Io) void { + if (self.dir) |dir| dir.close(io); } - pub fn next(self: *ReportIterator) !?Report { + pub fn next(self: *ReportIterator, io: std.Io) !?Report { // If we have no dir then we failed to open the directory. const dir = self.dir orelse return null; // Get the next file entry, if any. const entry = entry: while (true) { - const entry = try self.it.next(global.io()) orelse return null; + const entry = try self.it.next(io) orelse return null; if (entry.kind != .file) continue; break :entry entry; }; - const stat = try dir.statFile(global.io(), entry.name, .{}); + const stat = try dir.statFile(io, entry.name, .{}); return .{ .name = entry.name, .mtime = stat.mtime.toNanoseconds(), diff --git a/src/crash/sentry.zig b/src/crash/sentry.zig index b0ad68937..4181b5c1d 100644 --- a/src/crash/sentry.zig +++ b/src/crash/sentry.zig @@ -7,7 +7,6 @@ const build_options = @import("build_options"); const sentry = if (build_options.sentry) @import("sentry"); const internal_os = @import("../os/main.zig"); const crash = @import("main.zig"); -const global = @import("../global.zig"); const Surface = @import("../Surface.zig"); const log = std.log.scoped(.sentry); @@ -16,6 +15,16 @@ const log = std.log.scoped(.sentry); /// handling is a global process-wide thing. var init_thread: ?std.Thread = null; +/// Directory memory, holds the cache and state dirs persistently. This +/// prevents any sort of crashes due to initialization races. +var dir_mem: [std.fs.max_path_bytes * 2]u8 = undefined; + +/// Holds the XDG cache dir. +var cache_dir_: ?[]const u8 = null; + +/// Holds the XDG state dir. +var state_dir_: ?[]const u8 = null; + /// Thread-local state that can be set by thread main functions so that /// crashes have more context. /// @@ -46,32 +55,56 @@ pub threadlocal var thread_state: ?ThreadState = null; /// NOT send any data over the network. We use the Sentry native SDK to collect /// crash reports and logs, but we only store them locally (see Transport). /// It is up to the user to grab the logs and manually send them to us -/// (or they own Sentry instance) if they want to. -pub fn init(gpa: Allocator) !void { - if (comptime !build_options.sentry) return; +/// (or to their own Sentry instance) if they want to. +pub fn init(gpa: Allocator, environ_map: std.process.Environ.Map) !void { + if (comptime !build_options.sentry) { + var map = environ_map; + map.deinit(); + return; + } // Not supported on Windows currently, doesn't build. - if (comptime builtin.os.tag == .windows) return; + if (comptime builtin.os.tag == .windows) { + var map = environ_map; + map.deinit(); + return; + } // Must only start once assert(init_thread == null); - // We use a thread for initializing Sentry because initialization takes - // ~2k ns on my M3 Max. That's not a LOT of time but it's enough to be - // 90% of our pre-App startup time. Everything Sentry is doing initially - // is safe to do on a separate thread and fast enough that its very - // likely to be done before a crash occurs. - const thr = try std.Thread.spawn( + // We use a thread for initializing Sentry because initialization is + // slow enough to matter for process startup: resolving our directories + // can take multiple milliseconds on macOS (Apple APIs) and Sentry's + // own init does disk I/O. Everything Sentry is doing initially is safe + // to do on a separate thread and fast enough that its very likely to + // be done before a crash occurs. + // + // The environ map is a snapshot owned by the thread (and freed there), + // so it is safe against concurrent mutations of the process environment + // (e.g. ensureLocale on the main thread). + const thr = std.Thread.spawn( .{}, initThread, - .{gpa}, - ); - thr.setName(global.io(), "sentry-init") catch {}; + .{ gpa, environ_map }, + ) catch |err| { + var map = environ_map; + map.deinit(); + return err; + }; + + // Naming the thread from here only works on some platforms (e.g. + // Linux). On Darwin the thread names itself in initThread. + var single_threaded: std.Io.Threaded = .init_single_threaded; + defer single_threaded.deinit(); + thr.setName(single_threaded.io(), "sentry-init") catch {}; + init_thread = thr; } -fn initThread(gpa: Allocator) !void { - if (comptime !build_options.sentry) return; +fn initThread(gpa: Allocator, environ_map_: std.process.Environ.Map) !void { + var environ_map = environ_map_; + defer environ_map.deinit(); // Right now, on Darwin, `std.Thread.setName` can only name the current // thread, and we have no way to get the current thread from within it, @@ -80,9 +113,33 @@ fn initThread(gpa: Allocator) !void { internal_os.macos.pthread_setname_np(&"sentry-init".*); } - var arena = std.heap.ArenaAllocator.init(gpa); - defer arena.deinit(); - const alloc = arena.allocator(); + // Get our directories. + var single_threaded: std.Io.Threaded = .init_single_threaded; + defer single_threaded.deinit(); + var fba: std.heap.FixedBufferAllocator = .init(&dir_mem); + + state_dir_ = state_dir: { + const dir = try crash.defaultDir( + single_threaded.io(), + gpa, + &environ_map, + ); + defer gpa.free(dir.path); + break :state_dir try fba.allocator().dupe(u8, dir.path); + }; + errdefer state_dir_ = null; + + const cache_dir = cache_dir: { + const dir = try cacheDir( + single_threaded.io(), + gpa, + &environ_map, + ); + defer gpa.free(dir); + break :cache_dir try fba.allocator().dupe(u8, dir); + }; + cache_dir_ = cache_dir; + errdefer cache_dir_ = null; const transport = sentry.Transport.init(&Transport.send); // This will crash if the transport was never used so we avoid @@ -104,27 +161,6 @@ fn initThread(gpa: Allocator) !void { // do here and why we use this. sentry.c.sentry_options_set_before_send(opts, beforeSend, null); - // Determine the Sentry cache directory. - const cache_dir = cache_dir: { - // On macOS, we prefer to use the NSCachesDirectory value to be - // a more idiomatic macOS application. But if XDG env vars are set - // we will respect them. - if (comptime builtin.os.tag == .macos) macos: { - if (global.environ().containsUnemptyConstant("XDG_CACHE_HOME")) break :macos; - break :cache_dir try internal_os.macos.cacheDir( - alloc, - "sentry", - ); - } - - var environ_map = try global.environMap(); - defer environ_map.deinit(); - break :cache_dir try internal_os.xdg.cache( - alloc, - &environ_map, - .{ .subdir = "ghostty/sentry" }, - ); - }; sentry.c.sentry_options_set_database_path_n( opts, cache_dir.ptr, @@ -149,6 +185,28 @@ fn initThread(gpa: Allocator) !void { log.debug("sentry initialized database={s}", .{cache_dir}); } +fn cacheDir(io: std.Io, alloc: Allocator, environ_map: *const std.process.Environ.Map) ![]const u8 { + // On macOS, we prefer to use the NSCachesDirectory value to be + // a more idiomatic macOS application. But if XDG env vars are set + // we will respect them. + if (comptime builtin.os.tag == .macos) macos: { + const xdg_cache_home = environ_map.get("XDG_CACHE_HOME") orelse break :macos; + if (xdg_cache_home.len > 0) { + return try internal_os.macos.cacheDir( + alloc, + "sentry", + ); + } + } + + return try internal_os.xdg.cache( + io, + alloc, + environ_map, + .{ .subdir = "ghostty/sentry" }, + ); +} + /// Process-wide deinitialization of our Sentry client. This ensures all /// our data is flushed. pub fn deinit() void { @@ -252,7 +310,13 @@ pub const Transport = struct { /// Implementation of send but we can use Zig errors. fn sendInternal(envelope: *sentry.Envelope) !void { - var arena = std.heap.ArenaAllocator.init(global.alloc()); + const state_dir = state_dir_ orelse return error.StateDirNotInitialized; + + // The I/O and allocator we use here are just meant to get the job + // done for saving the crash report. + var single_threaded: std.Io.Threaded = .init_single_threaded; + defer single_threaded.deinit(); + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const alloc = arena.allocator(); @@ -281,20 +345,17 @@ pub const Transport = struct { // conflict. const uuid = sentry.UUID.init(); - // Get our XDG state directory where we'll store the crash reports. - // This directory must exist for writing to work. - const dir = try crash.defaultDir(alloc); - try std.Io.Dir.cwd().createDirPath(global.io(), dir.path); + try std.Io.Dir.cwd().createDirPath(single_threaded.io(), state_dir); // Build our final path and write to it. const path = try std.fs.path.join(alloc, &.{ - dir.path, + state_dir, try std.fmt.allocPrint(alloc, "{s}.ghosttycrash", .{uuid.string()}), }); - const file = try std.Io.Dir.cwd().createFile(global.io(), path, .{}); - defer file.close(global.io()); + const file = try std.Io.Dir.cwd().createFile(single_threaded.io(), path, .{}); + defer file.close(single_threaded.io()); var buf: [4096]u8 = undefined; - var file_writer = file.writer(global.io(), &buf); + var file_writer = file.writer(single_threaded.io(), &buf); try file_writer.interface.writeAll(json); try file_writer.end(); diff --git a/src/crc32c.zig b/src/crc32c.zig new file mode 100644 index 000000000..8b8ebd1b2 --- /dev/null +++ b/src/crc32c.zig @@ -0,0 +1,193 @@ +//! CRC32C with hardware acceleration. +//! +//! The Zig standard library implementation processes one byte per table +//! lookup (as of Zig 0.16), which is more than an order of magnitude slower +//! than the dedicated CRC32C instructions available on aarch64 (CRC +//! extension) and x86_64 (SSE4.2). This module selects the best backend at +//! compile time and falls back to the standard library elsewhere, including +//! WebAssembly. +//! +//! The resulting value is identical across all backends: this is the +//! iSCSI CRC32C parameter set (reflected, initial and final XOR +//! `0xFFFFFFFF`), matching `std.hash.crc.Crc32Iscsi`. + +const std = @import("std"); +const builtin = @import("builtin"); + +/// The standard-library implementation of the same parameter set. This is +/// both the portable fallback and the reference the tests compare against. +const Software = std.hash.crc.Crc32Iscsi; + +const Backend = enum { + aarch64_crc, + x86_64_sse42, + software, +}; + +const backend: Backend = backend: { + switch (builtin.cpu.arch) { + .aarch64, + .aarch64_be, + => if (std.Target.aarch64.featureSetHas( + builtin.cpu.features, + .crc, + )) break :backend .aarch64_crc, + + // The self-hosted x86_64 backend cannot encode the CRC32 + // instruction forms used below, so that combination falls back to + // the portable implementation. + .x86_64 => if (builtin.zig_backend == .stage2_llvm and + std.Target.x86.featureSetHas( + builtin.cpu.features, + .sse4_2, + )) break :backend .x86_64_sse42, + + else => {}, + } + break :backend .software; +}; + +/// Streaming CRC32C with the same interface shape as `std.hash.crc` types. +pub const Crc32c = struct { + crc: u32, + + pub fn init() Crc32c { + return .{ .crc = 0xFFFF_FFFF }; + } + + pub fn update(self: *Crc32c, bytes: []const u8) void { + self.crc = switch (comptime backend) { + .aarch64_crc, .x86_64_sse42 => updateHardware(self.crc, bytes), + .software => software: { + var crc: Software = .{ .crc = self.crc }; + crc.update(bytes); + break :software crc.crc; + }, + }; + } + + pub fn final(self: Crc32c) u32 { + return self.crc ^ 0xFFFF_FFFF; + } + + pub fn hash(bytes: []const u8) u32 { + var c: Crc32c = .init(); + c.update(bytes); + return c.final(); + } +}; + +/// One update pass using the dedicated CRC32C instructions. Both supported +/// architectures handle unaligned loads efficiently, so the loop reads +/// little-endian words directly from the input. +fn updateHardware(initial: u32, bytes: []const u8) u32 { + var crc = initial; + var remaining = bytes; + + while (remaining.len >= 8) : (remaining = remaining[8..]) { + crc = step(u64, crc, std.mem.readInt( + u64, + remaining[0..8], + .little, + )); + } + if (remaining.len >= 4) { + crc = step(u32, crc, std.mem.readInt( + u32, + remaining[0..4], + .little, + )); + remaining = remaining[4..]; + } + for (remaining) |byte| crc = step(u8, crc, byte); + return crc; +} + +/// One CRC32C instruction folding `value` into the running CRC. +inline fn step(comptime T: type, crc: u32, value: T) u32 { + return switch (comptime backend) { + .aarch64_crc => switch (T) { + u8 => asm ("crc32cb %[out:w], %[crc:w], %[value:w]" + : [out] "=r" (-> u32), + : [crc] "r" (crc), + [value] "r" (value), + ), + u32 => asm ("crc32cw %[out:w], %[crc:w], %[value:w]" + : [out] "=r" (-> u32), + : [crc] "r" (crc), + [value] "r" (value), + ), + u64 => asm ("crc32cx %[out:w], %[crc:w], %[value:x]" + : [out] "=r" (-> u32), + : [crc] "r" (crc), + [value] "r" (value), + ), + else => comptime unreachable, + }, + + .x86_64_sse42 => switch (T) { + u8 => asm ("crc32b %[value], %[out]" + : [out] "=r" (-> u32), + : [value] "r" (value), + [crc_in] "0" (crc), + ), + u32 => asm ("crc32l %[value], %[out]" + : [out] "=r" (-> u32), + : [value] "r" (value), + [crc_in] "0" (crc), + ), + u64 => @truncate(asm ("crc32q %[value], %[out]" + : [out] "=r" (-> u64), + : [value] "r" (value), + [crc_in] "0" (@as(u64, crc)), + )), + else => comptime unreachable, + }, + + .software => comptime unreachable, + }; +} + +test "matches the check value" { + // The catalog check value for CRC-32/ISCSI. + try std.testing.expectEqual( + @as(u32, 0xE3069283), + Crc32c.hash("123456789"), + ); +} + +test "matches the standard library at every length and split" { + var bytes: [259]u8 = undefined; + var prng = std.Random.DefaultPrng.init(0xC5C32C); + prng.random().bytes(&bytes); + + for (0..bytes.len + 1) |len| { + const input = bytes[0..len]; + try std.testing.expectEqual( + Software.hash(input), + Crc32c.hash(input), + ); + + // Streaming across arbitrary split points must not change the + // result: word batching may not leak state between updates. + var split: Crc32c = .init(); + split.update(input[0 .. len / 3]); + split.update(input[len / 3 .. len - len / 3]); + split.update(input[len - len / 3 ..]); + try std.testing.expectEqual(Software.hash(input), split.final()); + } +} + +test "matches the standard library at every alignment" { + var bytes: [64 + 16]u8 = undefined; + var prng = std.Random.DefaultPrng.init(0xA11C); + prng.random().bytes(&bytes); + + for (0..16) |offset| { + const input = bytes[offset..][0..64]; + try std.testing.expectEqual( + Software.hash(input), + Crc32c.hash(input), + ); + } +} diff --git a/src/datastruct/circ_buf.zig b/src/datastruct/circ_buf.zig index 3e373cb94..41a168329 100644 --- a/src/datastruct/circ_buf.zig +++ b/src/datastruct/circ_buf.zig @@ -167,18 +167,18 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { const prev_len = self.len(); const prev_cap = self.storage.len; self.storage = try alloc.realloc(self.storage, size); + const retained_len = @min(prev_len, size); // If we grew, we need to set our new defaults. We can add it // at the end since we rotated to start. if (size > prev_cap) { @memset(self.storage[prev_cap..], default); - - // Fix up our head/tail - if (self.full) { - self.head = prev_len; - self.full = false; - } } + + // Fix up our head/tail for the new capacity. + self.head = if (retained_len == size) 0 else retained_len; + self.tail = 0; + self.full = retained_len == size; } /// Rotate the data so that it is zero-aligned. @@ -793,6 +793,61 @@ test "CircBuf resize shrink" { } } +test "CircBuf resize shrink partially full" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 5); + defer buf.deinit(alloc); + + try buf.append(1); + try buf.append(2); + try buf.append(3); + + try buf.resize(alloc, 2); + try testing.expect(buf.full); + try testing.expectEqual(@as(usize, 2), buf.len()); + try testing.expectEqual(@as(usize, 2), buf.capacity()); + + var it = buf.iterator(.forward); + try testing.expectEqual(@as(u8, 1), it.next().?.*); + try testing.expectEqual(@as(u8, 2), it.next().?.*); + try testing.expect(it.next() == null); + try testing.expectError(error.OutOfMemory, buf.append(4)); +} + +test "CircBuf resize shrink to length" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 5); + defer buf.deinit(alloc); + + try buf.append(1); + try buf.append(2); + try buf.resize(alloc, 2); + + try testing.expect(buf.full); + try testing.expectEqual(@as(usize, 2), buf.len()); + try testing.expectEqual(@as(usize, 2), buf.capacity()); +} + +test "CircBuf resize empty to zero" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 5); + defer buf.deinit(alloc); + + try buf.resize(alloc, 0); + try testing.expect(buf.full); + try testing.expectEqual(@as(usize, 0), buf.len()); + try testing.expectEqual(@as(usize, 0), buf.capacity()); +} + test "CircBuf append empty slice" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/datastruct/limited_allocator.zig b/src/datastruct/limited_allocator.zig new file mode 100644 index 000000000..11c4fbeaf --- /dev/null +++ b/src/datastruct/limited_allocator.zig @@ -0,0 +1,124 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// An allocator that rejects any single allocation or resize larger than a +/// configured byte limit. The limit applies to each request independently, +/// not to the total amount of memory currently allocated. +pub const LimitedAllocator = struct { + child: Allocator, + limit: usize, + + /// Set after an allocation, resize, or remap is rejected for exceeding + /// the limit. This remains set until the caller clears it. + limit_exceeded: bool = false, + + pub fn init(child: Allocator, limit: usize) LimitedAllocator { + return .{ + .child = child, + .limit = limit, + }; + } + + pub fn allocator(self: *LimitedAllocator) Allocator { + return .{ + .ptr = self, + .vtable = &.{ + .alloc = alloc, + .resize = resize, + .remap = remap, + .free = free, + }, + }; + } + + fn check(self: *LimitedAllocator, size: usize) bool { + if (size <= self.limit) return true; + self.limit_exceeded = true; + return false; + } + + fn alloc( + ctx: *anyopaque, + len: usize, + alignment: std.mem.Alignment, + ret_addr: usize, + ) ?[*]u8 { + const self: *LimitedAllocator = @ptrCast(@alignCast(ctx)); + if (!self.check(len)) return null; + return self.child.rawAlloc(len, alignment, ret_addr); + } + + fn resize( + ctx: *anyopaque, + memory: []u8, + alignment: std.mem.Alignment, + new_len: usize, + ret_addr: usize, + ) bool { + const self: *LimitedAllocator = @ptrCast(@alignCast(ctx)); + if (!self.check(new_len)) return false; + return self.child.rawResize(memory, alignment, new_len, ret_addr); + } + + fn remap( + ctx: *anyopaque, + memory: []u8, + alignment: std.mem.Alignment, + new_len: usize, + ret_addr: usize, + ) ?[*]u8 { + const self: *LimitedAllocator = @ptrCast(@alignCast(ctx)); + if (!self.check(new_len)) return null; + return self.child.rawRemap(memory, alignment, new_len, ret_addr); + } + + fn free( + ctx: *anyopaque, + memory: []u8, + alignment: std.mem.Alignment, + ret_addr: usize, + ) void { + const self: *LimitedAllocator = @ptrCast(@alignCast(ctx)); + self.child.rawFree(memory, alignment, ret_addr); + } +}; + +test "LimitedAllocator allows allocations through the limit" { + const testing = std.testing; + + var limited: LimitedAllocator = .init(testing.allocator, 8); + const alloc = limited.allocator(); + const data = try alloc.alloc(u8, 8); + defer alloc.free(data); + + try testing.expect(!limited.limit_exceeded); +} + +test "LimitedAllocator rejects allocations before the child" { + const testing = std.testing; + + var failing = testing.FailingAllocator.init(testing.allocator, .{ + .fail_index = 0, + }); + var limited: LimitedAllocator = .init(failing.allocator(), 8); + + try testing.expectError(error.OutOfMemory, limited.allocator().alloc(u8, 9)); + try testing.expect(limited.limit_exceeded); + try testing.expect(!failing.has_induced_failure); +} + +test "LimitedAllocator rejects oversized resize and remap" { + const testing = std.testing; + + var limited: LimitedAllocator = .init(testing.allocator, 8); + const alloc = limited.allocator(); + const data = try alloc.alloc(u8, 8); + defer alloc.free(data); + + try testing.expect(!alloc.resize(data, 9)); + try testing.expect(limited.limit_exceeded); + + limited.limit_exceeded = false; + try testing.expect(alloc.remap(data, 9) == null); + try testing.expect(limited.limit_exceeded); +} diff --git a/src/datastruct/lru.zig b/src/datastruct/lru.zig deleted file mode 100644 index 83d2cf8ef..000000000 --- a/src/datastruct/lru.zig +++ /dev/null @@ -1,349 +0,0 @@ -const std = @import("std"); -const assert = @import("../quirks.zig").inlineAssert; -const Allocator = std.mem.Allocator; - -/// Create a HashMap for a key type that can be automatically hashed. -/// If you want finer-grained control, use HashMap directly. -pub fn AutoHashMap(comptime K: type, comptime V: type) type { - return HashMap( - K, - V, - std.hash_map.AutoContext(K), - std.hash_map.default_max_load_percentage, - ); -} - -/// HashMap implementation that supports least-recently-used eviction. -/// -/// Beware of the Zig bug where a hashmap gets slower over time -/// (https://github.com/ziglang/zig/issues/17851). This LRU uses a hashmap -/// and evictions will cause this issue to appear. Callers should keep -/// track of eviction counts and periodically reinitialize the LRU to -/// avoid this issue. The LRU itself can't do this because it doesn't -/// know how to free values. -/// -/// Note: This is a really elementary CS101 version of an LRU right now. -/// This is done initially to get something working. Once we have it working, -/// we can benchmark and improve if this ends up being a source of slowness. -pub fn HashMap( - comptime K: type, - comptime V: type, - comptime Context: type, - comptime max_load_percentage: u64, -) type { - return struct { - const Self = @This(); - const Queue = std.DoublyLinkedList; - const Map = std.HashMapUnmanaged( - K, - *Entry, - Context, - max_load_percentage, - ); - - /// Map to maintain our entries. - map: Map, - - /// Queue to maintain LRU order. - queue: Queue, - - /// The capacity of our map. If this capacity is reached, cache - /// misses will begin evicting entries. - capacity: Map.Size, - - const Entry = struct { - data: KV, - node: Queue.Node, - - fn fromNode(node: *Queue.Node) *Entry { - return @fieldParentPtr("node", node); - } - }; - - pub const KV = struct { - key: K, - value: V, - }; - - /// The result of a getOrPut operation. - pub const GetOrPutResult = struct { - /// The entry that was retrieved. If found_existing is false, - /// then this is a pointer to allocated space to store a V. - /// If found_existing is true, the pointer value is valid, but - /// can be overwritten. - value_ptr: *V, - - /// Whether an existing value was found or not. - found_existing: bool, - - /// If another entry had to be evicted to make space for this - /// put operation, then this is the value that was evicted. - evicted: ?KV, - }; - - pub fn init(capacity: Map.Size) Self { - return .{ - .map = .{}, - .queue = .{}, - .capacity = capacity, - }; - } - - pub fn deinit(self: *Self, alloc: Allocator) void { - // Important: use our queue as a source of truth for dealloc - // because we might keep items in the queue around that aren't - // present in our LRU anymore to prevent future allocations. - var it = self.queue.first; - while (it) |node| { - it = node.next; - alloc.destroy(Entry.fromNode(node)); - } - - self.map.deinit(alloc); - self.* = undefined; - } - - /// Get or put a value for a key. See GetOrPutResult on how to check - /// if an existing value was found, if an existing value was evicted, - /// etc. - pub fn getOrPut(self: *Self, allocator: Allocator, key: K) Allocator.Error!GetOrPutResult { - if (@sizeOf(Context) != 0) - @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutContext instead."); - return self.getOrPutContext(allocator, key, undefined); - } - - /// See getOrPut - pub fn getOrPutContext( - self: *Self, - alloc: Allocator, - key: K, - ctx: Context, - ) Allocator.Error!GetOrPutResult { - const map_gop = try self.map.getOrPutContext(alloc, key, ctx); - if (map_gop.found_existing) { - // Move to end to mark as most recently used - self.queue.remove(&map_gop.value_ptr.*.node); - self.queue.append(&map_gop.value_ptr.*.node); - - return GetOrPutResult{ - .found_existing = true, - .value_ptr = &map_gop.value_ptr.*.data.value, - .evicted = null, - }; - } - errdefer _ = self.map.remove(key); - - // We're evicting if our map insertion increased our capacity. - const evict = self.map.count() > self.capacity; - - // Get our entry. If we're not evicting then we allocate a new - // entry. If we are evicting then we avoid allocation by just - // reusing the entry we would've evicted. - const entry: *Entry = if (!evict) try alloc.create(Entry) else entry: { - // Our first node is the least recently used. - const least_used_node = self.queue.popFirst().?; - const least_used_entry: *Entry = .fromNode(least_used_node); - - // Remove the least used from the map - _ = self.map.remove(least_used_entry.data.key); - - break :entry least_used_entry; - }; - errdefer if (!evict) alloc.destroy(entry); - - // Store our entry in the map. - map_gop.value_ptr.* = entry; - - // Mark the entry as most recently used - self.queue.append(&entry.node); - - // Set our key - entry.data.key = key; - - return .{ - .found_existing = map_gop.found_existing, - .value_ptr = &entry.data.value, - .evicted = if (!evict) null else entry.data, - }; - } - - /// Get a value for a key. - pub fn get(self: *const Self, key: K) ?V { - if (@sizeOf(Context) != 0) { - @compileError("getContext must be used."); - } - return self.getContext(key, undefined); - } - - /// See get - pub fn getContext(self: *const Self, key: K, ctx: Context) ?V { - const node = self.map.getContext(key, ctx) orelse return null; - return node.data.value; - } - - /// Resize the LRU. If this shrinks the LRU then LRU items will be - /// deallocated. The deallocated items are returned in the slice. This - /// slice must be freed by the caller. - pub fn resize(self: *Self, alloc: Allocator, capacity: Map.Size) Allocator.Error!?[]V { - // Fastest - if (capacity >= self.capacity) { - self.capacity = capacity; - return null; - } - - // If we're shrinking but we're smaller than the new capacity, - // then we don't have to do anything. - if (self.map.count() <= capacity) { - self.capacity = capacity; - return null; - } - - // We're shrinking and we have more items than the new capacity - const delta = self.map.count() - capacity; - var evicted = try alloc.alloc(V, delta); - - var i: Map.Size = 0; - while (i < delta) : (i += 1) { - const node = self.queue.popFirst().?; - const entry: *Entry = .fromNode(node); - evicted[i] = entry.data.value; - self.queue.remove(node); - _ = self.map.remove(entry.data.key); - alloc.destroy(entry); - } - - self.capacity = capacity; - assert(self.map.count() == capacity); - - return evicted; - } - }; -} - -test "getOrPut" { - const testing = std.testing; - const alloc = testing.allocator; - - const Map = AutoHashMap(u32, u8); - var m = Map.init(2); - defer m.deinit(alloc); - - // Insert cap values, should be hits - { - const gop = try m.getOrPut(alloc, 1); - try testing.expect(!gop.found_existing); - try testing.expect(gop.evicted == null); - gop.value_ptr.* = 1; - } - { - const gop = try m.getOrPut(alloc, 2); - try testing.expect(!gop.found_existing); - try testing.expect(gop.evicted == null); - gop.value_ptr.* = 2; - } - - // 1 is LRU - try testing.expect((try m.getOrPut(alloc, 1)).found_existing); - try testing.expect((try m.getOrPut(alloc, 2)).found_existing); - - // Next should evict - { - const gop = try m.getOrPut(alloc, 3); - try testing.expect(!gop.found_existing); - try testing.expect(gop.evicted != null); - try testing.expect(gop.evicted.?.value == 1); - gop.value_ptr.* = 3; - } - - // Currently: 2 is LRU, let's make 3 LRU - try testing.expect((try m.getOrPut(alloc, 2)).found_existing); - - // Next should evict - { - const gop = try m.getOrPut(alloc, 4); - try testing.expect(!gop.found_existing); - try testing.expect(gop.evicted != null); - try testing.expect(gop.evicted.?.value == 3); - gop.value_ptr.* = 4; - } -} - -test "get" { - const testing = std.testing; - const alloc = testing.allocator; - - const Map = AutoHashMap(u32, u8); - var m = Map.init(2); - defer m.deinit(alloc); - - // Insert cap values, should be hits - { - const gop = try m.getOrPut(alloc, 1); - try testing.expect(!gop.found_existing); - try testing.expect(gop.evicted == null); - gop.value_ptr.* = 1; - } - - try testing.expect(m.get(1) != null); - try testing.expect(m.get(1).? == 1); - try testing.expect(m.get(2) == null); -} - -test "resize shrink without removal" { - const testing = std.testing; - const alloc = testing.allocator; - - const Map = AutoHashMap(u32, u8); - var m = Map.init(2); - defer m.deinit(alloc); - - // Insert cap values, LRU is 1 - { - const gop = try m.getOrPut(alloc, 1); - try testing.expect(!gop.found_existing); - try testing.expect(gop.evicted == null); - gop.value_ptr.* = 1; - } - - // Shrink - const evicted = try m.resize(alloc, 1); - try testing.expect(evicted == null); - { - const gop = try m.getOrPut(alloc, 1); - try testing.expect(gop.found_existing); - } -} - -test "resize shrink and remove" { - const testing = std.testing; - const alloc = testing.allocator; - - const Map = AutoHashMap(u32, u8); - var m = Map.init(2); - defer m.deinit(alloc); - - // Insert cap values, LRU is 1 - { - const gop = try m.getOrPut(alloc, 1); - try testing.expect(!gop.found_existing); - try testing.expect(gop.evicted == null); - gop.value_ptr.* = 1; - } - { - const gop = try m.getOrPut(alloc, 2); - try testing.expect(!gop.found_existing); - try testing.expect(gop.evicted == null); - gop.value_ptr.* = 2; - } - - // Shrink - const evicted = try m.resize(alloc, 1); - defer alloc.free(evicted.?); - try testing.expectEqual(@as(usize, 1), evicted.?.len); - { - const gop = try m.getOrPut(alloc, 1); - try testing.expect(!gop.found_existing); - try testing.expect(gop.evicted.?.value == 2); - gop.value_ptr.* = 1; - } -} diff --git a/src/datastruct/main.zig b/src/datastruct/main.zig index bfee23427..73e9afba9 100644 --- a/src/datastruct/main.zig +++ b/src/datastruct/main.zig @@ -5,16 +5,14 @@ const blocking_queue = @import("blocking_queue.zig"); const cache_table = @import("cache_table.zig"); const circ_buf = @import("circ_buf.zig"); const intrusive_linked_list = @import("intrusive_linked_list.zig"); -const segmented_pool = @import("segmented_pool.zig"); const split_tree = @import("split_tree.zig"); -pub const lru = @import("lru.zig"); pub const BlockingQueue = blocking_queue.BlockingQueue; pub const CacheTable = cache_table.CacheTable; pub const CircBuf = circ_buf.CircBuf; pub const IntrusiveDoublyLinkedList = intrusive_linked_list.DoublyLinkedList; +pub const LimitedAllocator = @import("limited_allocator.zig").LimitedAllocator; pub const MessageData = @import("message_data.zig").MessageData; -pub const SegmentedPool = segmented_pool.SegmentedPool; pub const SplitTree = split_tree.SplitTree; test { diff --git a/src/datastruct/segmented_pool.zig b/src/datastruct/segmented_pool.zig deleted file mode 100644 index 4ffc5d59f..000000000 --- a/src/datastruct/segmented_pool.zig +++ /dev/null @@ -1,96 +0,0 @@ -const std = @import("std"); -const assert = @import("../quirks.zig").inlineAssert; -const Allocator = std.mem.Allocator; -const SegmentedList = @import("segmented_list.zig").SegmentedList; -const testing = std.testing; - -/// A data structure where you can get stable (never copied) pointers to -/// a type that automatically grows if necessary. The values can be "put back" -/// but are expected to be put back IN ORDER. -/// -/// This is implemented specifically for libuv write requests, since the -/// write requests must have a stable pointer and are guaranteed to be processed -/// in order for a single stream. -/// -/// This is NOT thread safe. -pub fn SegmentedPool(comptime T: type, comptime prealloc: usize) type { - return struct { - const Self = @This(); - - i: usize = 0, - available: usize = prealloc, - list: SegmentedList(T, prealloc) = .{ .len = prealloc }, - - pub fn deinit(self: *Self, alloc: Allocator) void { - self.list.deinit(alloc); - self.* = undefined; - } - - /// Get the next available value out of the list. This will not - /// grow the list. - pub fn get(self: *Self) !*T { - // Error to not have any - if (self.available == 0) return error.OutOfValues; - - // The index we grab is just i % len, so we wrap around to the front. - const i = @mod(self.i, self.list.len); - self.i +%= 1; // Wrapping addition to swe go back to 0 - self.available -= 1; - return self.list.at(i); - } - - /// Get the next available value out of the list and grow the list - /// if necessary. - pub fn getGrow(self: *Self, alloc: Allocator) !*T { - if (self.available == 0) try self.grow(alloc); - return try self.get(); - } - - fn grow(self: *Self, alloc: Allocator) !void { - try self.list.growCapacity(alloc, self.list.len * 2); - self.i = self.list.len; - self.available = self.list.len; - self.list.len *= 2; - } - - /// Put a value back. The value put back is expected to be the - /// in order of get. - pub fn put(self: *Self) void { - self.available += 1; - assert(self.available <= self.list.len); - } - }; -} - -test "SegmentedPool" { - var list: SegmentedPool(u8, 2) = .{}; - defer list.deinit(testing.allocator); - try testing.expectEqual(@as(usize, 2), list.available); - - // Get to capacity - const v1 = try list.get(); - const v2 = try list.get(); - try testing.expect(v1 != v2); - try testing.expectError(error.OutOfValues, list.get()); - - // Test writing for later - v1.* = 42; - - // Put a value back - list.put(); - const temp = try list.get(); - try testing.expect(v1 == temp); - try testing.expect(temp.* == 42); - try testing.expectError(error.OutOfValues, list.get()); - - // Grow - const v3 = try list.getGrow(testing.allocator); - try testing.expect(v1 != v3 and v2 != v3); - _ = try list.get(); - try testing.expectError(error.OutOfValues, list.get()); - - // Put a value back - list.put(); - try testing.expect(v1 == try list.get()); - try testing.expectError(error.OutOfValues, list.get()); -} diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 1dc924ca7..012f107f3 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -183,6 +183,18 @@ pub fn SplitTree(comptime V: type) type { }; } + /// Find the handle within this tree that corresponds + /// to the given view. Uses pointer equality. + pub fn locate(self: *const Self, view: *const View) ?Node.Handle { + var it = self.iterator(); + while (it.next()) |entry| { + if (entry.view == view) { + return entry.handle; + } + } + return null; + } + /// An iterator over all the views in the tree. pub fn iterator( self: *const Self, diff --git a/src/fastprint.zig b/src/fastprint.zig new file mode 100644 index 000000000..e42030c20 --- /dev/null +++ b/src/fastprint.zig @@ -0,0 +1,68 @@ +//! Fastprint has fast printing routines that are significantly +//! faster than going through std.fmt. + +const std = @import("std"); + +/// Print a decimal type T. The buffer is expected to be large enough so if +/// necessary use comptime with a buffer-too-large to determine your +/// max size needed. Returns the length written. +pub fn printDecimal(comptime T: type, buf: []u8, v: T) usize { + // Note this only supports types as we need them. + switch (T) { + u8 => { + if (v >= 100) { + buf[0] = '0' + v / 100; + buf[1..3].* = std.fmt.digits2(v % 100); + return 3; + } + if (v >= 10) { + buf[0..2].* = std.fmt.digits2(v); + return 2; + } + buf[0] = '0' + v; + return 1; + }, + + // This can probably be generalized... + u21 => { + // Maximum u21 is 2097151: at most 7 digits. + var tmp: [7]u8 = undefined; + var val: u32 = v; + var i: usize = tmp.len; + while (true) { + i -= 1; + tmp[i] = '0' + @as(u8, @intCast(val % 10)); + val /= 10; + if (val == 0) break; + } + const n = tmp.len - i; + @memcpy(buf[0..n], tmp[i..]); + return n; + }, + + else => comptime unreachable, + } +} + +test printDecimal { + const testing = std.testing; + var buf: [16]u8 = undefined; + + // u8: exercise 1, 2, and 3 digit values including boundaries. + const u8_cases = [_]u8{ 0, 1, 9, 10, 99, 100, 255 }; + for (u8_cases) |v| { + var expected_buf: [3]u8 = undefined; + const expected = std.fmt.bufPrint(&expected_buf, "{d}", .{v}) catch unreachable; + const len = printDecimal(u8, &buf, v); + try testing.expectEqualStrings(expected, buf[0..len]); + } + + // u21: exercise digit count boundaries up to the maximum. + const u21_cases = [_]u21{ 0, 9, 10, 128, 65535, 1114111, std.math.maxInt(u21) }; + for (u21_cases) |v| { + var expected_buf: [8]u8 = undefined; + const expected = std.fmt.bufPrint(&expected_buf, "{d}", .{v}) catch unreachable; + const len = printDecimal(u21, &buf, v); + try testing.expectEqualStrings(expected, buf[0..len]); + } +} diff --git a/src/font/DeferredFace.zig b/src/font/DeferredFace.zig index 425f3c283..9a8124c96 100644 --- a/src/font/DeferredFace.zig +++ b/src/font/DeferredFace.zig @@ -184,7 +184,8 @@ pub fn name(self: DeferredFace, buf: []u8) ![]const u8 { .coretext_harfbuzz, .coretext_noshape, => if (self.ct) |ct| { - const display_name = ct.font.copyDisplayName(); + const display_name = ct.font.copyDisplayName() orelse + return self.familyName(buf); return display_name.cstringPtr(.utf8) orelse unsupported: { // "NULL if the internal storage of theString does not allow // this to be returned efficiently." In this case, we need diff --git a/src/font/SharedGridSet.zig b/src/font/SharedGridSet.zig index b4f602e89..dc7d8dd7c 100644 --- a/src/font/SharedGridSet.zig +++ b/src/font/SharedGridSet.zig @@ -340,6 +340,24 @@ fn collection( // specifying a font-family for emoji. if (comptime builtin.target.os.tag.isDarwin() and Discover != void) apple_emoji: { const disco = try self.discover() orelse break :apple_emoji; + + // Fast path: we know the exact name of the font we want so we + // can look it up directly, which is sometimes significantly faster than + // full discovery (e.g. CoreText). + if (@hasDecl(Discover, "discoverExactFamily")) { + if (try disco.discoverExactFamily( + "Apple Color Emoji", + )) |face| { + _ = try c.addDeferred(self.alloc, face, .{ + .style = .regular, + .fallback = true, + // No size adjustment for emojis. + .size_adjustment = .none, + }); + break :apple_emoji; + } + } + var disco_it = try disco.discover(self.alloc, .{ .family = "Apple Color Emoji", }); diff --git a/src/font/discovery.zig b/src/font/discovery.zig index d1e60fd34..1847693a6 100644 --- a/src/font/discovery.zig +++ b/src/font/discovery.zig @@ -350,6 +350,22 @@ pub const CoreText = struct { _ = self; } + /// Warm up the system font registry. + /// + /// The first CoreText query in a process initializes the system font + /// database, which takes multiple milliseconds, while subsequent + /// queries are microseconds. + pub fn warmup() void { + const name = macos.foundation.String.createWithBytes( + "AppleColorEmoji", + .utf8, + false, + ) catch return; + defer name.release(); + const ct_font = macos.text.Font.createWithName(name, 12) catch return; + ct_font.release(); + } + /// Discover fonts from a descriptor. This returns an iterator that can /// be used to build up the deferred fonts. pub fn discover(self: *const CoreText, alloc: Allocator, desc: Descriptor) !DiscoverIterator { @@ -383,6 +399,54 @@ pub const CoreText = struct { }; } + /// Discover a font by its exact name (family, full, or PostScript + /// name). This is significantly faster than `discover` because it + /// avoids the system-wide font matching that CTFontCollection does + /// (which takes multiple milliseconds). This should be preferred + /// when the desired font is known exactly, e.g. system fonts such + /// as Apple Color Emoji. + /// + /// Returns null if no font with this exact family name exists; + /// CoreText fallback fonts are never returned. + pub fn discoverExactFamily( + self: *const CoreText, + family: []const u8, + ) !?DeferredFace { + _ = self; + + const family_str = try macos.foundation.String.createWithBytes( + family, + .utf8, + false, + ); + defer family_str.release(); + + // Create our font. We need a size to initialize it so we use size + // 12 but we will alter the size later (same as DiscoverIterator). + const ct_font = try macos.text.Font.createWithName(family_str, 12); + + // CTFontCreateWithName never returns null: if the requested font + // isn't installed it returns a substitute font. Verify we got + // the family we asked for, otherwise report not found. + const found: bool = found: { + const actual = ct_font.copyFamilyName(); + defer actual.release(); + var buf: [256]u8 = undefined; + const actual_slice = actual.cstring(&buf, .utf8) orelse + break :found false; + break :found std.mem.eql(u8, actual_slice, family); + }; + if (!found) { + ct_font.release(); + return null; + } + + return .{ .ct = .{ + .font = ct_font, + .variations = &.{}, + } }; + } + pub fn discoverFallback( self: *const CoreText, alloc: Allocator, diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 27da357a7..5172744d8 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -361,7 +361,7 @@ pub const Shaper = struct { // Make room for the attributed string, CTTypesetter, and CTLine. try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 4); - const str = macos.foundation.String.createWithCharactersNoCopy(state.unichars.items); + const str = try macos.foundation.String.createWithCharactersNoCopy(state.unichars.items); self.cf_release_pool.appendAssumeCapacity(str); // Create an attributed string from our string @@ -381,7 +381,7 @@ pub const Shaper = struct { self.cf_release_pool.appendAssumeCapacity(typesetter); // Create a line from the typesetter - const line = typesetter.createLine(.{ .location = 0, .length = 0 }); + const line = try typesetter.createLine(.{ .location = 0, .length = 0 }); self.cf_release_pool.appendAssumeCapacity(line); // This keeps track of the current x offset (sum of advance.width) and diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index f83b51722..11f9fd23e 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -862,6 +862,41 @@ test "shape emoji width long" { try testing.expectEqual(@as(usize, 1), count); } +test "shaper selects font for entire grapheme" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + s.nextSlice("#\u{20E3}"); // Combining enclosing keycap + + const primary = (try testdata.grid.getIndex(alloc, '#', .regular, null)).?; + try testing.expect(testdata.grid.hasCodepoint(primary, '#', null)); + try testing.expect(!testdata.grid.hasCodepoint(primary, 0x20E3, null)); + + const additional = (try testdata.grid.getIndex(alloc, 0x20E3, .regular, null)).?; + try testing.expect(testdata.grid.hasCodepoint(additional, '#', null)); + try testing.expect(testdata.grid.hasCodepoint(additional, 0x20E3, null)); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + var it = testdata.shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + const run = (try it.next(alloc)).?; + try testing.expectEqual(additional, run.font_index); +} + test "shape variation selector VS15" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index 45c5d38ca..9f33ba32e 100644 --- a/src/font/shaper/run.zig +++ b/src/font/shaper/run.zig @@ -348,47 +348,34 @@ pub const RunIterator = struct { // we just return whatever index for the cell codepoint. if (!cell.hasGrapheme()) return primary; - // If this is a grapheme, we need to find a font that supports - // all of the codepoints in the grapheme. - var candidates: std.ArrayList(font.Collection.Index) = try .initCapacity( - alloc, - graphemes.len + 1, - ); - defer candidates.deinit(alloc); - candidates.appendAssumeCapacity(primary); + // Try the primary font followed by the font selected for each + // additional codepoint until one supports the entire grapheme. + candidate: for (0..graphemes.len + 1) |i| { + const idx = if (i == 0) primary else idx: { + const cp = graphemes[i - 1]; - for (graphemes) |cp| { - // Ignore Emoji ZWJs - if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue; + // Ignore Emoji ZWJs + if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue :candidate; - // Find a font that supports this codepoint. If none support this - // then the whole grapheme can't be rendered so we return null. - // - // We explicitly do not require the additional grapheme components - // to support the base presentation, since it is common for emoji - // fonts to support the base emoji with emoji presentation but not - // certain ZWJ-combined characters like the male and female signs. - const idx = try self.opts.grid.getIndex( - alloc, - cp, - style, - null, - ) orelse return null; - candidates.appendAssumeCapacity(idx); - } + // We explicitly do not require the additional grapheme components + // to support the base presentation, since it is common for emoji + // fonts to support the base emoji with emoji presentation but not + // certain ZWJ-combined characters like the male and female signs. + break :idx try self.opts.grid.getIndex( + alloc, + cp, + style, + null, + ) orelse return null; + }; - // We need to find a candidate that has ALL of our codepoints - for (candidates.items) |idx| { if (!self.opts.grid.hasCodepoint(idx, primary_cp, presentation)) continue; for (graphemes) |cp| { // Ignore Emoji ZWJs if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue; - if (!self.opts.grid.hasCodepoint(idx, cp, null)) break; - } else { - // If the while completed, then we have a candidate that - // supports all of our codepoints. - return idx; + if (!self.opts.grid.hasCodepoint(idx, cp, null)) continue :candidate; } + return idx; } return null; diff --git a/src/global.zig b/src/global.zig index 3c2ddbb72..3312039a9 100644 --- a/src/global.zig +++ b/src/global.zig @@ -1,6 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); const build_config = @import("build_config.zig"); +const build_options = @import("build_options"); const cli = @import("cli.zig"); const internal_os = @import("os/main.zig"); const fontconfig = @import("fontconfig"); @@ -179,13 +180,17 @@ pub fn init(opts: InitOpts) !void { // As early as possible, initialize our resource limits. self.rlimits = .init(); - // Initialize our crash reporting. - crash.init(self.alloc) catch |err| { - std.log.warn( - "sentry init failed, no crash capture available err={}", - .{err}, - ); - }; + if (build_options.sentry) { + // Initialize our crash reporting. The environ map snapshot is + // owned by crash.init (it is freed by the init thread). + const environ_map = try self.environ.createMap(self.alloc); + crash.init(self.alloc, environ_map) catch |err| { + std.log.warn( + "sentry init failed, no crash capture available err={}", + .{err}, + ); + }; + } // const sentrylib = @import("sentry"); // if (sentrylib.captureEvent(sentrylib.Value.initMessageEvent( diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 8c706027a..82b4bc8b8 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -587,6 +587,12 @@ pub const Action = union(enum) { /// the last tab. move_tab: isize, + /// Move a tab to a new window. + /// + /// Only implemented on Linux, but there's a native tab menu provided by + /// macOS. + move_tab_to_new_window, + /// Toggle the tab overview. /// /// This is only supported on Linux and when the system's libadwaita @@ -602,6 +608,13 @@ pub const Action = union(enum) { /// and persists across focus changes within the tab. prompt_tab_title, + /// Change the title of the current window via a pop-up prompt. The + /// title set via this prompt overrides any title set by the terminal + /// and persists across focus changes within the tab. + /// + /// Only implemented on Linux. + prompt_window_title, + /// Set the title for the current focused surface. /// /// If the title is empty, the surface title is reset to an empty title. @@ -612,6 +625,13 @@ pub const Action = union(enum) { /// If the title is empty, the tab title override is cleared. set_tab_title: []const u8, + /// Set the title for the current focused window. + /// + /// If the title is empty, the tab title override is cleared. + /// + /// Only implement on Linux. + set_window_title: []const u8, + /// Create a new split in the specified direction. /// /// Valid arguments: @@ -686,12 +706,14 @@ pub const Action = union(enum) { /// untested. show_on_screen_keyboard, - /// Open the configuration file in the default OS editor. + /// Open the configuration file in an editor. /// - /// If your default OS editor isn't configured then this will fail. - /// Currently, any failures to open the configuration will show up only in - /// the logs. - open_config, + /// * `os_open`: Use the OS's default editor to edit the configuration file. + /// This is the default action. (Available since 1.4.0) + /// * `new_window`: Launch the editor specified in `$EDITOR` or `$VISUAL` in + /// a new Ghostty window to edit the configuration file. GTK only. (Available + /// since 1.4.0.) + open_config: OpenConfig, /// Reload the configuration. /// @@ -741,8 +763,8 @@ pub const Action = union(enum) { /// Maximize or unmaximize the current window. /// - /// This has no effect on macOS as it does not have the concept of - /// maximized windows. + /// On macOS, this zooms the window, which is the closest equivalent + /// since macOS has no concept of a maximized window. toggle_maximize, /// Fullscreen or unfullscreen the current window. @@ -1186,6 +1208,16 @@ pub const Action = union(enum) { pub const default: CloseTabMode = .this; }; + pub const OpenConfig = enum { + /// Open the config in the OS default editor. + os_open, + + /// Open the config in a new window using $EDITOR or $VISUAL + new_window, + + pub const default: OpenConfig = .os_open; + }; + fn parseEnum(comptime T: type, value: []const u8) !T { return std.meta.stringToEnum(T, value) orelse return Error.InvalidFormat; } @@ -1370,8 +1402,10 @@ pub const Action = union(enum) { .set_font_size, .prompt_surface_title, .prompt_tab_title, + .prompt_window_title, .set_surface_title, .set_tab_title, + .set_window_title, .clear_screen, .select_all, .scroll_to_top, @@ -1418,6 +1452,7 @@ pub const Action = union(enum) { .last_tab, .goto_tab, .move_tab, + .move_tab_to_new_window, .toggle_tab_overview, .new_split, .goto_split, @@ -1524,7 +1559,7 @@ pub const Action = union(enum) { const value_info = @typeInfo(Value); switch (Value) { void => {}, - []const u8 => try std.zig.stringEscape(value, writer), + []const u8 => try writer.print("{s}", .{value}), else => switch (value_info) { .@"enum" => try writer.print("{t}", .{value}), .float => try writer.print("{d}", .{value}), @@ -4611,12 +4646,14 @@ test "action: format" { const testing = std.testing; const alloc = testing.allocator; - const a: Action = .{ .text = "👻" }; + const a: Action = .{ .text = "👻Ghostty'\"" }; var buf: std.Io.Writer.Allocating = .init(alloc); defer buf.deinit(); try a.format(&buf.writer); - try testing.expectEqualStrings("text:\\xf0\\x9f\\x91\\xbb", buf.written()); + + const b = try Binding.Action.parse(buf.written()); + try testing.expect(a.equal(b)); } test "action: format set title" { diff --git a/src/input/command.zig b/src/input/command.zig index ac048eec0..e75ede299 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -2,6 +2,7 @@ const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const Action = @import("Binding.zig").Action; +const i18n = @import("../os/i18n.zig"); /// A command is a named binding action that can be executed from /// something like a command palette. @@ -76,6 +77,14 @@ pub const Command = struct { }; } + pub fn translated(self: Command) Command { + return .{ + .action = self.action, + .title = std.mem.span(i18n._(self.title)), + .description = std.mem.span(i18n._(self.description)), + }; + } + /// Implements a comparison function for std.mem.sortUnstable /// and similar functions. The sorting is defined by Ghostty /// to be what we prefer. If a caller wants some other sorting, @@ -137,155 +146,155 @@ fn actionCommands(action: Action.Key) []const Command { .reset => comptime &.{.{ .action = .reset, - .title = "Reset Terminal", - .description = "Reset the terminal to a clean state.", + .title = i18n.N_("Reset Terminal"), + .description = i18n.N_("Reset the terminal to a clean state."), }}, .copy_to_clipboard => comptime &.{ .{ .action = .{ .copy_to_clipboard = .mixed }, - .title = "Copy to Clipboard", - .description = "Copy the selected text to the clipboard in both plain and styled formats.", + .title = i18n.N_("Copy to Clipboard"), + .description = i18n.N_("Copy the selected text to the clipboard in both plain and styled formats."), }, .{ .action = .{ .copy_to_clipboard = .plain }, - .title = "Copy Selection as Plain Text to Clipboard", - .description = "Copy the selected text as plain text to the clipboard.", + .title = i18n.N_("Copy Selection as Plain Text to Clipboard"), + .description = i18n.N_("Copy the selected text as plain text to the clipboard."), }, .{ .action = .{ .copy_to_clipboard = .vt }, - .title = "Copy Selection as ANSI Sequences to Clipboard", - .description = "Copy the selected text as ANSI escape sequences to the clipboard.", + .title = i18n.N_("Copy Selection as ANSI Sequences to Clipboard"), + .description = i18n.N_("Copy the selected text as ANSI escape sequences to the clipboard."), }, .{ .action = .{ .copy_to_clipboard = .html }, - .title = "Copy Selection as HTML to Clipboard", - .description = "Copy the selected text as HTML to the clipboard.", + .title = i18n.N_("Copy Selection as HTML to Clipboard"), + .description = i18n.N_("Copy the selected text as HTML to the clipboard."), } }, .copy_url_to_clipboard => comptime &.{.{ .action = .copy_url_to_clipboard, - .title = "Copy URL to Clipboard", - .description = "Copy the URL under the cursor to the clipboard.", + .title = i18n.N_("Copy URL to Clipboard"), + .description = i18n.N_("Copy the URL under the cursor to the clipboard."), }}, .copy_title_to_clipboard => comptime &.{.{ .action = .copy_title_to_clipboard, - .title = "Copy Terminal Title to Clipboard", - .description = "Copy the terminal title to the clipboard. If the terminal title is not set this has no effect.", + .title = i18n.N_("Copy Terminal Title to Clipboard"), + .description = i18n.N_("Copy the terminal title to the clipboard. If the terminal title is not set this has no effect."), }}, .paste_from_clipboard => comptime &.{.{ .action = .paste_from_clipboard, - .title = "Paste from Clipboard", - .description = "Paste the contents of the main clipboard.", + .title = i18n.N_("Paste from Clipboard"), + .description = i18n.N_("Paste the contents of the main clipboard."), }}, .paste_from_selection => comptime &.{.{ .action = .paste_from_selection, - .title = "Paste from Selection", - .description = "Paste the contents of the selection clipboard.", + .title = i18n.N_("Paste from Selection"), + .description = i18n.N_("Paste the contents of the selection clipboard."), }}, .start_search => comptime &.{.{ .action = .start_search, - .title = "Start Search", - .description = "Start a search if one isn't already active.", + .title = i18n.N_("Start Search"), + .description = i18n.N_("Start a search if one isn't already active."), }}, .search_selection => comptime &.{.{ .action = .search_selection, - .title = "Search Selection", - .description = "Start a search for the current text selection.", + .title = i18n.N_("Search Selection"), + .description = i18n.N_("Start a search for the current text selection."), }}, .end_search => comptime &.{.{ .action = .end_search, - .title = "End Search", - .description = "End the current search if any and hide any GUI elements.", + .title = i18n.N_("End Search"), + .description = i18n.N_("End the current search if any and hide any GUI elements."), }}, .navigate_search => comptime &.{ .{ .action = .{ .navigate_search = .next }, - .title = "Next Search Result", - .description = "Navigate to the next search result, if any.", + .title = i18n.N_("Next Search Result"), + .description = i18n.N_("Navigate to the next search result, if any."), }, .{ .action = .{ .navigate_search = .previous }, - .title = "Previous Search Result", - .description = "Navigate to the previous search result, if any.", + .title = i18n.N_("Previous Search Result"), + .description = i18n.N_("Navigate to the previous search result, if any."), } }, .increase_font_size => comptime &.{.{ .action = .{ .increase_font_size = 1 }, - .title = "Increase Font Size", - .description = "Increase the font size by 1 point.", + .title = i18n.N_("Increase Font Size"), + .description = i18n.N_("Increase the font size by 1 point."), }}, .decrease_font_size => comptime &.{.{ .action = .{ .decrease_font_size = 1 }, - .title = "Decrease Font Size", - .description = "Decrease the font size by 1 point.", + .title = i18n.N_("Decrease Font Size"), + .description = i18n.N_("Decrease the font size by 1 point."), }}, .reset_font_size => comptime &.{.{ .action = .reset_font_size, - .title = "Reset Font Size", - .description = "Reset the font size to the default.", + .title = i18n.N_("Reset Font Size"), + .description = i18n.N_("Reset the font size to the default."), }}, .clear_screen => comptime &.{.{ .action = .clear_screen, - .title = "Clear Screen", - .description = "Clear the screen and scrollback.", + .title = i18n.N_("Clear Screen"), + .description = i18n.N_("Clear the screen and scrollback."), }}, .select_all => comptime &.{.{ .action = .select_all, - .title = "Select All", - .description = "Select all text on the screen.", + .title = i18n.N_("Select All"), + .description = i18n.N_("Select all text on the screen."), }}, .scroll_to_top => comptime &.{.{ .action = .scroll_to_top, - .title = "Scroll to Top", - .description = "Scroll to the top of the screen.", + .title = i18n.N_("Scroll to Top"), + .description = i18n.N_("Scroll to the top of the screen."), }}, .scroll_to_bottom => comptime &.{.{ .action = .scroll_to_bottom, - .title = "Scroll to Bottom", - .description = "Scroll to the bottom of the screen.", + .title = i18n.N_("Scroll to Bottom"), + .description = i18n.N_("Scroll to the bottom of the screen."), }}, .scroll_to_selection => comptime &.{.{ .action = .scroll_to_selection, - .title = "Scroll to Selection", - .description = "Scroll to the selected text.", + .title = i18n.N_("Scroll to Selection"), + .description = i18n.N_("Scroll to the selected text."), }}, .scroll_page_up => comptime &.{.{ .action = .scroll_page_up, - .title = "Scroll Page Up", - .description = "Scroll the screen up by a page.", + .title = i18n.N_("Scroll Page Up"), + .description = i18n.N_("Scroll the screen up by a page."), }}, .scroll_page_down => comptime &.{.{ .action = .scroll_page_down, - .title = "Scroll Page Down", - .description = "Scroll the screen down by a page.", + .title = i18n.N_("Scroll Page Down"), + .description = i18n.N_("Scroll the screen down by a page."), }}, .write_screen_file => comptime &.{ .{ .action = .{ .write_screen_file = .copy }, - .title = "Copy Screen to Temporary File and Copy Path", - .description = "Copy the screen contents to a temporary file and copy the path to the clipboard.", + .title = i18n.N_("Copy Screen to Temporary File and Copy Path"), + .description = i18n.N_("Copy the screen contents to a temporary file and copy the path to the clipboard."), }, .{ .action = .{ .write_screen_file = .paste }, - .title = "Copy Screen to Temporary File and Paste Path", - .description = "Copy the screen contents to a temporary file and paste the path to the file.", + .title = i18n.N_("Copy Screen to Temporary File and Paste Path"), + .description = i18n.N_("Copy the screen contents to a temporary file and paste the path to the file."), }, .{ .action = .{ .write_screen_file = .open }, - .title = "Copy Screen to Temporary File and Open", - .description = "Copy the screen contents to a temporary file and open it.", + .title = i18n.N_("Copy Screen to Temporary File and Open"), + .description = i18n.N_("Copy the screen contents to a temporary file and open it."), }, .{ @@ -293,24 +302,24 @@ fn actionCommands(action: Action.Key) []const Command { .action = .copy, .emit = .html, } }, - .title = "Copy Screen as HTML to Temporary File and Copy Path", - .description = "Copy the screen contents as HTML to a temporary file and copy the path to the clipboard.", + .title = i18n.N_("Copy Screen as HTML to Temporary File and Copy Path"), + .description = i18n.N_("Copy the screen contents as HTML to a temporary file and copy the path to the clipboard."), }, .{ .action = .{ .write_screen_file = .{ .action = .paste, .emit = .html, } }, - .title = "Copy Screen as HTML to Temporary File and Paste Path", - .description = "Copy the screen contents as HTML to a temporary file and paste the path to the file.", + .title = i18n.N_("Copy Screen as HTML to Temporary File and Paste Path"), + .description = i18n.N_("Copy the screen contents as HTML to a temporary file and paste the path to the file."), }, .{ .action = .{ .write_screen_file = .{ .action = .open, .emit = .html, } }, - .title = "Copy Screen as HTML to Temporary File and Open", - .description = "Copy the screen contents as HTML to a temporary file and open it.", + .title = i18n.N_("Copy Screen as HTML to Temporary File and Open"), + .description = i18n.N_("Copy the screen contents as HTML to a temporary file and open it."), }, .{ @@ -318,42 +327,42 @@ fn actionCommands(action: Action.Key) []const Command { .action = .copy, .emit = .vt, } }, - .title = "Copy Screen as ANSI Sequences to Temporary File and Copy Path", - .description = "Copy the screen contents as ANSI escape sequences to a temporary file and copy the path to the clipboard.", + .title = i18n.N_("Copy Screen as ANSI Sequences to Temporary File and Copy Path"), + .description = i18n.N_("Copy the screen contents as ANSI escape sequences to a temporary file and copy the path to the clipboard."), }, .{ .action = .{ .write_screen_file = .{ .action = .paste, .emit = .vt, } }, - .title = "Copy Screen as ANSI Sequences to Temporary File and Paste Path", - .description = "Copy the screen contents as ANSI escape sequences to a temporary file and paste the path to the file.", + .title = i18n.N_("Copy Screen as ANSI Sequences to Temporary File and Paste Path"), + .description = i18n.N_("Copy the screen contents as ANSI escape sequences to a temporary file and paste the path to the file."), }, .{ .action = .{ .write_screen_file = .{ .action = .open, .emit = .vt, } }, - .title = "Copy Screen as ANSI Sequences to Temporary File and Open", - .description = "Copy the screen contents as ANSI escape sequences to a temporary file and open it.", + .title = i18n.N_("Copy Screen as ANSI Sequences to Temporary File and Open"), + .description = i18n.N_("Copy the screen contents as ANSI escape sequences to a temporary file and open it."), }, }, .write_selection_file => comptime &.{ .{ .action = .{ .write_selection_file = .copy }, - .title = "Copy Selection to Temporary File and Copy Path", - .description = "Copy the selection contents to a temporary file and copy the path to the clipboard.", + .title = i18n.N_("Copy Selection to Temporary File and Copy Path"), + .description = i18n.N_("Copy the selection contents to a temporary file and copy the path to the clipboard."), }, .{ .action = .{ .write_selection_file = .paste }, - .title = "Copy Selection to Temporary File and Paste Path", - .description = "Copy the selection contents to a temporary file and paste the path to the file.", + .title = i18n.N_("Copy Selection to Temporary File and Paste Path"), + .description = i18n.N_("Copy the selection contents to a temporary file and paste the path to the file."), }, .{ .action = .{ .write_selection_file = .open }, - .title = "Copy Selection to Temporary File and Open", - .description = "Copy the selection contents to a temporary file and open it.", + .title = i18n.N_("Copy Selection to Temporary File and Open"), + .description = i18n.N_("Copy the selection contents to a temporary file and open it."), }, .{ @@ -361,24 +370,24 @@ fn actionCommands(action: Action.Key) []const Command { .action = .copy, .emit = .html, } }, - .title = "Copy Selection as HTML to Temporary File and Copy Path", - .description = "Copy the selection contents as HTML to a temporary file and copy the path to the clipboard.", + .title = i18n.N_("Copy Selection as HTML to Temporary File and Copy Path"), + .description = i18n.N_("Copy the selection contents as HTML to a temporary file and copy the path to the clipboard."), }, .{ .action = .{ .write_selection_file = .{ .action = .paste, .emit = .html, } }, - .title = "Copy Selection as HTML to Temporary File and Paste Path", - .description = "Copy the selection contents as HTML to a temporary file and paste the path to the file.", + .title = i18n.N_("Copy Selection as HTML to Temporary File and Paste Path"), + .description = i18n.N_("Copy the selection contents as HTML to a temporary file and paste the path to the file."), }, .{ .action = .{ .write_selection_file = .{ .action = .open, .emit = .html, } }, - .title = "Copy Selection as HTML to Temporary File and Open", - .description = "Copy the selection contents as HTML to a temporary file and open it.", + .title = i18n.N_("Copy Selection as HTML to Temporary File and Open"), + .description = i18n.N_("Copy the selection contents as HTML to a temporary file and open it."), }, .{ @@ -386,299 +395,318 @@ fn actionCommands(action: Action.Key) []const Command { .action = .copy, .emit = .vt, } }, - .title = "Copy Selection as ANSI Sequences to Temporary File and Copy Path", - .description = "Copy the selection contents as ANSI escape sequences to a temporary file and copy the path to the clipboard.", + .title = i18n.N_("Copy Selection as ANSI Sequences to Temporary File and Copy Path"), + .description = i18n.N_("Copy the selection contents as ANSI escape sequences to a temporary file and copy the path to the clipboard."), }, .{ .action = .{ .write_selection_file = .{ .action = .paste, .emit = .vt, } }, - .title = "Copy Selection as ANSI Sequences to Temporary File and Paste Path", - .description = "Copy the selection contents as ANSI escape sequences to a temporary file and paste the path to the file.", + .title = i18n.N_("Copy Selection as ANSI Sequences to Temporary File and Paste Path"), + .description = i18n.N_("Copy the selection contents as ANSI escape sequences to a temporary file and paste the path to the file."), }, .{ .action = .{ .write_selection_file = .{ .action = .open, .emit = .vt, } }, - .title = "Copy Selection as ANSI Sequences to Temporary File and Open", - .description = "Copy the selection contents as ANSI escape sequences to a temporary file and open it.", + .title = i18n.N_("Copy Selection as ANSI Sequences to Temporary File and Open"), + .description = i18n.N_("Copy the selection contents as ANSI escape sequences to a temporary file and open it."), }, }, .new_window => comptime &.{.{ .action = .new_window, - .title = "New Window", - .description = "Open a new window.", + .title = i18n.N_("New Window"), + .description = i18n.N_("Open a new window."), }}, .new_tab => comptime &.{.{ .action = .new_tab, - .title = "New Tab", - .description = "Open a new tab.", + .title = i18n.N_("New Tab"), + .description = i18n.N_("Open a new tab."), }}, .move_tab => comptime &.{ .{ .action = .{ .move_tab = -1 }, - .title = "Move Tab Left", - .description = "Move the current tab to the left.", + .title = i18n.N_("Move Tab Left"), + .description = i18n.N_("Move the current tab to the left."), }, .{ .action = .{ .move_tab = 1 }, - .title = "Move Tab Right", - .description = "Move the current tab to the right.", + .title = i18n.N_("Move Tab Right"), + .description = i18n.N_("Move the current tab to the right."), }, }, + .move_tab_to_new_window => comptime &.{.{ + .action = .move_tab_to_new_window, + .title = i18n.N_("Move Tab to New Window"), + .description = i18n.N_("Move the current tab to a new window."), + }}, + .toggle_tab_overview => comptime &.{.{ .action = .toggle_tab_overview, - .title = "Toggle Tab Overview", - .description = "Toggle the tab overview.", + .title = i18n.N_("Toggle Tab Overview"), + .description = i18n.N_("Toggle the tab overview."), }}, .prompt_surface_title => comptime &.{.{ .action = .prompt_surface_title, - .title = "Change Terminal Title…", - .description = "Prompt for a new title for the current terminal.", + .title = i18n.N_("Change Terminal Title…"), + .description = i18n.N_("Prompt for a new title for the current terminal."), }}, .prompt_tab_title => comptime &.{.{ .action = .prompt_tab_title, - .title = "Change Tab Title…", - .description = "Prompt for a new title for the current tab.", + .title = i18n.N_("Change Tab Title…"), + .description = i18n.N_("Prompt for a new title for the current tab."), + }}, + + .prompt_window_title => comptime &.{.{ + .action = .prompt_window_title, + .title = "Change Window Title…", + .description = "Prompt for a new title for the current window.", }}, .new_split => comptime &.{ .{ .action = .{ .new_split = .left }, - .title = "Split Left", - .description = "Split the terminal to the left.", + .title = i18n.N_("Split Left"), + .description = i18n.N_("Split the terminal to the left."), }, .{ .action = .{ .new_split = .right }, - .title = "Split Right", - .description = "Split the terminal to the right.", + .title = i18n.N_("Split Right"), + .description = i18n.N_("Split the terminal to the right."), }, .{ .action = .{ .new_split = .up }, - .title = "Split Up", - .description = "Split the terminal up.", + .title = i18n.N_("Split Up"), + .description = i18n.N_("Split the terminal up."), }, .{ .action = .{ .new_split = .down }, - .title = "Split Down", - .description = "Split the terminal down.", + .title = i18n.N_("Split Down"), + .description = i18n.N_("Split the terminal down."), }, }, .goto_split => comptime &.{ .{ .action = .{ .goto_split = .previous }, - .title = "Focus Split: Previous", - .description = "Focus the previous split, if any.", + .title = i18n.N_("Focus Split: Previous"), + .description = i18n.N_("Focus the previous split, if any."), }, .{ .action = .{ .goto_split = .next }, - .title = "Focus Split: Next", - .description = "Focus the next split, if any.", + .title = i18n.N_("Focus Split: Next"), + .description = i18n.N_("Focus the next split, if any."), }, .{ .action = .{ .goto_split = .left }, - .title = "Focus Split: Left", - .description = "Focus the split to the left, if it exists.", + .title = i18n.N_("Focus Split: Left"), + .description = i18n.N_("Focus the split to the left, if it exists."), }, .{ .action = .{ .goto_split = .right }, - .title = "Focus Split: Right", - .description = "Focus the split to the right, if it exists.", + .title = i18n.N_("Focus Split: Right"), + .description = i18n.N_("Focus the split to the right, if it exists."), }, .{ .action = .{ .goto_split = .up }, - .title = "Focus Split: Up", - .description = "Focus the split above, if it exists.", + .title = i18n.N_("Focus Split: Up"), + .description = i18n.N_("Focus the split above, if it exists."), }, .{ .action = .{ .goto_split = .down }, - .title = "Focus Split: Down", - .description = "Focus the split below, if it exists.", + .title = i18n.N_("Focus Split: Down"), + .description = i18n.N_("Focus the split below, if it exists."), }, }, .goto_window => comptime &.{ .{ .action = .{ .goto_window = .previous }, - .title = "Focus Window: Previous", - .description = "Focus the previous window, if any.", + .title = i18n.N_("Focus Window: Previous"), + .description = i18n.N_("Focus the previous window, if any."), }, .{ .action = .{ .goto_window = .next }, - .title = "Focus Window: Next", - .description = "Focus the next window, if any.", + .title = i18n.N_("Focus Window: Next"), + .description = i18n.N_("Focus the next window, if any."), }, }, .toggle_split_zoom => comptime &.{.{ .action = .toggle_split_zoom, - .title = "Toggle Split Zoom", - .description = "Toggle the zoom state of the current split.", + .title = i18n.N_("Toggle Split Zoom"), + .description = i18n.N_("Toggle the zoom state of the current split."), }}, .toggle_readonly => comptime &.{.{ .action = .toggle_readonly, - .title = "Toggle Read-Only Mode", - .description = "Toggle read-only mode for the current surface.", + .title = i18n.N_("Toggle Read-Only Mode"), + .description = i18n.N_("Toggle read-only mode for the current surface."), }}, .equalize_splits => comptime &.{.{ .action = .equalize_splits, - .title = "Equalize Splits", - .description = "Equalize the size of all splits.", + .title = i18n.N_("Equalize Splits"), + .description = i18n.N_("Equalize the size of all splits."), }}, .reset_window_size => comptime &.{.{ .action = .reset_window_size, - .title = "Reset Window Size", - .description = "Reset the window size to the default.", + .title = i18n.N_("Reset Window Size"), + .description = i18n.N_("Reset the window size to the default."), }}, .inspector => comptime &.{.{ .action = .{ .inspector = .toggle }, - .title = "Toggle Inspector", - .description = "Toggle the inspector.", + .title = i18n.N_("Toggle Inspector"), + .description = i18n.N_("Toggle the inspector."), }}, .show_gtk_inspector => comptime &.{.{ .action = .show_gtk_inspector, - .title = "Show the GTK Inspector", - .description = "Show the GTK inspector.", + .title = i18n.N_("Show the GTK Inspector"), + .description = i18n.N_("Show the GTK inspector."), }}, .show_on_screen_keyboard => comptime &.{.{ .action = .show_on_screen_keyboard, - .title = "Show On-Screen Keyboard", - .description = "Show the on-screen keyboard if present.", + .title = i18n.N_("Show On-Screen Keyboard"), + .description = i18n.N_("Show the on-screen keyboard if present."), }}, - .open_config => comptime &.{.{ - .action = .open_config, - .title = "Open Config", - .description = "Open the config file.", - }}, + .open_config => comptime &.{ + .{ + .action = .{ .open_config = .os_open }, + .title = i18n.N_("Open Config Using OS editor"), + .description = i18n.N_("Open the config file with the OS's default editor."), + }, + .{ + .action = .{ .open_config = .new_window }, + .title = i18n.N_("Open Config in New Terminal Window"), + .description = i18n.N_("Open the config file in a new window using $EDITOR or $VISUAL."), + }, + }, .reload_config => comptime &.{.{ .action = .reload_config, - .title = "Reload Config", - .description = "Reload the config file.", + .title = i18n.N_("Reload Config"), + .description = i18n.N_("Reload the config file."), }}, .close_surface => comptime &.{.{ .action = .close_surface, - .title = "Close Terminal", - .description = "Close the current terminal.", + .title = i18n.N_("Close Terminal"), + .description = i18n.N_("Close the current terminal."), }}, .close_tab => comptime &.{ .{ .action = .{ .close_tab = .this }, - .title = "Close Tab", - .description = "Close the current tab.", + .title = i18n.N_("Close Tab"), + .description = i18n.N_("Close the current tab."), }, .{ .action = .{ .close_tab = .other }, - .title = "Close Other Tabs", - .description = "Close all tabs in this window except the current one.", + .title = i18n.N_("Close Other Tabs"), + .description = i18n.N_("Close all tabs in this window except the current one."), }, .{ .action = .{ .close_tab = .right }, - .title = "Close Tabs to the Right", - .description = "Close all tabs to the right of the current one.", + .title = i18n.N_("Close Tabs to the Right"), + .description = i18n.N_("Close all tabs to the right of the current one."), }, }, .close_window => comptime &.{.{ .action = .close_window, - .title = "Close Window", - .description = "Close the current window.", + .title = i18n.N_("Close Window"), + .description = i18n.N_("Close the current window."), }}, .close_all_windows => comptime &.{.{ .action = .close_all_windows, - .title = "Close All Windows", - .description = "Close all windows.", + .title = i18n.N_("Close All Windows"), + .description = i18n.N_("Close all windows."), }}, .toggle_maximize => comptime &.{.{ .action = .toggle_maximize, - .title = "Toggle Maximize", - .description = "Toggle the maximized state of the current window.", + .title = i18n.N_("Toggle Maximize"), + .description = i18n.N_("Toggle the maximized state of the current window."), }}, .toggle_fullscreen => comptime &.{.{ .action = .toggle_fullscreen, - .title = "Toggle Fullscreen", - .description = "Toggle the fullscreen state of the current window.", + .title = i18n.N_("Toggle Fullscreen"), + .description = i18n.N_("Toggle the fullscreen state of the current window."), }}, .toggle_window_decorations => comptime &.{.{ .action = .toggle_window_decorations, - .title = "Toggle Window Decorations", - .description = "Toggle the window decorations.", + .title = i18n.N_("Toggle Window Decorations"), + .description = i18n.N_("Toggle the window decorations."), }}, .toggle_window_float_on_top => comptime &.{.{ .action = .toggle_window_float_on_top, - .title = "Toggle Float on Top", - .description = "Toggle the float on top state of the current window.", + .title = i18n.N_("Toggle Float on Top"), + .description = i18n.N_("Toggle the float on top state of the current window."), }}, .toggle_secure_input => comptime &.{.{ .action = .toggle_secure_input, - .title = "Toggle Secure Input", - .description = "Toggle secure input mode.", + .title = i18n.N_("Toggle Secure Input"), + .description = i18n.N_("Toggle secure input mode."), }}, .toggle_mouse_reporting => comptime &.{.{ .action = .toggle_mouse_reporting, - .title = "Toggle Mouse Reporting", - .description = "Toggle whether mouse events are reported to terminal applications.", + .title = i18n.N_("Toggle Mouse Reporting"), + .description = i18n.N_("Toggle whether mouse events are reported to terminal applications."), }}, .toggle_background_opacity => comptime &.{.{ .action = .toggle_background_opacity, - .title = "Toggle Background Opacity", - .description = "Toggle the background opacity of a window that started transparent.", + .title = i18n.N_("Toggle Background Opacity"), + .description = i18n.N_("Toggle the background opacity of a window that started transparent."), }}, .check_for_updates => comptime &.{.{ .action = .check_for_updates, - .title = "Check for Updates", - .description = "Check for updates to the application.", + .title = i18n.N_("Check for Updates"), + .description = i18n.N_("Check for updates to the application."), }}, .undo => comptime &.{.{ .action = .undo, - .title = "Undo", - .description = "Undo the last action.", + .title = i18n.N_("Undo"), + .description = i18n.N_("Undo the last action."), }}, .redo => comptime &.{.{ .action = .redo, - .title = "Redo", - .description = "Redo the last undone action.", + .title = i18n.N_("Redo"), + .description = i18n.N_("Redo the last undone action."), }}, .quit => comptime &.{.{ .action = .quit, - .title = "Quit", - .description = "Quit the application.", + .title = i18n.N_("Quit"), + .description = i18n.N_("Quit the application."), }}, .text => comptime &.{.{ .action = .{ .text = "👻" }, - .title = "Ghostty", - .description = "Put a little Ghostty in your terminal.", + .title = i18n.N_("Ghostty"), + .description = i18n.N_("Put a little Ghostty in your terminal."), }}, // No commands because they're parameterized and there @@ -691,6 +719,7 @@ fn actionCommands(action: Action.Key) []const Command { .set_font_size, .set_surface_title, .set_tab_title, + .set_window_title, .search, .scroll_to_row, .scroll_page_fractional, diff --git a/src/inspector/widgets/surface.zig b/src/inspector/widgets/surface.zig index 28bd1def0..a23aebf08 100644 --- a/src/inspector/widgets/surface.zig +++ b/src/inspector/widgets/surface.zig @@ -47,7 +47,7 @@ pub const Inspector = struct { pub fn draw( self: *Inspector, - surface: *const Surface, + surface: *Surface, mouse: Mouse, ) void { // Create our dockspace first. If we had to setup our dockspace, @@ -111,7 +111,7 @@ pub const Inspector = struct { defer cimgui.c.ImGui_End(); if (open) { self.vt_stream.draw( - surface.alloc, + surface, &t.colors.palette.current, ); } diff --git a/src/inspector/widgets/termio.zig b/src/inspector/widgets/termio.zig index 04574f4ce..2c94b1bf8 100644 --- a/src/inspector/widgets/termio.zig +++ b/src/inspector/widgets/termio.zig @@ -7,6 +7,8 @@ const CircBuf = @import("../../datastruct/main.zig").CircBuf; const Surface = @import("../../Surface.zig"); const screen = @import("screen.zig"); +const log = std.log.scoped(.inspector_terminal_io); + /// VT event stream inspector widget. pub const Stream = struct { events: VTEvent.Ring, @@ -30,7 +32,10 @@ pub const Stream = struct { return .{ .events = events, - .parser_stream = .initAlloc(alloc, handler), + .parser_stream = .init(.{ + .allocator = alloc, + .handler = handler, + }), }; } @@ -59,9 +64,10 @@ pub const Stream = struct { pub fn draw( self: *Stream, - alloc: Allocator, + surface: *Surface, palette: *const terminal.color.Palette, ) void { + const alloc = surface.alloc; const events = &self.events; const handler = &self.parser_stream.handler; const popup_filter = "Filter"; @@ -93,6 +99,16 @@ pub const Stream = struct { handler.current_seq = 1; } + + cimgui.c.ImGui_SameLineEx(0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x); + if (cimgui.c.ImGui_Button("Copy")) { + self.copyEvents(surface); + } + + cimgui.c.ImGui_SameLineEx(0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x); + if (cimgui.c.ImGui_Button("Export to file")) { + self.exportEvents(surface); + } } } @@ -330,6 +346,73 @@ pub const Stream = struct { } } // filter popup } + + /// Copy all recorded events to the standard clipboard in chronological + /// order. + fn copyEvents( + self: *const Stream, + surface: *Surface, + ) void { + const alloc = surface.alloc; + const contents = self.formatEvents(alloc) catch |err| { + log.err("failed to format terminal IO events for copy err={}", .{err}); + return; + }; + defer alloc.free(contents); + + surface.rt_surface.setClipboard(.standard, &.{.{ + .mime = "text/plain", + .data = contents, + }}, false) catch |err| { + log.err("failed to copy terminal IO events err={}", .{err}); + }; + } + + /// Ask the application runtime to save all recorded events to a file. + fn exportEvents( + self: *const Stream, + surface: *Surface, + ) void { + const alloc = surface.alloc; + const contents = self.formatEvents(alloc) catch |err| { + log.err("failed to format terminal IO events for export err={}", .{err}); + return; + }; + defer alloc.free(contents); + + const handled = surface.rt_app.performAction( + .{ .surface = surface }, + .export_terminal_io, + .{ .contents = contents }, + ) catch |err| { + log.err("failed to export terminal IO events err={}", .{err}); + return; + }; + if (!handled) { + log.warn("application runtime does not support exporting terminal IO events", .{}); + } + } + + /// Format all recorded events as tab-separated text. Events are exported + /// oldest-first even though the inspector displays the newest event first. + fn formatEvents( + self: *const Stream, + alloc: Allocator, + ) ![:0]u8 { + var buf: std.Io.Writer.Allocating = .init(alloc); + errdefer buf.deinit(); + + try buf.writer.writeAll("Seq\tKind\tDescription\n"); + var it = self.events.iterator(.forward); + while (it.next()) |ev| { + try buf.writer.print( + "{d}\t{s}\t{s}\n", + .{ ev.seq, @tagName(ev.kind), ev.raw_description }, + ); + } + + return try buf.toOwnedSliceSentinel(0); + } }; /// Helper function to check keyboard state and determine navigation action. diff --git a/src/lib/TinyIo.zig b/src/lib/TinyIo.zig new file mode 100644 index 000000000..f89278070 --- /dev/null +++ b/src/lib/TinyIo.zig @@ -0,0 +1,1522 @@ +//! TinyIo: a tiny, blocking `std.Io` implementation optimized for +//! binary size. +//! +//! Compared to `std.Io.Threaded`, the binary cost is roughly ~100KB to +//! ~200KB (macOS vs Linux) smaller, and the runtime cost is ~300KB (256KB +//! of TLS plus the ~20KB threaded structure) smaller. +//! +//! Utilizing the built-in Zig `std.Io.Threaded` is easy but due to its +//! vtable architecture, the linker can't prune uncalled functions, meaning +//! you have to pay for all the code for every op. Plus, at runtime, Threaded +//! requires 256KB of thread-local storage, plus the structure itself is +//! very large. +//! +//! TinyIo implements exactly the operations we need as plain blocking +//! syscalls. It doesn't support cancelation. It doesn't support concurrency +//! operations, but all our APIs are direct syscalls so it supports the +//! concurrency of calls that those syscalls support (which is usually +//! safe on POSIX systems). +//! +//! A lot of the direct syscall code is cribbed from Zig's std.posix. + +const TinyIo = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const posix = std.posix; +const Io = std.Io; +const File = Io.File; +const Dir = Io.Dir; +const Threaded = std.Io.Threaded; + +/// True if this platform has a real TinyIo implementation. On +/// unsupported platforms `io()` still works but every operation fails +/// like `std.Io.failing`. +pub const supported: bool = switch (builtin.os.tag) { + .windows, .wasi, .freestanding, .other, .uefi => false, + else => true, +}; + +/// Initialize a TinyIo. TinyIo is stateless (zero-sized), so this exists +/// purely to mirror the `std.Io.Threaded` initialization and usage shape, +/// making the two easy to swap. +pub const init: TinyIo = .{}; + +/// Returns the `std.Io` interface for this implementation. +pub fn io(self: TinyIo) Io { + _ = self; + return .{ + .userdata = null, + .vtable = &vtable, + }; +} + +const vtable: Io.VTable = if (!supported) std.Io.failing.vtable.* else .{ + .crashHandler = Io.noCrashHandler, + + // `noAsync` runs the task synchronously and returns null, which means + // `await`/`cancel` can never be called. `concurrent` reports that + // concurrency is unavailable, which callers must handle anyway. + .async = Io.noAsync, + .concurrent = Io.failingConcurrent, + .await = Io.unreachableAwait, + .cancel = Io.unreachableCancel, + + .groupAsync = Io.noGroupAsync, + .groupConcurrent = Io.failingGroupConcurrent, + .groupAwait = Io.unreachableGroupAwait, + .groupCancel = Io.unreachableGroupCancel, + + // Cancelation is never requested (there is no async), but these are + // benign no-ops instead of `unreachable` since generic std code may + // toggle cancel protection around operations. + .recancel = recancel, + .swapCancelProtection = swapCancelProtection, + .checkCancel = checkCancel, + + // Real futexes: `std.Io.Mutex` (used by kitty graphics storage) parks + // on these when contended. + .futexWait = futexWait, + .futexWaitUncancelable = futexWaitUncancelable, + .futexWake = futexWake, + + // `operate` carries the streaming file reads used by `File.Reader`. + .operate = operate, + .batchAwaitAsync = Io.unreachableBatchAwaitAsync, + .batchAwaitConcurrent = Io.unreachableBatchAwaitConcurrent, + .batchCancel = Io.unreachableBatchCancel, + + .dirCreateDir = Io.failingDirCreateDir, + .dirCreateDirPath = Io.failingDirCreateDirPath, + .dirCreateDirPathOpen = Io.failingDirCreateDirPathOpen, + .dirOpenDir = Io.failingDirOpenDir, + .dirStat = Io.failingDirStat, + .dirStatFile = Io.failingDirStatFile, + .dirAccess = Io.failingDirAccess, + .dirCreateFile = Io.failingDirCreateFile, + .dirCreateFileAtomic = Io.failingDirCreateFileAtomic, + .dirOpenFile = dirOpenFile, + .dirClose = dirClose, + .dirRead = Io.noDirRead, + .dirRealPath = Io.failingDirRealPath, + .dirRealPathFile = dirRealPathFile, + .dirDeleteFile = dirDeleteFile, + .dirDeleteDir = Io.failingDirDeleteDir, + .dirRename = Io.failingDirRename, + .dirRenamePreserve = Io.failingDirRenamePreserve, + .dirSymLink = Io.failingDirSymLink, + .dirReadLink = Io.failingDirReadLink, + .dirSetOwner = Io.failingDirSetOwner, + .dirSetFileOwner = Io.failingDirSetFileOwner, + .dirSetPermissions = Io.failingDirSetPermissions, + .dirSetFilePermissions = Io.failingDirSetFilePermissions, + .dirSetTimestamps = Io.noDirSetTimestamps, + .dirHardLink = Io.failingDirHardLink, + + .fileStat = fileStat, + .fileLength = fileLength, + .fileClose = fileClose, + .fileWritePositional = Io.failingFileWritePositional, + .fileWriteFileStreaming = Io.noFileWriteFileStreaming, + .fileWriteFilePositional = Io.noFileWriteFilePositional, + .fileReadPositional = fileReadPositional, + .fileSeekBy = fileSeekBy, + .fileSeekTo = fileSeekTo, + .fileSync = Io.failingFileSync, + .fileIsTty = Io.unreachableFileIsTty, + .fileEnableAnsiEscapeCodes = Io.unreachableFileEnableAnsiEscapeCodes, + .fileSupportsAnsiEscapeCodes = Io.unreachableFileSupportsAnsiEscapeCodes, + .fileSetLength = Io.failingFileSetLength, + .fileSetOwner = Io.failingFileSetOwner, + .fileSetPermissions = Io.failingFileSetPermissions, + .fileSetTimestamps = Io.noFileSetTimestamps, + .fileLock = Io.failingFileLock, + .fileTryLock = Io.failingFileTryLock, + .fileUnlock = Io.unreachableFileUnlock, + .fileDowngradeLock = Io.failingFileDowngradeLock, + .fileRealPath = fileRealPath, + .fileHardLink = Io.failingFileHardLink, + + .fileMemoryMapCreate = Io.failingFileMemoryMapCreate, + .fileMemoryMapDestroy = Io.unreachableFileMemoryMapDestroy, + .fileMemoryMapSetLength = Io.unreachableFileMemoryMapSetLength, + .fileMemoryMapRead = Io.unreachableFileMemoryMapRead, + .fileMemoryMapWrite = Io.unreachableFileMemoryMapWrite, + + .processExecutableOpen = Io.failingProcessExecutableOpen, + .processExecutablePath = Io.failingProcessExecutablePath, + .lockStderr = Io.unreachableLockStderr, + .tryLockStderr = Io.noTryLockStderr, + .unlockStderr = Io.unreachableUnlockStderr, + .processCurrentPath = Io.failingProcessCurrentPath, + .processSetCurrentDir = Io.failingProcessSetCurrentDir, + .processSetCurrentPath = Io.failingProcessSetCurrentPath, + .processReplace = Io.failingProcessReplace, + .processReplacePath = Io.failingProcessReplacePath, + .processSpawn = Io.failingProcessSpawn, + .processSpawnPath = Io.failingProcessSpawnPath, + .childWait = Io.unreachableChildWait, + .childKill = Io.unreachableChildKill, + + .progressParentFile = Io.failingProgressParentFile, + + .random = Io.noRandom, + .randomSecure = Io.failingRandomSecure, + + .now = Io.noNow, + .clockResolution = Io.failingClockResolution, + .sleep = Io.noSleep, + + .netListenIp = Io.failingNetListenIp, + .netAccept = Io.failingNetAccept, + .netBindIp = Io.failingNetBindIp, + .netConnectIp = Io.failingNetConnectIp, + .netListenUnix = Io.failingNetListenUnix, + .netConnectUnix = Io.failingNetConnectUnix, + .netSocketCreatePair = Io.failingNetSocketCreatePair, + .netSend = Io.failingNetSend, + .netRead = Io.failingNetRead, + .netWrite = Io.failingNetWrite, + .netWriteFile = Io.failingNetWriteFile, + .netClose = Io.unreachableNetClose, + .netShutdown = Io.failingNetShutdown, + .netInterfaceNameResolve = Io.failingNetInterfaceNameResolve, + .netInterfaceName = Io.unreachableNetInterfaceName, + .netLookup = Io.failingNetLookup, +}; + +// 64-bit offset syscall selection, same as `std.Io.Threaded`. +const openat_sym = if (posix.lfs64_abi) posix.system.openat64 else posix.system.openat; +const fstat_sym = if (posix.lfs64_abi) posix.system.fstat64 else posix.system.fstat; +const fstatat_sym = if (posix.lfs64_abi) posix.system.fstatat64 else posix.system.fstatat; +const lseek_sym = if (posix.lfs64_abi) posix.system.lseek64 else posix.system.lseek; +const preadv_sym = if (posix.lfs64_abi) posix.system.preadv64 else posix.system.preadv; + +const have_preadv = switch (builtin.os.tag) { + .haiku => false, + else => true, +}; + +fn recancel(_: ?*anyopaque) void {} + +fn swapCancelProtection( + _: ?*anyopaque, + new: Io.CancelProtection, +) Io.CancelProtection { + // Stateless: there is no async, so cancelation can never be requested + // and the protection state is meaningless. Callers save this return + // value only to restore it via another call to this function. + _ = new; + return .blocked; +} + +fn checkCancel(_: ?*anyopaque) Io.Cancelable!void {} + +fn closeFd(fd: posix.fd_t) void { + // Never retry close on EINTR: POSIX leaves the fd state unspecified + // and Linux always closes it, so retrying risks closing an unrelated + // fd opened by another thread. + _ = posix.system.close(fd); +} + +fn dirOpenFile( + _: ?*anyopaque, + dir: Dir, + sub_path: []const u8, + options: Dir.OpenFileOptions, +) File.OpenError!File { + var path_buffer: [posix.PATH_MAX]u8 = undefined; + const sub_path_posix = try Threaded.pathToPosix(sub_path, &path_buffer); + + // Nothing in the terminal locks files. Implementing this requires + // flock fallbacks (see std.Io.Threaded); report it as unsupported. + if (options.lock != .none) return error.FileLocksUnsupported; + + var flags: posix.O = .{ + .ACCMODE = switch (options.mode) { + .read_only => .RDONLY, + .write_only => .WRONLY, + .read_write => .RDWR, + }, + .NOFOLLOW = !options.follow_symlinks, + }; + if (@hasField(posix.O, "CLOEXEC")) flags.CLOEXEC = true; + if (@hasField(posix.O, "LARGEFILE")) flags.LARGEFILE = true; + if (@hasField(posix.O, "NOCTTY")) flags.NOCTTY = !options.allow_ctty; + if (@hasField(posix.O, "PATH")) flags.PATH = options.path_only; + if (@hasField(posix.O, "RESOLVE_BENEATH")) flags.RESOLVE_BENEATH = options.resolve_beneath; + + const mode: posix.mode_t = 0; + const fd: posix.fd_t = while (true) { + const rc = openat_sym(dir.handle, sub_path_posix, flags, mode); + switch (posix.errno(rc)) { + .SUCCESS => break @intCast(rc), + .INTR => continue, + .INVAL => return error.BadPathName, + .ACCES => return error.AccessDenied, + .FBIG => return error.FileTooBig, + .OVERFLOW => return error.FileTooBig, + .ISDIR => return error.IsDir, + .LOOP => return error.SymLinkLoop, + .MFILE => return error.ProcessFdQuotaExceeded, + .NAMETOOLONG => return error.NameTooLong, + .NFILE => return error.SystemFdQuotaExceeded, + .NODEV => return error.NoDevice, + .NOENT => return error.FileNotFound, + .SRCH => return error.FileNotFound, + .NOMEM => return error.SystemResources, + .NOSPC => return error.NoSpaceLeft, + .NOTDIR => return error.NotDir, + .PERM => return error.PermissionDenied, + .EXIST => return error.PathAlreadyExists, + .BUSY => return error.DeviceBusy, + .OPNOTSUPP => return error.FileLocksUnsupported, + .AGAIN => return error.WouldBlock, + .TXTBSY => return error.FileBusy, + .NXIO => return error.NoDevice, + .ROFS => return error.ReadOnlyFileSystem, + .ILSEQ => return error.BadPathName, + else => |err| return posix.unexpectedErrno(err), + } + }; + errdefer closeFd(fd); + + const file: File = .{ .handle = fd, .flags = .{ .nonblocking = false } }; + + if (!options.allow_directory) { + const is_dir = is_dir: { + const stat = fileStat(null, file) catch |err| switch (err) { + // Directory-ness is unknown or unknowable. + error.Streaming => break :is_dir false, + else => |e| return e, + }; + break :is_dir stat.kind == .directory; + }; + if (is_dir) return error.IsDir; + } + + return file; +} + +fn dirClose(_: ?*anyopaque, dirs: []const Dir) void { + for (dirs) |dir| closeFd(dir.handle); +} + +fn fileClose(_: ?*anyopaque, files: []const File) void { + for (files) |file| closeFd(file.handle); +} + +fn fileStat(_: ?*anyopaque, file: File) File.StatError!File.Stat { + if (builtin.os.tag == .linux) { + const linux = std.os.linux; + while (true) { + var statx = std.mem.zeroes(linux.Statx); + switch (linux.errno(linux.statx( + file.handle, + "", + linux.AT.EMPTY_PATH, + Threaded.linux_statx_request, + &statx, + ))) { + .SUCCESS => return Threaded.statFromLinux(&statx), + .INTR => continue, + .NOMEM => return error.SystemResources, + else => |err| return posix.unexpectedErrno(err), + } + } + } + + while (true) { + var stat = std.mem.zeroes(posix.Stat); + switch (posix.errno(fstat_sym(file.handle, &stat))) { + .SUCCESS => return Threaded.statFromPosix(&stat), + .INTR => continue, + .NOMEM => return error.SystemResources, + .ACCES => return error.AccessDenied, + else => |err| return posix.unexpectedErrno(err), + } + } +} + +fn fileLength(userdata: ?*anyopaque, file: File) File.LengthError!u64 { + const stat = try fileStat(userdata, file); + return stat.size; +} + +/// Gathers non-empty buffers into iovecs. Returns an empty slice if there +/// is nothing to read into. +fn buffersToIovecs( + data: []const []u8, + iovecs_buffer: *[Threaded.max_iovecs_len]posix.iovec, +) []posix.iovec { + var i: usize = 0; + for (data) |buf| { + if (iovecs_buffer.len - i == 0) break; + if (buf.len != 0) { + iovecs_buffer[i] = .{ .base = buf.ptr, .len = buf.len }; + i += 1; + } + } + return iovecs_buffer[0..i]; +} + +fn fileReadPositional( + _: ?*anyopaque, + file: File, + data: []const []u8, + offset: u64, +) File.ReadPositionalError!usize { + var iovecs_buffer: [Threaded.max_iovecs_len]posix.iovec = undefined; + const dest = buffersToIovecs(data, &iovecs_buffer); + if (dest.len == 0) return 0; + + while (true) { + const rc = if (comptime have_preadv) + preadv_sym(file.handle, dest.ptr, @intCast(dest.len), @bitCast(offset)) + else + posix.system.pread(file.handle, dest[0].base, dest[0].len, @bitCast(offset)); + switch (posix.errno(rc)) { + .SUCCESS => return @bitCast(rc), + .INTR, .TIMEDOUT => continue, + .NXIO => return error.Unseekable, + .SPIPE => return error.Unseekable, + .OVERFLOW => return error.Unseekable, + .NOBUFS => return error.SystemResources, + .NOMEM => return error.SystemResources, + .AGAIN => return error.WouldBlock, + .IO => return error.InputOutput, + .ISDIR => return error.IsDir, + .BADF => return error.NotOpenForReading, + else => |err| return posix.unexpectedErrno(err), + } + } +} + +fn fileReadStreaming( + file: File, + data: []const []u8, +) Io.Operation.FileReadStreaming.Error!usize { + var iovecs_buffer: [Threaded.max_iovecs_len]posix.iovec = undefined; + const dest = buffersToIovecs(data, &iovecs_buffer); + if (dest.len == 0) return 0; + + while (true) { + const rc = posix.system.readv(file.handle, dest.ptr, @intCast(dest.len)); + switch (posix.errno(rc)) { + .SUCCESS => { + if (rc == 0) return error.EndOfStream; + return @intCast(rc); + }, + .INTR, .TIMEDOUT => continue, + .AGAIN => return error.WouldBlock, + .IO => return error.InputOutput, + .ISDIR => return error.IsDir, + .NOBUFS => return error.SystemResources, + .NOMEM => return error.SystemResources, + .NOTCONN => return error.SocketUnconnected, + .CONNRESET => return error.ConnectionResetByPeer, + .BADF => return error.NotOpenForReading, + else => |err| return posix.unexpectedErrno(err), + } + } +} + +fn operate( + userdata: ?*anyopaque, + operation: Io.Operation, +) Io.Cancelable!Io.Operation.Result { + switch (operation) { + // Streaming file reads are how `File.Reader` consumes files that + // don't support positional reads (and the fallback path in + // general). + .file_read_streaming => |o| return .{ + .file_read_streaming = fileReadStreaming(o.file, o.data), + }, + + // Everything else (streaming writes, ioctls, socket receives) is + // unused by the terminal; fail like `Io.failing` does. + else => return Io.failingOperate(userdata, operation), + } +} + +fn fileSeekBy(_: ?*anyopaque, file: File, offset: i64) File.SeekError!void { + while (true) { + const rc = lseek_sym(file.handle, offset, posix.SEEK.CUR); + switch (posix.errno(rc)) { + .SUCCESS => return, + .INTR => continue, + .INVAL => return error.Unseekable, + .OVERFLOW => return error.Unseekable, + .SPIPE => return error.Unseekable, + .NXIO => return error.Unseekable, + else => |err| return posix.unexpectedErrno(err), + } + } +} + +fn fileSeekTo(_: ?*anyopaque, file: File, offset: u64) File.SeekError!void { + while (true) { + const rc = lseek_sym(file.handle, @bitCast(offset), posix.SEEK.SET); + switch (posix.errno(rc)) { + .SUCCESS => return, + .INTR => continue, + .INVAL => return error.Unseekable, + .OVERFLOW => return error.Unseekable, + .SPIPE => return error.Unseekable, + .NXIO => return error.Unseekable, + else => |err| return posix.unexpectedErrno(err), + } + } +} + +fn fileRealPath( + _: ?*anyopaque, + file: File, + out_buffer: []u8, +) File.RealPathError!usize { + return realPathFd(file.handle, out_buffer); +} + +fn realPathFd(fd: posix.fd_t, out_buffer: []u8) File.RealPathError!usize { + switch (builtin.os.tag) { + .dragonfly, .driverkit, .ios, .maccatalyst, .macos, .tvos, .visionos, .watchos => { + var sufficient_buffer: [posix.PATH_MAX]u8 = undefined; + @memset(&sufficient_buffer, 0); + while (true) { + switch (posix.errno(posix.system.fcntl(fd, posix.F.GETPATH, &sufficient_buffer))) { + .SUCCESS => break, + .INTR => continue, + .ACCES => return error.AccessDenied, + .BADF => return error.FileNotFound, + .NOENT => return error.FileNotFound, + .NOMEM => return error.SystemResources, + .NOSPC => return error.NameTooLong, + .RANGE => return error.NameTooLong, + else => |err| return posix.unexpectedErrno(err), + } + } + const n = std.mem.indexOfScalar(u8, &sufficient_buffer, 0) orelse sufficient_buffer.len; + if (n > out_buffer.len) return error.NameTooLong; + @memcpy(out_buffer[0..n], sufficient_buffer[0..n]); + return n; + }, + + .linux, .serenity, .illumos => { + var procfs_buf: ["/proc/self/path/-2147483648\x00".len]u8 = undefined; + const template = if (builtin.os.tag == .illumos) "/proc/self/path/{d}" else "/proc/self/fd/{d}"; + const proc_path = std.fmt.bufPrintSentinel(&procfs_buf, template, .{fd}, 0) catch unreachable; + while (true) { + const rc = posix.system.readlink(proc_path, out_buffer.ptr, out_buffer.len); + switch (posix.errno(rc)) { + .SUCCESS => return @bitCast(rc), + .INTR => continue, + .ACCES => return error.AccessDenied, + .IO => return error.FileSystem, + .LOOP => return error.SymLinkLoop, + .NAMETOOLONG => return error.NameTooLong, + .NOENT => return error.FileNotFound, + .NOMEM => return error.SystemResources, + .NOTDIR => return error.NotDir, + else => |err| return posix.unexpectedErrno(err), + } + } + }, + + .freebsd => { + var k_file: std.c.kinfo_file = undefined; + k_file.structsize = std.c.KINFO_FILE_SIZE; + while (true) { + switch (posix.errno(std.c.fcntl(fd, std.c.F.KINFO, @intFromPtr(&k_file)))) { + .SUCCESS => break, + .INTR => continue, + .BADF => return error.FileNotFound, + else => |err| return posix.unexpectedErrno(err), + } + } + const len = std.mem.findScalar(u8, &k_file.path, 0) orelse k_file.path.len; + if (len == 0) return error.NameTooLong; + @memcpy(out_buffer[0..len], k_file.path[0..len]); + return len; + }, + + else => return error.OperationUnsupported, + } +} + +fn dirRealPathFile( + _: ?*anyopaque, + dir: Dir, + sub_path: []const u8, + out_buffer: []u8, +) Dir.RealPathFileError!usize { + var path_buffer: [posix.PATH_MAX]u8 = undefined; + const sub_path_posix = try Threaded.pathToPosix(sub_path, &path_buffer); + + if (builtin.link_libc and dir.handle == posix.AT.FDCWD) { + if (out_buffer.len < posix.PATH_MAX) return error.NameTooLong; + while (true) { + if (std.c.realpath(sub_path_posix, out_buffer.ptr)) |redundant_pointer| { + std.debug.assert(redundant_pointer == out_buffer.ptr); + return std.mem.indexOfScalar(u8, out_buffer, 0) orelse out_buffer.len; + } + switch (@as(posix.E, @enumFromInt(std.c._errno().*))) { + .INTR => continue, + .ACCES => return error.AccessDenied, + .NOENT => return error.FileNotFound, + .OPNOTSUPP => return error.OperationUnsupported, + .NOTDIR => return error.NotDir, + .NAMETOOLONG => return error.NameTooLong, + .LOOP => return error.SymLinkLoop, + .IO => return error.InputOutput, + else => |err| return posix.unexpectedErrno(err), + } + } + } + + // Fallback: open the path and resolve the fd. Used for non-cwd + // directory handles (which the terminal itself never passes) and + // non-libc builds. + var flags: posix.O = .{}; + if (@hasField(posix.O, "NONBLOCK")) flags.NONBLOCK = true; + if (@hasField(posix.O, "CLOEXEC")) flags.CLOEXEC = true; + if (@hasField(posix.O, "PATH")) flags.PATH = true; + + const mode: posix.mode_t = 0; + const fd: posix.fd_t = while (true) { + const rc = openat_sym(dir.handle, sub_path_posix, flags, mode); + switch (posix.errno(rc)) { + .SUCCESS => break @intCast(rc), + .INTR => continue, + .INVAL => return error.BadPathName, + .ACCES => return error.AccessDenied, + .LOOP => return error.SymLinkLoop, + .NAMETOOLONG => return error.NameTooLong, + .NOENT => return error.FileNotFound, + .NOMEM => return error.SystemResources, + .NOTDIR => return error.NotDir, + .ILSEQ => return error.BadPathName, + else => |err| return posix.unexpectedErrno(err), + } + }; + defer closeFd(fd); + + return realPathFd(fd, out_buffer); +} + +fn dirDeleteFile( + _: ?*anyopaque, + dir: Dir, + sub_path: []const u8, +) Dir.DeleteFileError!void { + var path_buffer: [posix.PATH_MAX]u8 = undefined; + const sub_path_posix = try Threaded.pathToPosix(sub_path, &path_buffer); + + while (true) { + switch (posix.errno(posix.system.unlinkat(dir.handle, sub_path_posix, 0))) { + .SUCCESS => return, + .INTR => continue, + // Some systems return EPERM when trying to delete a directory; + // stat to disambiguate from a real permission error. + .PERM => switch (builtin.os.tag) { + .driverkit, .ios, .maccatalyst, .macos, .tvos, .visionos, .watchos, .freebsd, .netbsd, .dragonfly, .openbsd, .illumos => { + var st = std.mem.zeroes(posix.Stat); + while (true) { + switch (posix.errno(fstatat_sym( + dir.handle, + sub_path_posix, + &st, + posix.AT.SYMLINK_NOFOLLOW, + ))) { + .SUCCESS => break, + .INTR => continue, + else => return error.PermissionDenied, + } + } + const is_dir = st.mode & posix.S.IFMT == posix.S.IFDIR; + return if (is_dir) error.IsDir else error.PermissionDenied; + }, + else => return error.PermissionDenied, + }, + .ACCES => return error.AccessDenied, + .BUSY => return error.FileBusy, + .IO => return error.FileSystem, + .ISDIR => return error.IsDir, + .LOOP => return error.SymLinkLoop, + .NAMETOOLONG => return error.NameTooLong, + .NOENT => return error.FileNotFound, + .NOTDIR => return error.NotDir, + .NOMEM => return error.SystemResources, + .ROFS => return error.ReadOnlyFileSystem, + .ILSEQ => return error.BadPathName, + else => |err| return posix.unexpectedErrno(err), + } + } +} + +/// Convert an `Io.Timeout` to relative nanoseconds without consulting a +/// clock. Deadlines can't be resolved without `now` support; treat them +/// as a short poll, which is valid because futex waits are allowed to +/// wake spuriously (callers must re-check their condition and retry). +fn timeoutToNs(timeout: Io.Timeout) ?u64 { + return switch (timeout) { + .none => null, + .duration => |d| @intCast(@max(0, d.raw.toNanoseconds())), + .deadline => 10 * std.time.ns_per_ms, + }; +} + +fn futexWait( + userdata: ?*anyopaque, + ptr: *const u32, + expected: u32, + timeout: Io.Timeout, +) Io.Cancelable!void { + _ = userdata; + futexWaitInner(ptr, expected, timeoutToNs(timeout)); +} + +fn futexWaitUncancelable(userdata: ?*anyopaque, ptr: *const u32, expected: u32) void { + _ = userdata; + futexWaitInner(ptr, expected, null); +} + +fn futexWaitInner(ptr: *const u32, expected: u32, timeout_ns: ?u64) void { + @branchHint(.cold); + + if (builtin.single_threaded) unreachable; // nobody would ever wake us + + switch (builtin.os.tag) { + .linux => { + const linux = std.os.linux; + var ts_buffer: linux.timespec = undefined; + const ts: ?*linux.timespec = if (timeout_ns) |ns| ts: { + ts_buffer = .{ + .sec = @intCast(ns / std.time.ns_per_s), + .nsec = @intCast(ns % std.time.ns_per_s), + }; + break :ts &ts_buffer; + } else null; + const rc = linux.futex_4arg(ptr, .{ .cmd = .WAIT, .private = true }, expected, ts); + switch (linux.errno(rc)) { + .SUCCESS => {}, // notified by wake + .INTR => {}, // caller's responsibility to retry + .AGAIN => {}, // ptr.* != expected + .INVAL => {}, // possibly timeout overflow + .TIMEDOUT => {}, + else => {}, // spurious wakeup; caller retries + } + }, + + .driverkit, .ios, .maccatalyst, .macos, .tvos, .visionos, .watchos => { + const c = std.c; + const flags: c.UL = .{ + .op = .COMPARE_AND_WAIT, + .NO_ERRNO = true, + }; + const us: u32 = us: { + const ns = timeout_ns orelse break :us 0; // 0 means infinite + const us = std.math.lossyCast(u32, ns / std.time.ns_per_us); + break :us if (us == 0) 1 else us; + }; + const status = c.__ulock_wait(flags, ptr, expected, us); + if (status >= 0) return; + switch (@as(c.E, @enumFromInt(-status))) { + .INTR => {}, // spurious wake + .FAULT => {}, // futex address paged out; caller retries + .TIMEDOUT => {}, + else => {}, // spurious wakeup; caller retries + } + }, + + .freebsd => { + const flags = @intFromEnum(std.c.UMTX_OP.WAIT_UINT_PRIVATE); + var tm_size: usize = 0; + var tm: std.c._umtx_time = undefined; + var tm_ptr: ?*const std.c._umtx_time = null; + if (timeout_ns) |ns| { + tm_ptr = &tm; + tm_size = @sizeOf(@TypeOf(tm)); + tm.flags = 0; // relative time + tm.clockid = .MONOTONIC; + tm.timeout = .{ + .sec = @intCast(ns / std.time.ns_per_s), + .nsec = @intCast(ns % std.time.ns_per_s), + }; + } + _ = std.c._umtx_op( + @intFromPtr(ptr), + flags, + @as(c_ulong, expected), + tm_size, + @intFromPtr(tm_ptr), + ); + }, + + else => { + // Portable fallback: futex waits may wake spuriously, so a + // bounded sleep is a valid (if inefficient) implementation. + // Contention is not expected in libghostty-vt's threading + // model, so this is effectively never reached. + if (@atomicLoad(u32, ptr, .seq_cst) != expected) return; + const ns = @min(timeout_ns orelse std.time.ns_per_ms, std.time.ns_per_ms); + const ts: posix.timespec = .{ + .sec = 0, + .nsec = @intCast(ns), + }; + _ = posix.system.nanosleep(&ts, null); + }, + } +} + +fn futexWake(userdata: ?*anyopaque, ptr: *const u32, max_waiters: u32) void { + @branchHint(.cold); + _ = userdata; + + if (builtin.single_threaded) return; // nothing to wake up + + switch (builtin.os.tag) { + .linux => { + const linux = std.os.linux; + _ = linux.futex_3arg( + ptr, + .{ .cmd = .WAKE, .private = true }, + @min(max_waiters, std.math.maxInt(i32)), + ); + }, + + .driverkit, .ios, .maccatalyst, .macos, .tvos, .visionos, .watchos => { + const c = std.c; + const flags: c.UL = .{ + .op = .COMPARE_AND_WAIT, + .NO_ERRNO = true, + .WAKE_ALL = max_waiters > 1, + }; + while (true) { + const status = c.__ulock_wake(flags, ptr, 0); + if (status >= 0) return; + switch (@as(c.E, @enumFromInt(-status))) { + .INTR, .CANCELED => continue, // spurious wake + else => return, + } + } + }, + + .freebsd => { + _ = std.c._umtx_op( + @intFromPtr(ptr), + @intFromEnum(std.c.UMTX_OP.WAKE_PRIVATE), + @min(max_waiters, std.math.maxInt(c_ulong)), + 0, + 0, + ); + }, + + // Portable fallback waiters poll with a timeout; nothing to do. + else => {}, + } +} + +test "read a file through File.Reader" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const contents = "hello minimal test_io\n" ** 100; + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "test.txt", + .data = contents, + }); + + // Open through our Io. The Dir handle is a plain fd, so it is usable + // across Io implementations. + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + var file = try dir.openFile(test_io, "test.txt", .{}); + defer file.close(test_io); + + // Stat through our Io. + const stat = try file.stat(test_io); + try testing.expectEqual(@as(u64, contents.len), stat.size); + try testing.expectEqual(File.Kind.file, stat.kind); + + // fileLength. + try testing.expectEqual(@as(u64, contents.len), try file.length(test_io)); + + // Read it all back through File.Reader (exercises positional reads + // and streaming fallbacks). + var buf: [64]u8 = undefined; + var reader = file.reader(test_io, &buf); + var list: std.ArrayList(u8) = .empty; + defer list.deinit(testing.allocator); + try reader.interface.appendRemaining(testing.allocator, &list, .unlimited); + try testing.expectEqualStrings(contents, list.items); +} + +test "seek" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "seek.txt", + .data = "0123456789", + }); + + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + var file = try dir.openFile(test_io, "seek.txt", .{}); + defer file.close(test_io); + + // Exercise our seek and streaming read implementations directly at + // the vtable level; the higher-level File.Reader seek plumbing has + // its own buffering behaviors that are independent of the Io + // implementation. + var out: [4]u8 = undefined; + var slices = [_][]u8{&out}; + + try test_io.vtable.fileSeekTo(test_io.userdata, file, 6); + var n = try file.readStreaming(test_io, &slices); + try testing.expectEqualStrings("6789", out[0..n]); + + // Seek back relative and re-read. + try test_io.vtable.fileSeekBy(test_io.userdata, file, -8); + n = try file.readStreaming(test_io, &slices); + try testing.expectEqualStrings("2345", out[0..n]); + + // Positional reads are independent of the seek position. + var pslices = [_][]u8{&out}; + n = try test_io.vtable.fileReadPositional(test_io.userdata, file, &pslices, 1); + try testing.expectEqualStrings("1234", out[0..n]); +} + +test "realPath and deleteFile" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + // Only platforms with a real implementation. + switch (builtin.os.tag) { + .macos, .ios, .linux, .freebsd => {}, + else => return error.SkipZigTest, + } + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "real.txt", + .data = "x", + }); + + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + var file = try dir.openFile(test_io, "real.txt", .{}); + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const path = path_buf[0..try file.realPath(test_io, &path_buf)]; + try testing.expect(std.mem.endsWith(u8, path, "real.txt")); + file.close(test_io); + + // dirRealPathFile via absolute path from cwd. + var path_buf2: [std.fs.max_path_bytes]u8 = undefined; + const path2 = path_buf2[0..try Dir.cwd().realPathFile(test_io, path, &path_buf2)]; + try testing.expectEqualStrings(path, path2); + + // Delete it through our Io, verify it is gone. + try dir.deleteFile(test_io, "real.txt"); + try testing.expectError(error.FileNotFound, dir.openFile(test_io, "real.txt", .{})); +} + +test "openFile of a directory returns IsDir" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + try tmp_dir.dir.createDir(testing.io, "sub", .default_dir); + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + try testing.expectError(error.IsDir, dir.openFile(test_io, "sub", .{ + .allow_directory = false, + })); +} + +test "Io.Mutex through TinyIo" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + // Contended and uncontended lock/unlock; exercises the futex ops. + var mutex: Io.Mutex = .init; + mutex.lockUncancelable(test_io); + mutex.unlock(test_io); + + // Wake with no waiters must be a no-op. + var word: u32 = 0; + test_io.vtable.futexWake(test_io.userdata, &word, 1); + + // Wait with a non-matching expected value must return immediately. + test_io.vtable.futexWaitUncancelable(test_io.userdata, &word, 1); +} + +test "unused operations fail gracefully" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + try testing.expectError( + error.NoSpaceLeft, + dir.createFile(test_io, "nope.txt", .{}), + ); +} + +test "openFile edge cases" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "edge.txt", + .data = "edge", + }); + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + + // File locking is unimplemented and must be reported, not ignored. + try testing.expectError(error.FileLocksUnsupported, dir.openFile( + test_io, + "edge.txt", + .{ .lock = .shared }, + )); + + // NUL bytes never form a valid POSIX path. + try testing.expectError(error.BadPathName, dir.openFile( + test_io, + "bad\x00path", + .{}, + )); + + // Paths that can't fit in PATH_MAX must not be silently truncated. + const long_name = "a" ** (std.fs.max_path_bytes + 1); + try testing.expectError(error.NameTooLong, dir.openFile( + test_io, + long_name, + .{}, + )); + + // A path component that is a file, not a directory. + try testing.expectError(error.NotDir, dir.openFile( + test_io, + "edge.txt/child", + .{}, + )); + + // Directories may be opened when allowed (the default) and stat + // reports their kind. + try tmp_dir.dir.createDir(testing.io, "subdir", .default_dir); + var dir_file = try dir.openFile(test_io, "subdir", .{}); + const dir_stat = try dir_file.stat(test_io); + try testing.expectEqual(File.Kind.directory, dir_stat.kind); + dir_file.close(test_io); + + // Write modes translate to the right ACCMODE flags; TinyIo can't + // write but opening for write must succeed. + var wfile = try dir.openFile(test_io, "edge.txt", .{ .mode = .write_only }); + wfile.close(test_io); + var rwfile = try dir.openFile(test_io, "edge.txt", .{ .mode = .read_write }); + rwfile.close(test_io); +} + +test "openFile symlink handling" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + // Platforms where we know both symlink creation (via the testing Io) + // and O_NOFOLLOW behave as expected. + switch (builtin.os.tag) { + .macos, .ios, .linux, .freebsd => {}, + else => return error.SkipZigTest, + } + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "target.txt", + .data = "target", + }); + try tmp_dir.dir.symLink(testing.io, "target.txt", "link.txt", .{}); + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + + // Following symlinks (the default) opens the target... + var file = try dir.openFile(test_io, "link.txt", .{}); + try testing.expectEqual(@as(u64, "target".len), try file.length(test_io)); + + // ...and realPath resolves through the link to the target. This is + // the property the Kitty graphics path validation relies on. + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const path = path_buf[0..try file.realPath(test_io, &path_buf)]; + try testing.expect(std.mem.endsWith(u8, path, "target.txt")); + file.close(test_io); + + // Refusing to follow symlinks fails with SymLinkLoop. + try testing.expectError(error.SymLinkLoop, dir.openFile( + test_io, + "link.txt", + .{ .follow_symlinks = false }, + )); +} + +test "positional reads at and beyond EOF return zero" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "eof.txt", + .data = "0123456789", + }); + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + var file = try dir.openFile(test_io, "eof.txt", .{}); + defer file.close(test_io); + + var out: [4]u8 = undefined; + var slices = [_][]u8{&out}; + + // At EOF and past EOF: the vtable contract is "returns 0 if reading + // at or past the end". + try testing.expectEqual(@as(usize, 0), try test_io.vtable.fileReadPositional( + test_io.userdata, + file, + &slices, + 10, + )); + try testing.expectEqual(@as(usize, 0), try test_io.vtable.fileReadPositional( + test_io.userdata, + file, + &slices, + 9999, + )); + + // No buffers and only-empty buffers read nothing without a syscall. + try testing.expectEqual(@as(usize, 0), try test_io.vtable.fileReadPositional( + test_io.userdata, + file, + &.{}, + 0, + )); + var empty = [_][]u8{ &.{}, &.{} }; + try testing.expectEqual(@as(usize, 0), try test_io.vtable.fileReadPositional( + test_io.userdata, + file, + &empty, + 0, + )); +} + +test "vectored reads scatter across buffers" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "vec.txt", + .data = "0123456789abcdef", + }); + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + var file = try dir.openFile(test_io, "vec.txt", .{}); + defer file.close(test_io); + + // Scatter a positional read across multiple buffers, with empty + // buffers interleaved (they must be skipped). + var a: [4]u8 = undefined; + var b: [2]u8 = undefined; + var c: [6]u8 = undefined; + var slices = [_][]u8{ &a, &.{}, &b, &c }; + const n = try test_io.vtable.fileReadPositional( + test_io.userdata, + file, + &slices, + 0, + ); + try testing.expectEqual(@as(usize, 12), n); + try testing.expectEqualStrings("0123", &a); + try testing.expectEqualStrings("45", &b); + try testing.expectEqualStrings("6789ab", &c); + + // More buffers than max_iovecs_len: reads are truncated to the + // first max_iovecs_len non-empty buffers (partial reads are allowed + // by the vtable contract; callers retry). + comptime std.debug.assert(Threaded.max_iovecs_len < 16); + var bytes: [16][1]u8 = undefined; + var many: [16][]u8 = undefined; + for (&many, &bytes) |*s, *byte| s.* = byte; + const n2 = try test_io.vtable.fileReadPositional( + test_io.userdata, + file, + &many, + 0, + ); + try testing.expectEqual(@as(usize, Threaded.max_iovecs_len), n2); + for (bytes[0..n2], "0123456789abcdef"[0..n2]) |got, want| { + try testing.expectEqual(want, got[0]); + } +} + +test "streaming reads: EndOfStream and scatter" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "stream.txt", + .data = "streaming!", + }); + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + var file = try dir.openFile(test_io, "stream.txt", .{}); + defer file.close(test_io); + + // Scatter a streaming read. + var a: [6]u8 = undefined; + var b: [4]u8 = undefined; + var slices = [_][]u8{ &a, &b }; + try testing.expectEqual(@as(usize, 10), try file.readStreaming(test_io, &slices)); + try testing.expectEqualStrings("stream", &a); + try testing.expectEqualStrings("ing!", &b); + + // Reading again at EOF is a stream end, not a zero-length success. + try testing.expectError(error.EndOfStream, file.readStreaming(test_io, &slices)); + + // Empty destinations read nothing. + try testing.expectEqual(@as(usize, 0), try file.readStreaming(test_io, &.{})); + + // Seeking beyond EOF is legal; the next streaming read hits EOF. + try test_io.vtable.fileSeekTo(test_io.userdata, file, 9999); + try testing.expectError(error.EndOfStream, file.readStreaming(test_io, &slices)); + + // And seeking back to zero re-reads from the start. + try test_io.vtable.fileSeekTo(test_io.userdata, file, 0); + try testing.expectEqual(@as(usize, 10), try file.readStreaming(test_io, &slices)); + try testing.expectEqualStrings("stream", &a); +} + +test "pipes: streaming works, positional and seek are Unseekable" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var fds: [2]posix.fd_t = undefined; + switch (posix.errno(posix.system.pipe(&fds))) { + .SUCCESS => {}, + else => return error.SkipZigTest, + } + defer closeFd(fds[1]); + const read_end: File = .{ .handle = fds[0], .flags = .{ .nonblocking = false } }; + defer read_end.close(test_io); + + const msg = "through the pipe"; + try testing.expectEqual( + @as(isize, msg.len), + @as(isize, @intCast(posix.system.write(fds[1], msg, msg.len))), + ); + + // Streaming reads work on unseekable files; this is the fallback + // File.Reader depends on when positional reads report Unseekable. + var buf: [msg.len]u8 = undefined; + var slices = [_][]u8{&buf}; + try testing.expectEqual(@as(usize, msg.len), try read_end.readStreaming(test_io, &slices)); + try testing.expectEqualStrings(msg, &buf); + + // Positional reads and seeks must report Unseekable so callers can + // fall back to streaming. + try testing.expectError(error.Unseekable, test_io.vtable.fileReadPositional( + test_io.userdata, + read_end, + &slices, + 0, + )); + try testing.expectError(error.Unseekable, test_io.vtable.fileSeekTo( + test_io.userdata, + read_end, + 0, + )); + try testing.expectError(error.Unseekable, test_io.vtable.fileSeekBy( + test_io.userdata, + read_end, + 1, + )); +} + +test "operate delegates non-read operations to failing stubs" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "w.txt", + .data = "x", + }); + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + var file = try dir.openFile(test_io, "w.txt", .{ .mode = .write_only }); + defer file.close(test_io); + + const result = try test_io.vtable.operate(test_io.userdata, .{ + .file_write_streaming = .{ + .file = file, + .data = &.{"nope"}, + }, + }); + try testing.expectError(error.InputOutput, result.file_write_streaming); +} + +test "dirRealPathFile edge cases" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + switch (builtin.os.tag) { + .macos, .ios, .linux, .freebsd => {}, + else => return error.SkipZigTest, + } + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "real.txt", + .data = "x", + }); + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + + // Resolve the canonical path through an open file for reference. + var file = try dir.openFile(test_io, "real.txt", .{}); + var want_buf: [std.fs.max_path_bytes]u8 = undefined; + const want = want_buf[0..try file.realPath(test_io, &want_buf)]; + file.close(test_io); + + // A non-cwd directory handle exercises the open-then-resolve + // fallback branch rather than libc realpath. + var got_buf: [std.fs.max_path_bytes]u8 = undefined; + const got = got_buf[0..try test_io.vtable.dirRealPathFile( + test_io.userdata, + dir, + "real.txt", + &got_buf, + )]; + try testing.expectEqualStrings(want, got); + + // Missing paths report FileNotFound (libc realpath branch, via an + // absolute path anchored at cwd). + var missing_buf: [std.fs.max_path_bytes]u8 = undefined; + const missing = std.fmt.bufPrint(&missing_buf, "{s}.missing", .{want}) catch + return error.SkipZigTest; + var out_buf: [std.fs.max_path_bytes]u8 = undefined; + try testing.expectError(error.FileNotFound, Dir.cwd().realPathFile( + test_io, + missing, + &out_buf, + )); + + // libc realpath requires a PATH_MAX-sized output buffer; smaller + // buffers must error rather than risk truncation. + var small_buf: [8]u8 = undefined; + try testing.expectError(error.NameTooLong, Dir.cwd().realPathFile( + test_io, + want, + &small_buf, + )); +} + +test "deleteFile edge cases" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + + // Nonexistent files. + try testing.expectError(error.FileNotFound, dir.deleteFile(test_io, "missing.txt")); + + // NUL bytes never form a valid POSIX path. + try testing.expectError(error.BadPathName, dir.deleteFile(test_io, "bad\x00path")); + + // Deleting a directory reports IsDir. On BSD-derived systems + // (including macOS) unlink returns EPERM for directories, which + // exercises the stat-based disambiguation path. + try tmp_dir.dir.createDir(testing.io, "subdir", .default_dir); + try testing.expectError(error.IsDir, dir.deleteFile(test_io, "subdir")); + + // Deleting a symlink removes the link, not its target. + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "target.txt", + .data = "x", + }); + try tmp_dir.dir.symLink(testing.io, "target.txt", "link.txt", .{}); + try dir.deleteFile(test_io, "link.txt"); + var file = try dir.openFile(test_io, "target.txt", .{}); + file.close(test_io); + try testing.expectError(error.FileNotFound, dir.openFile(test_io, "link.txt", .{})); +} + +test "dirClose closes the descriptor" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.createDir(testing.io, "subdir", .default_dir); + const opened = try tmp_dir.dir.openDir(testing.io, "subdir", .{}); + + const dir: Dir = .{ .handle = opened.handle }; + test_io.vtable.dirClose(test_io.userdata, &.{dir}); + + // Verify with a raw dup that the fd is gone. We check the errno + // directly rather than going through our Io so no error path prints + // "unexpected errno" diagnostics in debug test builds. `dup` rather + // than `fstat` because glibc has no LFS64 `fstat` symbol, so + // `fstat_sym` doesn't exist on Linux. + try testing.expectEqual(posix.E.BADF, posix.errno(posix.system.dup(dir.handle))); +} + +test "cancel protection operations are benign" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + // There is no async, so cancelation can never be requested. + try test_io.vtable.checkCancel(test_io.userdata); + + // Swap and restore roundtrip like generic std code does around + // uninterruptible sections. + const prev = test_io.vtable.swapCancelProtection(test_io.userdata, .blocked); + _ = test_io.vtable.swapCancelProtection(test_io.userdata, prev); + test_io.vtable.recancel(test_io.userdata); + + _ = testing; +} + +test "async runs inline; concurrency is unavailable" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + const S = struct { + fn work(x: *u32) u32 { + x.* += 1; + return x.*; + } + }; + + // The task must have executed synchronously, before await. + var state: u32 = 41; + var future = test_io.async(S.work, .{&state}); + try testing.expectEqual(@as(u32, 42), state); + try testing.expectEqual(@as(u32, 42), future.await(test_io)); + + // Concurrency must be reported as unavailable, not silently run. + try testing.expectError( + error.ConcurrencyUnavailable, + test_io.concurrent(S.work, .{&state}), + ); + try testing.expectEqual(@as(u32, 42), state); +} + +test "futex timed waits return" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + + // Nobody wakes this futex, so returning at all proves the timeout + // (or the spurious-wakeup contract) works. + var word: u32 = 1; + try test_io.vtable.futexWait(test_io.userdata, &word, 1, .{ + .duration = .{ .raw = .fromNanoseconds(5 * std.time.ns_per_ms), .clock = .awake }, + }); + + // Deadlines can't be resolved without a clock; they degrade to a + // short poll which must also return. + try test_io.vtable.futexWait(test_io.userdata, &word, 1, .{ + .deadline = .{ .raw = .fromNanoseconds(1), .clock = .awake }, + }); + + // A mismatched expected value returns immediately even with no + // timeout. + try test_io.vtable.futexWait(test_io.userdata, &word, 2, .none); + + // Waking more than one waiter takes the wake-all path. + test_io.vtable.futexWake(test_io.userdata, &word, 2); + test_io.vtable.futexWake(test_io.userdata, &word, std.math.maxInt(u32)); +} + +test "Io.Mutex under real thread contention" { + if (comptime !supported) return error.SkipZigTest; + if (comptime builtin.single_threaded) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + // Hammer a mutex from several threads so waiters actually park in + // futexWait and get released by futexWake. + const S = struct { + const iterations = 10_000; + + fn worker(m: *Io.Mutex, io_: Io, counter: *u64) void { + for (0..iterations) |_| { + m.lockUncancelable(io_); + defer m.unlock(io_); + counter.* += 1; + } + } + }; + + var mutex: Io.Mutex = .init; + var counter: u64 = 0; + + const thread_count = 4; + var threads: [thread_count]std.Thread = undefined; + var spawned: usize = 0; + defer for (threads[0..spawned]) |t| t.join(); + for (&threads) |*t| { + t.* = std.Thread.spawn(.{}, S.worker, .{ &mutex, test_io, &counter }) catch + break; + spawned += 1; + } + for (threads[0..spawned]) |t| t.join(); + spawned = 0; + + try testing.expectEqual(@as(u64, S.iterations * thread_count), counter); +} diff --git a/src/lib/main.zig b/src/lib/main.zig index b22b3d987..45cdb71d4 100644 --- a/src/lib/main.zig +++ b/src/lib/main.zig @@ -5,6 +5,7 @@ const types = @import("types.zig"); const unionpkg = @import("union.zig"); pub const allocator = @import("allocator.zig"); +pub const TinyIo = @import("TinyIo.zig"); pub const Buffer = types.Buffer; pub const Enum = enumpkg.Enum; pub const checkGhosttyHEnum = enumpkg.checkGhosttyHEnum; @@ -13,7 +14,6 @@ pub const Struct = structpkg.Struct; pub const structSizedFieldFits = structpkg.sizedFieldFits; pub const Target = @import("target.zig").Target; pub const TaggedUnion = unionpkg.TaggedUnion; -pub const cutPrefix = @import("string.zig").cutPrefix; test { std.testing.refAllDecls(@This()); diff --git a/src/lib/string.zig b/src/lib/string.zig deleted file mode 100644 index 795823c25..000000000 --- a/src/lib/string.zig +++ /dev/null @@ -1,15 +0,0 @@ -const std = @import("std"); - -// This is a copy of std.mem.cutPrefix from 0.16. Once Ghostty has been ported -// to 0.16 this can be removed. - -/// If slice starts with prefix, returns the rest of slice starting at -/// prefix.len. -pub fn cutPrefix(comptime T: type, slice: []const T, prefix: []const T) ?[]const T { - return if (std.mem.startsWith(T, slice, prefix)) slice[prefix.len..] else null; -} - -test cutPrefix { - try std.testing.expectEqualStrings("foo", cutPrefix(u8, "--example=foo", "--example=").?); - try std.testing.expectEqual(null, cutPrefix(u8, "--example=foo", "-example=")); -} diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 0d9fda7c6..66da515a8 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -11,6 +11,7 @@ const lib = @This(); const std = @import("std"); const builtin = @import("builtin"); +const stderr = @import("os/stderr.zig"); // The public API below reproduces a lot of terminal/main.zig but // is separate because (1) we need our root file to be in `src/` @@ -37,6 +38,17 @@ const terminal = @import("terminal/main.zig"); /// Additional functionality will be added here over time as needed. pub const sys = terminal.sys; +/// A tiny, blocking `std.Io` implementation optimized for binary size. +/// +/// Constructing a `Terminal` requires a `std.Io` for features that touch +/// the filesystem (e.g. Kitty graphics file transmission). Embedders that +/// don't have their own `Io` can use `TinyIo` (e.g. +/// `(TinyIo.init).io()`) instead of `std.Io.Threaded` to avoid linking +/// Threaded's full vtable (networking, process spawning, async +/// machinery, etc.), which is worth roughly 110KB of binary size. See +/// the TinyIo docs for the exact tradeoffs. +pub const TinyIo = @import("lib/TinyIo.zig"); + pub const apc = terminal.apc; pub const dcs = terminal.dcs; pub const osc = terminal.osc; @@ -80,6 +92,7 @@ pub const Terminal = terminal.Terminal; pub const TerminalStream = terminal.TerminalStream; pub const Stream = terminal.Stream; pub const StreamAction = terminal.StreamAction; +pub const UnknownSequence = terminal.UnknownSequence; pub const Cursor = Screen.Cursor; pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = terminal.CursorStyle; @@ -281,13 +294,15 @@ comptime { @export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" }); @export(&c.terminal_set, .{ .name = "ghostty_terminal_set" }); @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); + @export(&c.terminal_vt_write_until_ground, .{ .name = "ghostty_terminal_vt_write_until_ground" }); @export(&c.terminal_scroll_viewport, .{ .name = "ghostty_terminal_scroll_viewport" }); @export(&c.terminal_compression_activity, .{ .name = "ghostty_terminal_compression_activity" }); @export(&c.terminal_compress, .{ .name = "ghostty_terminal_compress" }); - @export(&c.terminal_mode_get, .{ .name = "ghostty_terminal_mode_get" }); - @export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" }); @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); @export(&c.terminal_get_multi, .{ .name = "ghostty_terminal_get_multi" }); + @export(&c.terminal_continuation_write, .{ .name = "ghostty_terminal_continuation_write" }); + @export(&c.terminal_continuation_buf, .{ .name = "ghostty_terminal_continuation_buf" }); + @export(&c.terminal_continuation_alloc, .{ .name = "ghostty_terminal_continuation_alloc" }); @export(&c.terminal_select_word, .{ .name = "ghostty_terminal_select_word" }); @export(&c.terminal_select_word_between, .{ .name = "ghostty_terminal_select_word_between" }); @export(&c.terminal_select_line, .{ .name = "ghostty_terminal_select_line" }); @@ -310,6 +325,18 @@ comptime { @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); + @export(&c.snapshot_encode, .{ .name = "ghostty_snapshot_encode" }); + @export(&c.snapshot_encode_buf, .{ .name = "ghostty_snapshot_encode_buf" }); + @export(&c.snapshot_encode_alloc, .{ .name = "ghostty_snapshot_encode_alloc" }); + @export(&c.snapshot_decoder_new, .{ .name = "ghostty_snapshot_decoder_new" }); + @export(&c.snapshot_decoder_new_buf, .{ .name = "ghostty_snapshot_decoder_new_buf" }); + @export(&c.snapshot_decoder_free, .{ .name = "ghostty_snapshot_decoder_free" }); + @export(&c.snapshot_decoder_set, .{ .name = "ghostty_snapshot_decoder_set" }); + @export(&c.snapshot_decoder_get, .{ .name = "ghostty_snapshot_decoder_get" }); + @export(&c.snapshot_decoder_get_multi, .{ .name = "ghostty_snapshot_decoder_get_multi" }); + @export(&c.snapshot_decoder_ready, .{ .name = "ghostty_snapshot_decoder_ready" }); + @export(&c.snapshot_decoder_next, .{ .name = "ghostty_snapshot_decoder_next" }); + @export(&c.snapshot_decoder_decode, .{ .name = "ghostty_snapshot_decoder_decode" }); @export(&c.kitty_graphics_get, .{ .name = "ghostty_kitty_graphics_get" }); @export(&c.kitty_graphics_image, .{ .name = "ghostty_kitty_graphics_image" }); @export(&c.kitty_graphics_image_get, .{ .name = "ghostty_kitty_graphics_image_get" }); @@ -400,6 +427,79 @@ pub const std_options: std.Options = opts: { break :opts options; }; +/// True for builds where we keep the full std debug machinery (stack +/// traces on panic, std.debug.print, etc.). These builds are for +/// development, where the roughly 160KB of binary size it costs is +/// worth it. +const debug_machinery: bool = builtin.is_test or switch (builtin.mode) { + .Debug, .ReleaseSafe => true, + .ReleaseFast, .ReleaseSmall => false, +}; + +/// The panic handler for when this file is the root module. +/// +/// In ReleaseFast and ReleaseSmall builds we print the panic message to +/// stderr and trap, but do not attempt to unwind the stack to print a +/// stack trace. +pub const panic: type = if (debug_machinery) + std.debug.FullPanic(std.debug.defaultPanic) +else + std.debug.FullPanic(tinyPanicImpl); + +/// Guards release builds against accidentally reintroducing the std +/// debug Io machinery. +/// +/// `std.Options.debug_io` defaults to `std.Io.Threaded`, and anything +/// that reaches it (std.debug.print, std.debug.lockStderr, the default +/// std.log handler, etc.) pins Threaded's entire vtable into the binary: +/// roughly 110KB of unreachable code. +/// +/// This verifies nothing ever touches it. +pub const std_options_debug_io: std.Io = if (debug_machinery) + std.Io.Threaded.global_single_threaded.io() +else + @compileError( + \\The std debug Io machinery (std.debug.print, std.debug.lockStderr, + \\std.log's default handler, ...) is disabled in libghostty-vt release + \\builds because it costs ~110KB of binary size. Use std.log (routed + \\through our logFn), os/stderr.zig for raw diagnostic writes, or + \\gate the code on debug builds. + ); + +/// Prints the panic message to stderr (best-effort) and traps. +/// +/// This intentionally avoids `std.debug.lockStderr`, which routes through +/// `std.Options.debug_io` and would keep the entire `std.Io.Threaded` +/// vtable alive in the binary which takes up hundreds of KB. +/// +/// This is safe to call from any thread (and even from signal handlers): +/// it takes no locks, performs no allocation, and touches no shared +/// mutable state. The message is emitted with a single raw write so that +/// concurrent stderr output doesn't interleave with it. +fn tinyPanicImpl(msg: []const u8, ra: ?usize) noreturn { + @branchHint(.cold); + _ = ra; + + // 256 bytes is enough for most messages, so try that first + // so that we can try to write in a single syscall. + var buf: [256]u8 = undefined; + if (std.fmt.bufPrint( + &buf, + "panic: {s}\n", + .{msg}, + )) |line| { + stderr.write(line); + } else |_| { + stderr.write("panic: "); + stderr.write(msg); + stderr.write("\n"); + } + + // Trap forces a standard crash that embedder-provided debuggers + // or environments can catch. + @trap(); +} + test { // Zig 0.16.0 has made test logging more strict. Now, *anything* that gets // printed to stderr results in a "failed command" message, even if the diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index cd2ef0c17..86e779899 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -140,11 +140,12 @@ fn logFn( .err => .fault, }; - // Initialize a logger. This is slow to do on every operation - // but we shouldn't be logging too much. - const logger = macos.os.Log.create(build_config.bundle_id, @tagName(scope)); - defer logger.release(); - logger.log(std.heap.c_allocator, mac_level, prefix ++ format, args); + macosLogger(scope).log( + std.heap.c_allocator, + mac_level, + prefix ++ format, + args, + ); } stderr: { @@ -166,6 +167,37 @@ fn logFn( } } +/// Returns the macOS unified logging logger for the given scope. The +/// logger is created once per scope and cached for the lifetime of the +/// process, because os_log object creation is slow (it shows up in +/// startup profiles when done per log call) and Apple's guidance is to +/// create loggers once and reuse them. +fn macosLogger(comptime scope: @TypeOf(.EnumLiteral)) *macos.os.Log { + const S = struct { + var cached: std.atomic.Value(?*macos.os.Log) = .init(null); + }; + + if (S.cached.load(.acquire)) |v| return v; + + // Create and attempt to store our logger. If we race with another + // thread then we use theirs and release ours. + const created = macos.os.Log.create( + build_config.bundle_id, + @tagName(scope), + ); + if (S.cached.cmpxchgStrong( + null, + created, + .acq_rel, + .acquire, + )) |existing| { + created.release(); + return existing.?; + } + + return created; +} + pub const std_options: std.Options = .{ // Our log level is always at least info in every build mode. // diff --git a/src/os/TempDir.zig b/src/os/TempDir.zig index f093ac3a7..ca23426e9 100644 --- a/src/os/TempDir.zig +++ b/src/os/TempDir.zig @@ -2,6 +2,7 @@ //! store temporary data and is destroyed on deinit. const TempDir = @This(); +const builtin = @import("builtin"); const std = @import("std"); const Dir = std.Io.Dir; const file = @import("file.zig"); @@ -61,25 +62,94 @@ pub fn name(self: *TempDir) []const u8 { /// Finish with the temporary directory. This deletes all contents in the /// directory. pub fn deinit(self: *TempDir) void { + self.close(.delete); +} + +pub const CloseMode = enum { delete, retain }; + +/// Close the directory handles, optionally retaining the temporary directory +/// and its contents on disk. +pub fn close(self: *TempDir, mode: CloseMode) void { self.dir.close(global.io()); - self.parent.deleteTree(global.io(), self.name()) catch |err| - log.err("error deleting temp dir err={}", .{err}); + switch (mode) { + .delete => self.parent.deleteTree(global.io(), self.name()) catch |err| + log.err("error deleting temp dir err={}", .{err}), + .retain => {}, + } + self.parent.close(global.io()); } test { const testing = std.testing; - var td = try init(); - errdefer td.deinit(); + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + var path_len: usize = undefined; + var dir_handle: Dir.Handle = undefined; + var parent_handle: Dir.Handle = undefined; + { + var td = try init(); + errdefer td.deinit(); - const nameval = td.name(); - try testing.expect(nameval.len > 0); + const nameval = td.name(); + try testing.expect(nameval.len > 0); - // Can open a new handle to it proves it exists. - var dir = try td.parent.openDir(testing.io, nameval, .{}); - dir.close(testing.io); + // Can open a new handle to it proves it exists. + var dir = try td.parent.openDir(testing.io, nameval, .{}); + dir.close(testing.io); - // Should be deleted after we deinit - td.deinit(); - try testing.expectError(error.FileNotFound, td.parent.openDir(testing.io, nameval, .{})); + path_len = try td.dir.realPath(testing.io, &path_buf); + dir_handle = td.dir.handle; + parent_handle = td.parent.handle; + + // Should be deleted after we deinit. + td.deinit(); + } + + switch (builtin.os.tag) { + .freebsd, .ios, .linux, .macos => { + for ([_]Dir.Handle{ dir_handle, parent_handle }) |handle| { + const result = std.posix.system.fcntl(handle, std.posix.F.GETFD); + try testing.expectEqual(std.posix.E.BADF, std.posix.errno(result)); + } + }, + + else => {}, + } + try testing.expectError( + error.FileNotFound, + Dir.openDirAbsolute(testing.io, path_buf[0..path_len], .{}), + ); +} + +test "close retains temporary directory" { + const testing = std.testing; + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + var path_len: usize = undefined; + var dir_handle: Dir.Handle = undefined; + var parent_handle: Dir.Handle = undefined; + { + var td = try init(); + errdefer td.deinit(); + + path_len = try td.dir.realPath(testing.io, &path_buf); + dir_handle = td.dir.handle; + parent_handle = td.parent.handle; + + td.close(.retain); + } + defer Dir.deleteDirAbsolute(testing.io, path_buf[0..path_len]) catch {}; + + switch (builtin.os.tag) { + .freebsd, .ios, .linux, .macos => { + for ([_]Dir.Handle{ dir_handle, parent_handle }) |handle| { + const result = std.posix.system.fcntl(handle, std.posix.F.GETFD); + try testing.expectEqual(std.posix.E.BADF, std.posix.errno(result)); + } + }, + + else => {}, + } + var dir = try Dir.openDirAbsolute(testing.io, path_buf[0..path_len], .{}); + dir.close(testing.io); } diff --git a/src/os/edit.zig b/src/os/edit.zig new file mode 100644 index 000000000..3e14bcaa6 --- /dev/null +++ b/src/os/edit.zig @@ -0,0 +1,69 @@ +const std = @import("std"); + +const global = @import("../global.zig"); +const ShellEscapeWriter = @import("shell.zig").ShellEscapeWriter; + +const log = std.log.scoped(.os_edit); + +const Options = struct { + default_editor: enum { + /// Return an error if $EDITOR or $VISUAL is not defined. + failure, + /// Default to "vi" if $EDITOR or $VISUAL is not defined. + vi, + /// Default to "nano" if $EDITOR or $VISUAL is not defined. + nano, + } = .vi, + + pub const default: Options = .{}; +}; + +pub const Error = error{NoEditorConfigured}; + +pub fn getConfigEditCommand(alloc: std.mem.Allocator, path: []const u8, options: Options) (Error || std.Io.Writer.Error || std.mem.Allocator.Error)![:0]const u8 { + const editor: []const u8 = editor: { + // VISUAL vs. EDITOR: https://unix.stackexchange.com/questions/4859/visual-vs-editor-what-s-the-difference + v: { + if (global.environ().containsUnempty(alloc, "VISUAL") catch break :v) { + break :editor global.environ().getAlloc(alloc, "VISUAL") catch break :v; + } + } + + e: { + if (global.environ().containsUnempty(alloc, "EDITOR") catch break :e) { + break :editor global.environ().getAlloc(alloc, "EDITOR") catch break :e; + } + } + + switch (options.default_editor) { + .failure => { + log.warn("$EDITOR or $VISUAL must be set to open config in a new window", .{}); + return error.NoEditorConfigured; + }, + .vi => { + log.warn("$EDITOR or $VISUAL not set, falling back to vi", .{}); + break :editor try alloc.dupe(u8, "vi"); + }, + .nano => { + log.warn("$EDITOR or $VISUAL not set, falling back to nano", .{}); + break :editor try alloc.dupe(u8, "nano"); + }, + } + }; + defer alloc.free(editor); + + var buffer: std.Io.Writer.Allocating = .init(alloc); + defer buffer.deinit(); + const writer = &buffer.writer; + + try writer.writeAll(editor); + try writer.writeByte(' '); + { + var sh: ShellEscapeWriter = .init(writer); + try sh.writer.writeAll(path); + try sh.writer.flush(); + } + try writer.flush(); + + return try buffer.toOwnedSliceSentinel(0); +} diff --git a/src/os/homedir.zig b/src/os/homedir.zig index 4972621eb..8da9640a2 100644 --- a/src/os/homedir.zig +++ b/src/os/homedir.zig @@ -1,7 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); const passwd = @import("passwd.zig"); -const global = @import("../global.zig"); const objc = @import("objc"); const Error = error{ @@ -11,9 +10,9 @@ const Error = error{ /// Determine the home directory for the currently executing user. This /// is generally an expensive process so the value should be cached. -pub inline fn home(environ_map: *const std.process.Environ.Map, buf: []u8) !?[]const u8 { +pub inline fn home(io: std.Io, environ_map: *const std.process.Environ.Map, buf: []u8) !?[]const u8 { return switch (builtin.os.tag) { - .linux, .freebsd, .macos => try homeUnix(environ_map, buf), + .linux, .freebsd, .macos => try homeUnix(io, environ_map, buf), .windows => homeWindows(environ_map, buf) catch return error.BufferTooSmall, // iOS doesn't have a user-writable home directory @@ -23,7 +22,7 @@ pub inline fn home(environ_map: *const std.process.Environ.Map, buf: []u8) !?[]c }; } -fn homeUnix(environ_map: *const std.process.Environ.Map, buf: []u8) !?[]const u8 { +fn homeUnix(io: std.Io, environ_map: *const std.process.Environ.Map, buf: []u8) !?[]const u8 { // First: if we have a HOME env var, then we use that. if (environ_map.get("HOME")) |result| { if (buf.len < result.len) return Error.BufferTooSmall; @@ -60,8 +59,9 @@ fn homeUnix(environ_map: *const std.process.Environ.Map, buf: []u8) !?[]const u8 // If all else fails, have the shell tell us... fba.reset(); - const run = try std.process.run(fba.allocator(), global.io(), .{ + const run = try std.process.run(fba.allocator(), io, .{ .argv = &[_][]const u8{ "/bin/sh", "-c", "cd && pwd" }, + .environ_map = environ_map, .stdout_limit = .limited(fba.buffer.len / 2), .stderr_limit = .limited(fba.buffer.len / 2), }); @@ -97,9 +97,14 @@ pub const ExpandError = error{ /// /// Errors if `home` fails or if the size of the expanded path is larger /// than `buf.len`. -pub fn expandHome(environ_map: *const std.process.Environ.Map, path: []const u8, buf: []u8) ExpandError![]const u8 { +pub fn expandHome( + io: std.Io, + environ_map: *const std.process.Environ.Map, + path: []const u8, + buf: []u8, +) ExpandError![]const u8 { return switch (builtin.os.tag) { - .linux, .freebsd, .macos => try expandHomeUnix(environ_map, path, buf), + .linux, .freebsd, .macos => try expandHomeUnix(io, environ_map, path, buf), // `~/` is not an idiom generally used on Windows .windows => return path, @@ -111,9 +116,14 @@ pub fn expandHome(environ_map: *const std.process.Environ.Map, path: []const u8, }; } -fn expandHomeUnix(environ_map: *const std.process.Environ.Map, path: []const u8, buf: []u8) ExpandError![]const u8 { +fn expandHomeUnix( + io: std.Io, + environ_map: *const std.process.Environ.Map, + path: []const u8, + buf: []u8, +) ExpandError![]const u8 { if (!std.mem.startsWith(u8, path, "~/")) return path; - const home_dir: []const u8 = if (home(environ_map, buf)) |home_| + const home_dir: []const u8 = if (home(io, environ_map, buf)) |home_| home_ orelse return error.HomeDetectionFailed else |_| return error.HomeDetectionFailed; @@ -130,30 +140,32 @@ test "expandHomeUnix" { if (builtin.os.tag == .windows) return error.SkipZigTest; const testing = std.testing; + const io = testing.io; const allocator = testing.allocator; var environ_map = try testing.environ.createMap(testing.allocator); defer environ_map.deinit(); var buf: [std.fs.max_path_bytes]u8 = undefined; - const home_dir = try expandHomeUnix(&environ_map, "~/", &buf); + const home_dir = try expandHomeUnix(io, &environ_map, "~/", &buf); // Joining the home directory `~` with the path `/` // the result should end with a separator here. (e.g. `/home/user/`) try testing.expect(home_dir[home_dir.len - 1] == std.fs.path.sep); - const downloads = try expandHomeUnix(&environ_map, "~/Downloads/shader.glsl", &buf); + const downloads = try expandHomeUnix(io, &environ_map, "~/Downloads/shader.glsl", &buf); const expected_downloads = try std.mem.concat(allocator, u8, &[_][]const u8{ home_dir, "Downloads/shader.glsl" }); defer allocator.free(expected_downloads); try testing.expectEqualStrings(expected_downloads, downloads); - try testing.expectEqualStrings("~", try expandHomeUnix(&environ_map, "~", &buf)); - try testing.expectEqualStrings("~abc/", try expandHomeUnix(&environ_map, "~abc/", &buf)); - try testing.expectEqualStrings("/home/user", try expandHomeUnix(&environ_map, "/home/user", &buf)); - try testing.expectEqualStrings("", try expandHomeUnix(&environ_map, "", &buf)); + try testing.expectEqualStrings("~", try expandHomeUnix(io, &environ_map, "~", &buf)); + try testing.expectEqualStrings("~abc/", try expandHomeUnix(io, &environ_map, "~abc/", &buf)); + try testing.expectEqualStrings("/home/user", try expandHomeUnix(io, &environ_map, "/home/user", &buf)); + try testing.expectEqualStrings("", try expandHomeUnix(io, &environ_map, "", &buf)); // Expect an error if the buffer is large enough to hold the home directory, // but not the expanded path var small_buf = try allocator.alloc(u8, home_dir.len); defer allocator.free(small_buf); try testing.expectError(error.BufferTooSmall, expandHomeUnix( + io, &environ_map, "~/Downloads", small_buf[0..], @@ -162,11 +174,12 @@ test "expandHomeUnix" { test { const testing = std.testing; + const io = testing.io; var environ_map = try testing.environ.createMap(testing.allocator); defer environ_map.deinit(); var buf: [1024]u8 = undefined; - const result = try home(&environ_map, &buf); + const result = try home(io, &environ_map, &buf); try testing.expect(result != null); try testing.expect(result.?.len > 0); } diff --git a/src/os/i18n.zig b/src/os/i18n.zig index 0f7790faa..20d3c1b97 100644 --- a/src/os/i18n.zig +++ b/src/os/i18n.zig @@ -62,11 +62,21 @@ pub fn initGlobalDomain() error{OutOfMemory}!void { } /// Translate a message for the Ghostty domain. +/// +/// If this is called at comptime, the direct msgid is returned without +/// translation. This still allows the string to be marked for translation +/// while remaining usable in comptime contexts. pub fn _(msgid: [*:0]const u8) [*:0]const u8 { if (comptime !build_config.i18n) return msgid; + if (@inComptime()) return msgid; return dgettext(build_config.bundle_id, msgid); } +/// Mark a string for translation without translating it immediately. +pub fn N_(msgid: [:0]const u8) [:0]const u8 { + return msgid; +} + /// Canonicalize a locale name from a platform-specific value to /// a POSIX-compliant value. This is a thin layer over the unexported /// gnulib-lib function in gettext that does this already. @@ -191,3 +201,10 @@ test "canonicalizeLocale darwin" { // underscores. We should parse them out before calling this function. try testing.expectEqualStrings("en_US.UTF_8", try canonicalizeLocale(&buf, "en_US.UTF-8")); } + +test "_ returns msgid at comptime" { + const testing = std.testing; + + const msgid = comptime @"_"("Ghostty"); + try testing.expectEqualStrings("Ghostty", std.mem.span(msgid)); +} diff --git a/src/os/main.zig b/src/os/main.zig index 7f9e12cf9..05f2a45af 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -19,6 +19,7 @@ const kernel_info = @import("kernel_info.zig"); // Namespaces pub const cgroup = @import("cgroup.zig"); +pub const edit = @import("edit.zig"); pub const hostname = @import("hostname.zig"); pub const i18n = @import("i18n.zig"); pub const mach = @import("mach.zig"); @@ -28,6 +29,7 @@ pub const xdg = @import("xdg.zig"); pub const windows = @import("windows.zig"); pub const macos = @import("macos.zig"); pub const shell = @import("shell.zig"); +pub const stderr = @import("stderr.zig"); pub const uri = @import("uri.zig"); // Functions and types @@ -54,9 +56,12 @@ pub const resourcesDir = resourcesdir.resourcesDir; pub const ResourcesDir = resourcesdir.ResourcesDir; pub const ShellEscapeWriter = shell.ShellEscapeWriter; pub const getKernelInfo = kernel_info.getKernelInfo; +pub const getConfigEditCommand = edit.getConfigEditCommand; test { _ = file; + _ = stderr; + _ = edit; _ = i18n; _ = path; _ = uri; diff --git a/src/os/open.zig b/src/os/open.zig index 806030fcd..bc09518e6 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -19,12 +19,21 @@ pub fn open( kind: apprt.action.OpenUrl.Kind, url: []const u8, ) !void { + // On macOS, the apprt handles OSC 8 targets before this fallback. Ghostty's + // native apprt applies its allowlist, confirmation, and file safety policy. + // If a macOS embedder declines the action, fail closed rather than bypassing + // that policy by handing producer-controlled terminal output to `open`. + if (comptime builtin.os.tag == .macos) { + if (kind == .osc8) return error.UnsafeOSC8Link; + } + var spawn_opts: std.process.SpawnOptions = switch (builtin.os.tag) { .linux, .freebsd => .{ .argv = &.{ "xdg-open", url } }, .windows => .{ .argv = &.{ "rundll32", "url.dll,FileProtocolHandler", url } }, .macos => switch (kind) { .text => .{ .argv = &.{ "open", "-t", url } }, .html, .unknown => .{ .argv = &.{ "open", url } }, + .osc8 => unreachable, }, .ios => return error.Unimplemented, else => @compileError("unsupported OS"), @@ -58,6 +67,15 @@ pub fn open( thread.detach(); } +test "macOS OSC 8 links have no generic opener fallback" { + if (builtin.os.tag != .macos) return error.SkipZigTest; + + try std.testing.expectError( + error.UnsafeOSC8Link, + open(.osc8, "file:///tmp/payload.command"), + ); +} + fn openThread(io: std.Io, exe_: std.process.Child) void { // Copy the exe so it is non-const. This is necessary because wait() // requires a mutable reference and we can't have one as a thread diff --git a/src/os/stderr.zig b/src/os/stderr.zig new file mode 100644 index 000000000..201bc438b --- /dev/null +++ b/src/os/stderr.zig @@ -0,0 +1,60 @@ +//! Raw, best-effort stderr writing that bypasses `std.Io` entirely. +//! +//! `std.debug.lockStderr` (and everything layered on top of it) routes +//! through `std.Options.debug_io`, whose default implementation is +//! `std.Io.Threaded`. This keeps ~70KB of unreachable code in binaries. + +const std = @import("std"); +const builtin = @import("builtin"); + +/// Write bytes to stderr using the most primitive mechanism available +/// for the target. This is best-effort: errors are ignored, since this +/// is only used for diagnostics (logging and panic messages). +/// +/// Freestanding targets (e.g. wasm) have no stderr, so this is a no-op +/// there. +pub fn write(bytes: []const u8) void { + switch (builtin.os.tag) { + .freestanding, .other => {}, + + .windows => { + const windows = std.os.windows; + const handle = windows.peb().ProcessParameters.hStdError; + var iosb: windows.IO_STATUS_BLOCK = undefined; + _ = windows.ntdll.NtWriteFile( + handle, + null, // event + null, // APC routine + null, // APC context + &iosb, + bytes.ptr, + @intCast(bytes.len), + null, // byte offset + null, // key + ); + }, + + else => { + const posix = std.posix; + var i: usize = 0; + while (i < bytes.len) { + const rc = posix.system.write( + posix.STDERR_FILENO, + bytes[i..].ptr, + bytes.len - i, + ); + switch (posix.errno(rc)) { + .SUCCESS => i += @as(usize, @intCast(rc)), + .INTR => continue, + else => return, + } + } + }, + } +} + +test write { + // Smoke test: must not crash. We can't assert on the output without + // capturing stderr, which isn't worth the complexity here. + write(""); +} diff --git a/src/os/xdg.zig b/src/os/xdg.zig index 7659bbd14..92029586d 100644 --- a/src/os/xdg.zig +++ b/src/os/xdg.zig @@ -6,7 +6,6 @@ const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const posix = std.posix; const homedir = @import("homedir.zig"); -const global = @import("../global.zig"); pub const Options = struct { /// Subdirectories to join to the base. This avoids extra allocations @@ -20,8 +19,8 @@ pub const Options = struct { }; /// Get the XDG user config directory. The returned value is allocated. -pub fn config(alloc: Allocator, environ_map: *const std.process.Environ.Map, opts: Options) ![]u8 { - return try dir(alloc, environ_map, opts, .{ +pub fn config(io: std.Io, alloc: Allocator, environ_map: *const std.process.Environ.Map, opts: Options) ![]u8 { + return try dir(io, alloc, environ_map, opts, .{ .env = "XDG_CONFIG_HOME", .windows_env = "LOCALAPPDATA", .default_subdir = ".config", @@ -29,8 +28,8 @@ pub fn config(alloc: Allocator, environ_map: *const std.process.Environ.Map, opt } /// Get the XDG cache directory. The returned value is allocated. -pub fn cache(alloc: Allocator, environ_map: *const std.process.Environ.Map, opts: Options) ![]u8 { - return try dir(alloc, environ_map, opts, .{ +pub fn cache(io: std.Io, alloc: Allocator, environ_map: *const std.process.Environ.Map, opts: Options) ![]u8 { + return try dir(io, alloc, environ_map, opts, .{ .env = "XDG_CACHE_HOME", .windows_env = "LOCALAPPDATA", .default_subdir = ".cache", @@ -38,8 +37,8 @@ pub fn cache(alloc: Allocator, environ_map: *const std.process.Environ.Map, opts } /// Get the XDG state directory. The returned value is allocated. -pub fn state(alloc: Allocator, environ_map: *const std.process.Environ.Map, opts: Options) ![]u8 { - return try dir(alloc, environ_map, opts, .{ +pub fn state(io: std.Io, alloc: Allocator, environ_map: *const std.process.Environ.Map, opts: Options) ![]u8 { + return try dir(io, alloc, environ_map, opts, .{ .env = "XDG_STATE_HOME", .windows_env = "LOCALAPPDATA", .default_subdir = ".local/state", @@ -54,6 +53,7 @@ const InternalOptions = struct { /// Unified helper to get XDG directories that follow a common pattern. fn dir( + io: std.Io, alloc: Allocator, environ_map: *const std.process.Environ.Map, opts: Options, @@ -89,7 +89,7 @@ fn dir( // Get our home dir var buf: [1024]u8 = undefined; - if (try homedir.home(environ_map, &buf)) |home| { + if (try homedir.home(io, environ_map, &buf)) |home| { return try std.fs.path.join(alloc, &[_][]const u8{ home, internal_opts.default_subdir, @@ -119,12 +119,13 @@ pub fn parseTerminalExec(argv: []const [*:0]const u8) ?[]const [*:0]const u8 { test { const testing = std.testing; + const io = testing.io; const alloc = testing.allocator; var environ_map = try testing.environ.createMap(alloc); defer environ_map.deinit(); { - const value = try config(alloc, &environ_map, .{}); + const value = try config(io, alloc, &environ_map, .{}); defer alloc.free(value); try testing.expect(value.len > 0); } @@ -132,6 +133,7 @@ test { test "cache directory paths" { const testing = std.testing; + const io = testing.io; const alloc = testing.allocator; const mock_home = if (builtin.os.tag == .windows) "C:\\Users\\test" else "/Users/test"; var environ_map = try testing.environ.createMap(alloc); @@ -141,7 +143,7 @@ test "cache directory paths" { { // Test base path { - const cache_path = try cache(alloc, &environ_map, .{ .home = mock_home }); + const cache_path = try cache(io, alloc, &environ_map, .{ .home = mock_home }); defer alloc.free(cache_path); const expected = try std.fs.path.join(alloc, &.{ mock_home, ".cache" }); defer alloc.free(expected); @@ -150,7 +152,7 @@ test "cache directory paths" { // Test with subdir { - const cache_path = try cache(alloc, &environ_map, .{ + const cache_path = try cache(io, alloc, &environ_map, .{ .home = mock_home, .subdir = "ghostty", }); @@ -165,11 +167,12 @@ test "cache directory paths" { test "fallback when xdg env empty" { if (builtin.os.tag == .windows) return error.SkipZigTest; + const io = std.testing.io; const alloc = std.testing.allocator; const DirCase = struct { name: [:0]const u8, - func: fn (Allocator, *std.process.Environ.Map, Options) anyerror![]u8, + func: fn (std.Io, Allocator, *std.process.Environ.Map, Options) anyerror![]u8, default_subdir: []const u8, }; @@ -193,7 +196,7 @@ test "fallback when xdg env empty" { // Test with empty string - should fallback to home try environ_map.put(case.name, ""); - const actual = try case.func(alloc, &environ_map, .{}); + const actual = try case.func(io, alloc, &environ_map, .{}); defer alloc.free(actual); try std.testing.expectEqualStrings(expected, actual); @@ -203,11 +206,12 @@ test "fallback when xdg env empty" { test "fallback when xdg env empty and subdir" { if (builtin.os.tag == .windows) return error.SkipZigTest; + const io = std.testing.io; const alloc = std.testing.allocator; const DirCase = struct { name: [:0]const u8, - func: fn (Allocator, *const std.process.Environ.Map, Options) anyerror![]u8, + func: fn (std.Io, Allocator, *const std.process.Environ.Map, Options) anyerror![]u8, default_subdir: []const u8, }; @@ -232,7 +236,7 @@ test "fallback when xdg env empty and subdir" { // Test with empty string - should fallback to home try environ_map.put(case.name, ""); - const actual = try case.func(alloc, &environ_map, .{ .subdir = "ghostty" }); + const actual = try case.func(io, alloc, &environ_map, .{ .subdir = "ghostty" }); defer alloc.free(actual); try std.testing.expectEqualStrings(expected, actual); diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 6c7432d21..9247454db 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -407,6 +407,44 @@ pub inline fn beginFrame( return try Frame.begin(.{ .queue = self.queue }, renderer, target); } +/// Warm up the Metal device machinery. The first Metal device query in +/// a process takes multiple milliseconds; once warm, subsequent queries +/// are effectively free. Calling this early (e.g. on a background +/// thread at app startup; Metal device queries are thread-safe) moves +/// that one-time cost off the critical path of the first surface's +/// renderer initialization. +pub fn warmup() void { + const device = chooseDevice() catch return; + defer device.release(); + + // Create and release a command queue. The first command queue + // created for a device pays additional one-time driver setup + // costs; subsequent creations are much cheaper. + const queue = device.msgSend(objc.Object, objc.sel("newCommandQueue"), .{}); + queue.release(); + + // Build and discard our shader pipelines for both pixel formats we + // may use (which one is used depends on the blending config). The + // first pipeline state creation compiles shaders which is slow; + // once warm, later creations hit driver and OS caches. + inline for (.{ + mtl.MTLPixelFormat.bgra8unorm_srgb, + mtl.MTLPixelFormat.bgra8unorm, + }) |format| { + if (shaders.Shaders.init( + std.heap.c_allocator, + device, + &.{}, + format, + )) |s| { + var s_mut = s; + s_mut.deinit(std.heap.c_allocator); + } else |err| { + log.warn("metal warmup shader init failed err={}", .{err}); + } + } +} + fn chooseDevice() error{NoMetalDevice}!objc.Object { var chosen_device: ?objc.Object = null; diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 4b2b741d8..e3e2ceccf 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -501,7 +501,7 @@ fn drainMailbox(self: *Thread) !void { .macos_display_id => |v| { if (@hasDecl(rendererpkg.Renderer, "setMacOSDisplayID")) { - try self.renderer.setMacOSDisplayID(v); + try self.renderer.setMacOSDisplayID(v, &self.draw_now); } }, } diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index e0760fce1..1337c5154 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1,6 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); -const xev = @import("xev"); +const global = @import("../global.zig"); +const xev = global.xev; const wuffs = @import("wuffs"); const apprt = @import("../apprt.zig"); const configpkg = @import("../config.zig"); @@ -27,7 +28,6 @@ const ArenaAllocator = std.heap.ArenaAllocator; const Terminal = terminal.Terminal; const Health = renderer.Health; const compat_file = @import("../lib/compat/file.zig"); -const global = @import("../global.zig"); const getConstraint = @import("../font/nerd_font_attributes.zig").getConstraint; @@ -117,6 +117,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// True if the window is focused focused: bool, + /// True if the window is visible. + visible: bool, + /// Flag to indicate that our focus state changed for custom /// shaders to update their state. custom_shader_focused_changed: bool = false, @@ -693,15 +696,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }; }; - const display_link: ?DisplayLink = switch (builtin.os.tag) { - .macos => if (options.config.vsync) - try macos.video.DisplayLink.createWithActiveCGDisplays() - else - null, - else => null, - }; - errdefer if (display_link) |v| v.release(); - var result: Self = .{ .alloc = alloc, .config = options.config, @@ -709,6 +703,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .grid_metrics = font_critical.metrics, .size = options.size, .focused = true, + .visible = true, .scrollbar = .zero, .scrollbar_dirty = false, .last_bottom_node = null, @@ -787,7 +782,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Graphics API stuff .api = api, .swap_chain = swap_chain, - .display_link = display_link, }; try result.initShaders(); @@ -907,16 +901,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // If we don't support a display link we have no work to do. if (comptime DisplayLink == void) return; - // This is when we know our "self" pointer is stable so we can - // setup the display link. To setup the display link we set our - // callback and we can start it immediately. - const display_link = self.display_link orelse return; - try display_link.setOutputCallback( - xev.Async, - &displayLinkCallback, - &thr.draw_now, - ); - display_link.start() catch {}; + self.syncDisplayLink(null, &thr.draw_now); } /// Called by renderer.Thread when it exits the main loop. @@ -1003,13 +988,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } /// Called when we get an updated display ID for our display link. - pub fn setMacOSDisplayID(self: *Self, id: u32) !void { + pub fn setMacOSDisplayID( + self: *Self, + id: u32, + draw_now: *xev.Async, + ) !void { if (comptime DisplayLink == void) return; - const display_link = self.display_link orelse return; - log.info("updating display link display id={}", .{id}); - display_link.setCurrentCGDisplay(id) catch |err| { - log.warn("error setting display link display id err={}", .{err}); - }; + self.syncDisplayLink(id, draw_now); } /// True if our renderer has animations so that a higher frequency @@ -1038,33 +1023,65 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Flag that we need to update our custom shaders self.custom_shader_focused_changed = true; - // If we're not focused, then we want to stop the display link - // because it is a waste of resources and we can move to pure - // change-driven updates. - if (comptime DisplayLink != void) link: { - const display_link = self.display_link orelse break :link; - if (focus) { - display_link.start() catch {}; - } else { - display_link.stop() catch {}; - } - } + self.syncDisplayLink(null, null); } /// Callback when the window is visible or occluded. /// /// Must be called on the render thread. pub fn setVisible(self: *Self, visible: bool) void { + self.visible = visible; + self.syncDisplayLink(null, null); + } + + /// Create or update the display link and match it to the current + /// surface state. + fn syncDisplayLink( + self: *Self, + display_id: ?u32, + draw_now: ?*xev.Async, + ) void { + if (comptime DisplayLink == void) return; + + const display_link = self.display_link orelse display_link: { + if (!self.config.vsync) return; + const callback = draw_now orelse return; + const result = macos.video.DisplayLink.createWithActiveCGDisplays() catch |err| { + // A locked macOS session can temporarily have no active + // displays. Rendering can continue without vsync and a + // later display update will retry this method. + log.warn("error creating display link; using fallback rendering err={}", .{err}); + return; + }; + result.setOutputCallback( + xev.Async, + &displayLinkCallback, + callback, + ) catch |err| { + log.warn("error configuring display link err={}", .{err}); + result.release(); + return; + }; + + self.display_link = result; + log.info("created display link", .{}); + break :display_link result; + }; + + if (display_id) |id| { + log.info("updating display link display id={}", .{id}); + display_link.setCurrentCGDisplay(id) catch |err| { + log.warn("error setting display link display id err={}", .{err}); + }; + } + // If we're not visible, then we want to stop the display link // because it is a waste of resources and we can move to pure // change-driven updates. - if (comptime DisplayLink != void) link: { - const display_link = self.display_link orelse break :link; - if (visible and self.focused) { - display_link.start() catch {}; - } else { - display_link.stop() catch {}; - } + if (self.visible and self.focused) { + display_link.start() catch {}; + } else { + display_link.stop() catch {}; } } @@ -1129,6 +1146,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { state: *renderer.State, cursor_blink_visible: bool, ) Allocator.Error!void { + // CoreText shaping accumulates objects for deferred release over + // the course of a frame. Always flush those objects, including + // when rebuilding the frame fails due to memory pressure. + defer self.font_shaper.endFrame(); + // We fully deinit and reset the terminal state every so often // so that a particularly large terminal state doesn't cause // the renderer to hold on to retained memory. @@ -1138,6 +1160,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (self.terminal_state_frame_count >= max_terminal_state_frame_count) { self.terminal_state.deinit(self.alloc); self.terminal_state = .empty; + self.terminal_state_frame_count = 0; } self.terminal_state_frame_count += 1; @@ -1426,10 +1449,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Update custom shader uniforms that depend on terminal state. self.updateCustomShaderUniformsFromState(); } - - // Notify our shaper we're done for the frame. For some shapers, - // such as CoreText, this triggers off-thread cleanup logic. - self.font_shaper.endFrame(); } /// Draw the frame to the screen. diff --git a/src/renderer/image.zig b/src/renderer/image.zig index dc5e3e228..0126d1c3a 100644 --- a/src/renderer/image.zig +++ b/src/renderer/image.zig @@ -277,7 +277,13 @@ pub const State = struct { while (it.next()) |kv| { switch (kv.key_ptr.*) { // We're only looking at Kitty images - .kitty => |id| if (storage.imageById(id) == null) { + .kitty => |id| if (storage.imageById(id)) |image| { + // A pending source must not keep an older texture for + // the same ID renderable while its placements wait. + if (image.data.isPending()) { + kv.value_ptr.image.markForUnload(); + } + } else { kv.value_ptr.image.markForUnload(); }, @@ -408,6 +414,10 @@ pub const State = struct { image: *const terminal.kitty.graphics.Image, p: *const terminal.kitty.graphics.ImageStorage.Placement, ) PrepImageError!void { + // Keep the native placement but do not create a renderer placement or + // texture until the decoded bytes arrive. + if (image.data.isPending()) return; + // Get the rect for the placement. If this placement doesn't have // a rect then its virtual or something so skip it. const rect = p.rect(image.*, t) orelse return; @@ -478,6 +488,7 @@ pub const State = struct { ); return; }; + if (image.data.isPending()) return; const rp = p.renderPlacement( storage, @@ -597,6 +608,7 @@ pub const State = struct { alloc: Allocator, image: *const terminal.kitty.graphics.Image, ) PrepImageError!void { + const data = image.data.bytes() orelse unreachable; try self.prepImage( alloc, .{ .kitty = image.id }, @@ -615,7 +627,7 @@ pub const State = struct { // constCasts are always gross but this one is safe is because // the data is only read from here and copied into its own // buffer. - .data = @constCast(image.data.ptr), + .data = @constCast(data.ptr), }, ); } @@ -963,3 +975,64 @@ pub const Image = union(enum) { }; } }; + +test "kitty renderer ignores pending payloads and removes replaced placements" { + 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; + const tracked = t.screens.active.pages.countTrackedPins(); + const pending = try storage.addPendingImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 1, + .height = 1, + .format = .rgba, + .data = .{ .pending = 4 }, + }); + 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 }); + try testing.expectEqual(@as(usize, 1), storage.placements.count()); + try testing.expectEqual(@as(usize, 0), state.kitty_placements.items.len); + try testing.expect(state.images.get(.{ .kitty = 1 }) == null); + + const pixels = try alloc.dupe(u8, "rgba"); + try testing.expect(pending.complete(storage, io, pixels)); + state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 }); + try testing.expectEqual(@as(usize, 1), state.kitty_placements.items.len); + try testing.expectEqual( + pending.generation, + state.images.get(.{ .kitty = 1 }).?.generation, + ); + + // A newer pending replacement deletes the native placement and marks the + // copied texture data for unload. + _ = try storage.addPendingImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 1, + .height = 1, + .format = .rgba, + .data = .{ .pending = 4 }, + }); + state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 }); + try testing.expectEqual(@as(usize, 0), storage.placements.count()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); + try testing.expectEqual(@as(usize, 0), state.kitty_placements.items.len); + try testing.expect(state.images.get(.{ .kitty = 1 }).?.image.isUnloading()); +} diff --git a/src/renderer/metal/IOSurfaceLayer.zig b/src/renderer/metal/IOSurfaceLayer.zig index 34fbfbed5..cd97126a4 100644 --- a/src/renderer/metal/IOSurfaceLayer.zig +++ b/src/renderer/metal/IOSurfaceLayer.zig @@ -40,6 +40,10 @@ pub fn init() !IOSurfaceLayer { } pub fn release(self: *IOSurfaceLayer) void { + // The layer may be retained by the view after we release our reference. + // Clear the callback first so that a later display pass can't access the + // renderer that owned this wrapper after it has been freed. + self.setDisplayCallback(null, null); self.layer.release(); } diff --git a/src/synthetic/cli.zig b/src/synthetic/cli.zig index dcac0d0dc..560896030 100644 --- a/src/synthetic/cli.zig +++ b/src/synthetic/cli.zig @@ -9,6 +9,7 @@ pub const Action = enum { ascii, kitty, osc, + styled, utf8, /// Returns the struct associated with the action. The struct @@ -24,6 +25,7 @@ pub const Action = enum { .ascii => @import("cli/Ascii.zig"), .kitty => @import("cli/Kitty.zig"), .osc => @import("cli/Osc.zig"), + .styled => @import("cli/Styled.zig"), .utf8 => @import("cli/Utf8.zig"), }; } diff --git a/src/synthetic/cli/Ascii.zig b/src/synthetic/cli/Ascii.zig index 81a4e6073..b9ce2cb15 100644 --- a/src/synthetic/cli/Ascii.zig +++ b/src/synthetic/cli/Ascii.zig @@ -7,7 +7,14 @@ const Bytes = @import("../Bytes.zig"); const log = std.log.scoped(.@"terminal-stream-bench"); -pub const Options = struct {}; +pub const Options = struct { + /// When nonzero, emit lines whose printable length is uniformly + /// distributed in `[line-min, line-max]`, each terminated by CR LF. + /// When zero (the default), emit an unbroken stream of printable + /// bytes that relies on terminal wrapping. + @"line-min": usize = 0, + @"line-max": usize = 0, +}; fn checkAsciiAlphabet(c: u8) bool { return switch (c) { @@ -18,13 +25,16 @@ fn checkAsciiAlphabet(c: u8) bool { pub const ascii = Bytes.generateAlphabet(checkAsciiAlphabet); +opts: Options, + /// Create a new terminal stream handler for the given arguments. pub fn create( alloc: Allocator, - _: Options, + opts: Options, ) !*Ascii { const ptr = try alloc.create(Ascii); errdefer alloc.destroy(ptr); + ptr.* = .{ .opts = opts }; return ptr; } @@ -32,16 +42,18 @@ pub fn destroy(self: *Ascii, alloc: Allocator) void { alloc.destroy(self); } -pub fn run(_: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void { +pub fn run(self: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void { + const line_max = @max(self.opts.@"line-min", self.opts.@"line-max"); + const lines = line_max > 0; var gen: Bytes = .{ .rand = rand, .alphabet = ascii, - .min_len = 1024, - .max_len = 1024, + .min_len = if (lines) @max(self.opts.@"line-min", 1) else 1024, + .max_len = if (lines) line_max else 1024, }; while (true) { - _ = gen.write(writer) catch |err| { + writeChunk(&gen, writer, lines) catch |err| { const Error = error{ WriteFailed, BrokenPipe } || @TypeOf(err); switch (@as(Error, err)) { error.BrokenPipe => return, // stdout closed @@ -51,6 +63,15 @@ pub fn run(_: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void { } } +fn writeChunk( + gen: *const Bytes, + writer: *std.Io.Writer, + lines: bool, +) std.Io.Writer.Error!void { + _ = try gen.write(writer); + if (lines) try writer.writeAll("\r\n"); +} + test Ascii { const testing = std.testing; const alloc = testing.allocator; @@ -65,3 +86,30 @@ test Ascii { var writer: std.Io.Writer = .fixed(&buf); try impl.run(&writer, rand); } + +test "Ascii lines" { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *Ascii = try .create(alloc, .{ + .@"line-min" = 5, + .@"line-max" = 20, + }); + defer impl.destroy(alloc); + + var prng = std.Random.DefaultPrng.init(1); + const rand = prng.random(); + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try impl.run(&writer, rand); + + // Every emitted line respects the configured bounds. + var it = std.mem.splitSequence(u8, writer.buffered(), "\r\n"); + while (it.next()) |line| { + // The fixed buffer may end mid-line. + if (it.rest().len == 0) break; + try testing.expect(line.len >= 5); + try testing.expect(line.len <= 20); + } +} diff --git a/src/synthetic/cli/Styled.zig b/src/synthetic/cli/Styled.zig new file mode 100644 index 000000000..c437a6ab5 --- /dev/null +++ b/src/synthetic/cli/Styled.zig @@ -0,0 +1,410 @@ +//! Generates styled terminal content: lines of printable text +//! interspersed with SGR sequences, optional multi-byte UTF-8 +//! codepoints, combining marks (multi-codepoint graphemes), and +//! OSC 8 hyperlinks. +//! +//! This exists primarily to build corpora for benchmarking code that +//! consumes terminal *contents* (e.g. the terminal formatter used for +//! clipboard copy and dumps), where we want workloads with controlled +//! amounts of styling and Unicode rather than raw random bytes. +//! +//! Examples: +//! +//! # Plain ASCII lines (equivalent to `ascii` with lines) +//! ghostty-gen styled --seed=42 +//! +//! # Heavily styled ASCII +//! ghostty-gen styled --seed=42 --style-rate=0.8 +//! +//! # Unicode-heavy, no styling +//! ghostty-gen styled --seed=42 --weight-two=1 --weight-three=1 \ +//! --weight-four=0.5 --grapheme-rate=0.1 +//! +//! # Mixed: styles, Unicode, and hyperlinks +//! ghostty-gen styled --seed=42 --style-rate=0.3 --weight-two=0.5 \ +//! --weight-three=0.25 --weight-four=0.1 --grapheme-rate=0.05 \ +//! --osc8-rate=0.1 +const Styled = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +pub const Options = struct { + /// Seed to use for deterministic generation. If unset, a time-based + /// seed is used by the generic synthetic CLI. + seed: ?u64 = null, + + /// Emit lines whose printable length (in characters, not columns) + /// is uniformly distributed in `[line-min, line-max]`. Each line is + /// terminated by CR LF. + @"line-min": usize = 40, + @"line-max": usize = 80, + + /// Probability that a style change (SGR sequence) is emitted at + /// each run boundary. Zero (default) produces unstyled output. + @"style-rate": f64 = 0.0, + + /// The length in characters of a "run": a sequence of characters + /// that share styling. Style/hyperlink changes only happen on run + /// boundaries. + @"run-min": usize = 4, + @"run-max": usize = 16, + + /// Relative weights for choosing the UTF-8 encoding length of each + /// generated character. Unlike the raw `utf8` generator, characters + /// are drawn from curated printable ranges so the output never + /// contains control characters: + /// + /// one: printable ASCII (with occasional spaces) + /// two: Latin-1 Supplement / Latin Extended (narrow) + /// three: Hiragana/Katakana and CJK ideographs (wide) + /// four: emoji (wide) + @"weight-one": f64 = 1.0, + @"weight-two": f64 = 0.0, + @"weight-three": f64 = 0.0, + @"weight-four": f64 = 0.0, + + /// Probability that a generated character is followed by a + /// combining diacritical mark, producing a multi-codepoint + /// grapheme cluster. + @"grapheme-rate": f64 = 0.0, + + /// Probability that an OSC 8 hyperlink is toggled (opened or + /// closed) at each run boundary. + @"osc8-rate": f64 = 0.0, +}; + +opts: Options, + +pub fn create( + alloc: Allocator, + opts: Options, +) !*Styled { + for ([_]f64{ + opts.@"style-rate", + opts.@"grapheme-rate", + opts.@"osc8-rate", + }) |rate| { + if (rate < 0 or rate > 1) return error.InvalidValue; + } + + const weights = [_]f64{ + opts.@"weight-one", + opts.@"weight-two", + opts.@"weight-three", + opts.@"weight-four", + }; + var weight_sum: f64 = 0; + for (weights) |weight| { + if (weight < 0) return error.InvalidValue; + weight_sum += weight; + } + if (weight_sum <= 0) return error.InvalidValue; + + if (opts.@"line-min" == 0) return error.InvalidValue; + if (opts.@"run-min" == 0) return error.InvalidValue; + + const ptr = try alloc.create(Styled); + errdefer alloc.destroy(ptr); + ptr.* = .{ .opts = opts }; + return ptr; +} + +pub fn destroy(self: *Styled, alloc: Allocator) void { + alloc.destroy(self); +} + +pub fn run(self: *Styled, writer: *std.Io.Writer, rand: std.Random) !void { + var prng: ?std.Random.DefaultPrng = null; + var gen_rand = rand; + if (self.opts.seed) |seed| { + prng = std.Random.DefaultPrng.init(seed); + gen_rand = prng.?.random(); + } + + var state: State = .{}; + while (true) { + self.writeLine(writer, gen_rand, &state) catch |err| { + const Error = error{ WriteFailed, BrokenPipe } || @TypeOf(err); + switch (@as(Error, err)) { + error.BrokenPipe => return, // stdout closed + error.WriteFailed => return, // fixed buffer full + } + }; + } +} + +const State = struct { + /// True when a non-default SGR style is currently active. + styled: bool = false, + + /// True when an OSC 8 hyperlink is currently open. + link: bool = false, +}; + +fn writeLine( + self: *Styled, + writer: *std.Io.Writer, + rand: std.Random, + state: *State, +) std.Io.Writer.Error!void { + const line_min = self.opts.@"line-min"; + const line_max = @max(line_min, self.opts.@"line-max"); + const run_min = self.opts.@"run-min"; + const run_max = @max(run_min, self.opts.@"run-max"); + + const line_len = rand.intRangeAtMostBiased(usize, line_min, line_max); + var remaining = line_len; + while (remaining > 0) { + if (self.opts.@"style-rate" > 0 and + rand.float(f64) < self.opts.@"style-rate") + { + try self.writeSgr(writer, rand, state); + } + + if (self.opts.@"osc8-rate" > 0 and + rand.float(f64) < self.opts.@"osc8-rate") + { + try self.toggleLink(writer, rand, state); + } + + const run_len = @min( + remaining, + rand.intRangeAtMostBiased(usize, run_min, run_max), + ); + for (0..run_len) |_| try self.writeChar(writer, rand); + remaining -= run_len; + } + + // Close any open styling/hyperlink so state never bleeds across + // lines. This mirrors what well-behaved programs do. + if (state.styled) { + try writer.writeAll("\x1b[0m"); + state.styled = false; + } + if (state.link) { + try writer.writeAll("\x1b]8;;\x1b\\"); + state.link = false; + } + + try writer.writeAll("\r\n"); +} + +fn writeChar( + self: *Styled, + writer: *std.Io.Writer, + rand: std.Random, +) std.Io.Writer.Error!void { + const weights = [_]f64{ + self.opts.@"weight-one", + self.opts.@"weight-two", + self.opts.@"weight-three", + self.opts.@"weight-four", + }; + + const cp: u21 = switch (rand.weightedIndex(f64, &weights)) { + // Printable ASCII with occasional word-ish spacing. + 0 => if (rand.float(f64) < 0.15) + ' ' + else + rand.intRangeAtMostBiased(u21, 0x21, 0x7E), + + // Latin-1 Supplement and Latin Extended-A/B (narrow). + 1 => rand.intRangeAtMostBiased(u21, 0xC0, 0x24F), + + // Kana or CJK ideographs (wide). + 2 => if (rand.boolean()) + rand.intRangeAtMostBiased(u21, 0x3041, 0x30FE) + else + rand.intRangeAtMostBiased(u21, 0x4E00, 0x9FFF), + + // Emoji (wide). + 3 => rand.intRangeAtMostBiased(u21, 0x1F600, 0x1F64F), + + else => unreachable, + }; + + try writer.print("{u}", .{cp}); + + // Optionally follow with a combining diacritical mark to form a + // multi-codepoint grapheme cluster. + if (self.opts.@"grapheme-rate" > 0 and + rand.float(f64) < self.opts.@"grapheme-rate") + { + const mark = rand.intRangeAtMostBiased(u21, 0x300, 0x36F); + try writer.print("{u}", .{mark}); + } +} + +fn writeSgr( + self: *Styled, + writer: *std.Io.Writer, + rand: std.Random, + state: *State, +) std.Io.Writer.Error!void { + _ = self; + + switch (rand.uintLessThan(u8, 8)) { + // Reset. + 0 => { + try writer.writeAll("\x1b[0m"); + state.styled = false; + return; + }, + + // 16-color foreground. + 1 => { + const base: u8 = if (rand.boolean()) 30 else 90; + try writer.print("\x1b[{d}m", .{base + rand.uintLessThan(u8, 8)}); + }, + + // 16-color background. + 2 => { + const base: u8 = if (rand.boolean()) 40 else 100; + try writer.print("\x1b[{d}m", .{base + rand.uintLessThan(u8, 8)}); + }, + + // 256-color foreground/background. + 3 => try writer.print("\x1b[38;5;{d}m", .{rand.int(u8)}), + 4 => try writer.print("\x1b[48;5;{d}m", .{rand.int(u8)}), + + // RGB foreground/background. Components are quantized so the + // number of unique styles stays bounded (6^3 per channel pair) + // rather than pathologically unique per cell. + 5, 6 => |kind| { + const layer: u8 = if (kind == 5) 38 else 48; + try writer.print("\x1b[{d};2;{d};{d};{d}m", .{ + layer, + rand.uintLessThan(u8, 6) * 51, + rand.uintLessThan(u8, 6) * 51, + rand.uintLessThan(u8, 6) * 51, + }); + }, + + // Attributes: bold, italic, underline, inverse, strikethrough. + 7 => { + const attrs = [_]u8{ 1, 3, 4, 7, 9 }; + try writer.print("\x1b[{d}m", .{ + attrs[rand.uintLessThan(usize, attrs.len)], + }); + }, + + else => unreachable, + } + + state.styled = true; +} + +fn toggleLink( + self: *Styled, + writer: *std.Io.Writer, + rand: std.Random, + state: *State, +) std.Io.Writer.Error!void { + _ = self; + + if (state.link) { + try writer.writeAll("\x1b]8;;\x1b\\"); + state.link = false; + return; + } + + try writer.print( + "\x1b]8;;http://example.com/{d}\x1b\\", + .{rand.uintLessThan(u16, 1024)}, + ); + state.link = true; +} + +test Styled { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *Styled = try .create(alloc, .{ .seed = 1 }); + defer impl.destroy(alloc); + + var prng = std.Random.DefaultPrng.init(1); + const rand = prng.random(); + + var buf: [4096]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try impl.run(&writer, rand); + const output = writer.buffered(); + try testing.expect(output.len > 0); + + // Default options: plain printable ASCII lines only. + for (output) |byte| { + try testing.expect(byte == '\r' or byte == '\n' or + (byte >= 0x20 and byte < 0x7F)); + } +} + +test "Styled styled output" { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *Styled = try .create(alloc, .{ + .seed = 1, + .@"style-rate" = 0.5, + .@"osc8-rate" = 0.2, + }); + defer impl.destroy(alloc); + + var prng = std.Random.DefaultPrng.init(1); + const rand = prng.random(); + + var buf: [16384]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try impl.run(&writer, rand); + const output = writer.buffered(); + + // Must contain SGR and OSC 8 sequences. + try testing.expect(std.mem.indexOf(u8, output, "\x1b[") != null); + try testing.expect(std.mem.indexOf(u8, output, "\x1b]8;;") != null); +} + +test "Styled unicode output" { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *Styled = try .create(alloc, .{ + .seed = 1, + .@"weight-two" = 1.0, + .@"weight-three" = 1.0, + .@"weight-four" = 1.0, + .@"grapheme-rate" = 0.25, + }); + defer impl.destroy(alloc); + + var prng = std.Random.DefaultPrng.init(1); + const rand = prng.random(); + + var buf: [16384]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try impl.run(&writer, rand); + const output = writer.buffered(); + + // No escapes, valid UTF-8 (modulo a possibly truncated tail from + // the fixed buffer filling up mid-sequence). + try testing.expect(std.mem.indexOfScalar(u8, output, 0x1B) == null); + var end = output.len; + while (end > 0 and output[end - 1] & 0xC0 == 0x80) end -= 1; + if (end > 0 and output[end - 1] >= 0xC0) end -= 1; + try testing.expect(std.unicode.utf8ValidateSlice(output[0..end])); +} + +test "Styled invalid options" { + const testing = std.testing; + const alloc = testing.allocator; + + try testing.expectError(error.InvalidValue, Styled.create(alloc, .{ + .@"style-rate" = 1.5, + })); + try testing.expectError(error.InvalidValue, Styled.create(alloc, .{ + .@"weight-one" = 0, + })); + try testing.expectError(error.InvalidValue, Styled.create(alloc, .{ + .@"line-min" = 0, + })); +} diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index b8149544c..429a3633b 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -9,6 +9,7 @@ const build_options = @import("terminal_options"); const Allocator = std.mem.Allocator; const assert = @import("../quirks.zig").inlineAssert; const fastmem = @import("../fastmem.zig"); +const simd = @import("../simd/main.zig"); const tripwire = @import("../tripwire.zig"); const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList; const color = @import("color.zig"); @@ -402,6 +403,15 @@ page_size: usize, /// in the state struct. page_compression: IncrementalCompressionState = .{}, +/// A node available for immediate reuse by createPage, bypassing the +/// memory pool round trip. This is only set during column reflow +/// (resizeCols), which destroys one source page for roughly every +/// destination page it creates: returning a page buffer to the pool +/// decommits it and taking one back recommits it, and that syscall +/// pair per page is a significant part of reflow cost. Always null +/// outside of an in-progress reflow. +recycle_node: ?*List.Node = null, + /// Limits for scrollback. limits: Limits, @@ -1416,6 +1426,14 @@ fn resizeCols( } } + // Reflowed source pages are stashed for reuse as destination + // pages (see recycle_node). Whether we succeed or fail, a stashed + // node must not outlive the reflow. + defer if (self.recycle_node) |node| { + self.recycle_node = null; + self.destroyNode(node); + }; + // Set our new page as the only page. This orphans the existing pages // in the list, but that's fine since we're gonna delete them anyway. self.pages.first = first_rewritten_node; @@ -1431,11 +1449,21 @@ fn resizeCols( if (preserved_cursor) |c| c.tracked_pin else null, ); - // Once we're done reflowing a page, destroy it immediately. - // This frees memory and makes it more likely in memory - // constrained environments that the next reflow will work. - if (row.y == row.node.rows() - 1) { - self.destroyNode(row.node); + // Once we're done reflowing a page, we're done with it, so + // make it available for reuse (or destroy it). Making it + // immediately available frees memory and makes it more + // likely in memory constrained environments that the next + // reflow will work. + if (row.y == row.node.rows() - 1) destroy_node: { + if (self.recycle_node != null or + row.node.owned != .pool or + row.node.data != .resident) + { + self.destroyNode(row.node); + break :destroy_node; + } + + self.recycle_node = row.node; } } @@ -1522,6 +1550,37 @@ const ReflowCursor = struct { /// This is the final row count of the reflowed pages. total_rows: usize, + /// Memoizes the most recent source-to-destination style id + /// mapping. Styled cells come in long runs sharing the same style + /// so this lets writeCell bump the destination ref count directly + /// instead of performing a set lookup for every styled cell. + /// + /// The destination id is only valid for the current destination + /// page, which is why this lives on the cursor: every destination + /// page change goes through init() which resets this. + style_cache: StyleCache, + + /// Memoizes the capacity adjustment for new destination pages + /// (see reflowRow). It only depends on the source page so this is + /// keyed by the source page pointer, which reflow visits + /// sequentially and never revisits. + cap_memo: ?struct { + src_page: *const Page, + cap: Capacity, + }, + + const StyleCache = struct { + src_page: ?*const Page, + src_id: stylepkg.Id, + dst_id: stylepkg.Id, + + const invalid: StyleCache = .{ + .src_page = null, + .src_id = stylepkg.default_id, + .dst_id = stylepkg.default_id, + }; + }; + fn init(node: *List.Node) ReflowCursor { const page = node.page(); const rows = page.rows.ptr(page.memory); @@ -1537,6 +1596,9 @@ const ReflowCursor = struct { // Initially whatever size our input node is. .total_rows = node.rows(), + + .style_cache = .invalid, + .cap_memo = null, }; } @@ -1566,12 +1628,21 @@ const ReflowCursor = struct { if (cols_len == 0 and src_row.semantic_prompt != .none) cols_len = 1; } - // Handle tracked pin adjustments. + // Handle tracked pin adjustments. We also note whether any + // tracked pin is on this row at all so that the per-cell loop + // below can skip pin scans entirely for the overwhelmingly + // common case of a row with no pins. Note we compare nodes + // rather than pages since a node owns exactly one page; this + // is cheaper and avoids `page()` restoring unrelated + // compressed nodes purely for a comparison. + var row_has_pins = false; { const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (p.node.page() != src_page or - p.y != src_y) continue; + if (p.node != row.node or p.y != src_y) continue; + + // This row has pins + row_has_pins = true; if (cursor_pin != null and p == cursor_pin.?) continue; @@ -1593,7 +1664,7 @@ const ReflowCursor = struct { // If the cursor is after blanks on the right, those cells are still // before the next write and must reflow with it. if (cursor_pin) |p| { - if (p.node.page() == src_page and p.y == src_y) { + if (p.node == row.node and p.y == src_y) { cols_len = @max(cols_len, p.x + 1); } } @@ -1610,18 +1681,16 @@ const ReflowCursor = struct { // Inherit increased styles or grapheme bytes from the src page // we're reflowing from for new pages. - const cap = src_page.capacity.adjust( - .{ .cols = self.page.size.cols }, - ) catch |err| err: { - comptime assert(@TypeOf(err) == error{OutOfMemory}); - - var cap = src_page.capacity; - cap.cols = self.page.size.cols; - // We're already a non-standard page. We don't want to - // inherit a massive set of rows, so cap it at our std size. - cap.rows = @min(src_page.size.rows, std_capacity.rows); - break :err cap; - }; + // + // This only depends on the source page, which we process row + // by row, so memoize it: computing the adjustment requires a + // full page layout calculation which is much too expensive to + // do for every row. + const cap: Capacity = if (self.cap_memo) |memo| cap: { + if (memo.src_page == src_page) break :cap memo.cap; + // Source page changed, fall through to recompute. + break :cap self.computeAndMemoizeCap(src_page); + } else self.computeAndMemoizeCap(src_page); // Our row isn't blank, write any new rows we deferred. while (self.new_rows > 0) { @@ -1640,11 +1709,30 @@ const ReflowCursor = struct { self.page_row.wrap_continuation = true; } + // Fast path: bulk-copy a run of simple cells directly + // into the destination row. The vast majority of cells + // are narrow, have no managed memory (graphemes, + // hyperlinks), and share a single style in long runs, so + // this avoids the per-cell state machine below for most + // of the work. Rows with tracked pins take the slow path + // so pin remapping behaves identically. + if (!row_has_pins) { + const max_run = @min( + cols_len - x, + @as(usize, self.page.size.cols) - self.x, + ); + const run = bulkRunLength(cells[x..][0..max_run]); + if (run > 0 and self.copyRun(cells[x..][0..run], src_page)) { + x += run; + continue; + } + } + // Move any tracked pins from the source. - { + if (row_has_pins) { const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (p.node.page() != src_page or + if (p.node != row.node or p.y != src_y or p.x != x) continue; @@ -1667,16 +1755,15 @@ const ReflowCursor = struct { .skip_next => { // Remap any tracked pins at the skipped position (x+1) // since we won't process that cell in the loop. - const pin_keys = list.tracked_pins.keys(); - for (pin_keys) |p| { - if (p.node.page() != src_page or + if (row_has_pins) for (list.tracked_pins.keys()) |p| { + if (p.node != row.node or p.y != src_y or p.x != x + 1) continue; p.node = self.node; p.x = self.x; p.y = self.y; - } + }; x += 2; }, @@ -1714,6 +1801,246 @@ const ReflowCursor = struct { } } + /// Compute and memoize the new-page capacity for the given source + /// page. See the call site in reflowRow for details. + fn computeAndMemoizeCap( + self: *ReflowCursor, + src_page: *const Page, + ) Capacity { + const cap = src_page.capacity.adjust( + .{ .cols = self.page.size.cols }, + ) catch |err| err: { + comptime assert(@TypeOf(err) == error{OutOfMemory}); + + var cap = src_page.capacity; + cap.cols = self.page.size.cols; + // We're already a non-standard page. We don't want to + // inherit a massive set of rows, so cap it at our std size. + cap.rows = @min(src_page.size.rows, std_capacity.rows); + break :err cap; + }; + + self.cap_memo = .{ .src_page = src_page, .cap = cap }; + return cap; + } + + /// True if this cell can be copied verbatim as part of a bulk + /// run: a narrow plain-text or bg-color cell with no managed + /// memory (graphemes, hyperlinks) and no special reflow handling + /// (wide characters, spacers, Kitty virtual placeholders). For + /// these cells writeCell reduces to a copy of the raw cell plus + /// a style ref count adjustment. + inline fn bulkCopyable(cell: pagepkg.Cell) bool { + return switch (cell.content_tag) { + .codepoint => copyable: { + if (cell.wide != .narrow) break :copyable false; + if (cell.hyperlink) break :copyable false; + if (comptime build_options.kitty_graphics) { + // Placeholders must set a row flag, so they take + // the slow path. + if (cell.content.codepoint.data == + kitty.graphics.unicode.placeholder) + { + break :copyable false; + } + } + break :copyable true; + }, + + // Grapheme data must be cloned cell-by-cell. + .codepoint_grapheme => false, + + // These are guaranteed to have no style or grapheme data + // (see writeCell) so they are pure copies. The style + // check is defensive so that a bg cell can never join or + // extend a styled run. + .bg_color_palette, + .bg_color_rgb, + => cell.style_id == stylepkg.default_id, + }; + } + + /// The group length for the vectorized bulk run scan below: the + /// SIMD lane count where the target supports it, otherwise a + /// plain unrolled group like other cell scans use (e.g. the + /// render state scans). + const bulk_group_len = simd.lanes(u64) orelse 8; + + /// Masked compare helper covering every cell field that a run + /// must share to be copied by copyRun: given that the first cell + /// of a run passed the full bulkCopyable predicate, equality on + /// these fields implies the same for every subsequent cell, with + /// the same style. + /// + /// Note this is slightly stricter than bulkCopyable (e.g. a + /// bg-color cell won't extend an unstyled text run even though it + /// is copyable): that only splits the copy into multiple runs, + /// which is still correct. + const BulkRunMask = pagepkg.Mask(pagepkg.Cell, &.{ + "content_tag", + "style_id", + "wide", + "hyperlink", + }, bulk_group_len); + + /// Masked compare helper for detecting the Kitty virtual + /// placeholder codepoint in text cells. Placeholders take the + /// slow path (they must set a row flag), so they terminate a run. + const PlaceholderMask = pagepkg.Mask(pagepkg.Cell, &.{ + "content.codepoint.data", + }, bulk_group_len); + + /// The length of the prefix of cells that can be copied at once + /// with copyRun: bulk-copyable cells sharing a single style. The + /// scan uses masked compares of the raw cell bits (see + /// BulkRunMask), which is significantly cheaper than the + /// field-wise predicate for this hot loop. + fn bulkRunLength(cells: []const pagepkg.Cell) usize { + if (cells.len == 0) return 0; + const first = cells[0]; + if (!bulkCopyable(first)) return 0; + + const run_pattern = BulkRunMask.pattern(first); + + // Only text cells can contain a placeholder; for bg color + // tags the content bits are a color, so we skip the check for + // those (the tag is part of BulkRunMask, making tags uniform + // per run). + const check_placeholder = build_options.kitty_graphics and + first.content_tag == .codepoint; + const placeholder_pattern = comptime pattern: { + // Never used without kitty graphics (check_placeholder + // is comptime-false), but it must still compile and the + // placeholder codepoint doesn't exist in that build. + if (!build_options.kitty_graphics) break :pattern 0; + + break :pattern PlaceholderMask.pattern(.init( + kitty.graphics.unicode.placeholder, + )); + }; + + var len: usize = 1; + + // Vectorized scan: check whole groups of cells at once. If a + // group fully matches, the run extends by the whole group; + // otherwise fall through to the scalar loop below, which + // finds the exact end of the run within it. + while (cells.len - len >= bulk_group_len) { + if (!BulkRunMask.eql(cells, len, run_pattern)) break; + if (check_placeholder and PlaceholderMask.eqlAny( + cells, + len, + placeholder_pattern, + )) break; + len += bulk_group_len; + } + + while (len < cells.len) : (len += 1) { + if (!BulkRunMask.eqlScalar(cells[len], run_pattern)) break; + if (check_placeholder and PlaceholderMask.eqlScalar( + cells[len], + placeholder_pattern, + )) break; + } + + return len; + } + + /// Copy a run of bulk-copyable cells (see bulkCopyable) sharing + /// one style into the destination row at the current position, + /// then advance the cursor. The run must fit in the remaining + /// columns of the destination row. + /// + /// Returns false without any state change if the style could not + /// be mapped into the destination page without a capacity + /// change; the caller should fall back to writeCell which + /// handles growing capacity. + fn copyRun( + self: *ReflowCursor, + src_cells: []const pagepkg.Cell, + src_page: *const Page, + ) bool { + assert(!self.pending_wrap); + assert(src_cells.len >= 1); + assert(src_cells.len <= self.page.size.cols - self.x); + + const style_id = src_cells[0].style_id; + const n: u16 = @intCast(src_cells.len); + + // Resolve the destination style id for this run and take one + // reference per cell. This mirrors the per-cell style logic + // in writeCell, including the memoization (see StyleCache). + const dst_style_id: stylepkg.Id = if (style_id == stylepkg.default_id) + stylepkg.default_id + else dst: { + if (self.style_cache.src_page == @as(?*const Page, src_page) and + self.style_cache.src_id == style_id) + { + const id = self.style_cache.dst_id; + self.page.styles.useMultiple(self.page.memory, id, n); + break :dst id; + } + + const style = src_page.styles.get( + src_page.memory, + style_id, + ).*; + + // Any error here (set full or needs rehash) is handled + // by falling back to the slow path, which grows capacity. + // No state has been modified yet at this point. + const id = (self.page.styles.addWithId( + self.page.memory, + style, + style_id, + ) catch return false) orelse style_id; + + // addWithId took one reference, take the rest. + if (n > 1) self.page.styles.useMultiple( + self.page.memory, + id, + n - 1, + ); + + self.style_cache = .{ + .src_page = src_page, + .src_id = style_id, + .dst_id = id, + }; + + break :dst id; + }; + + // Copy the raw cell contents. + const dst_cells: []pagepkg.Cell = @as( + [*]pagepkg.Cell, + @ptrCast(self.page_cell), + )[0..src_cells.len]; + @memcpy(dst_cells, src_cells); + + // If the style resolved to a different id in the destination + // page then rewrite the copied cells to point at it. + if (dst_style_id != style_id) { + for (dst_cells) |*cell| cell.style_id = dst_style_id; + } + if (dst_style_id != stylepkg.default_id) self.page_row.styled = true; + + // Advance the cursor, matching what repeated cursorForward + // calls after each cell write would have done. + const cols = self.page.size.cols; + const cell_ptr: [*]pagepkg.Cell = @ptrCast(self.page_cell); + if (self.x + n == cols) { + self.x = cols - 1; + self.page_cell = @ptrCast(cell_ptr + n - 1); + self.pending_wrap = true; + } else { + self.x += n; + self.page_cell = @ptrCast(cell_ptr + n); + } + + return true; + } + /// Write a cell. On error, this will not unwrite the cell but /// the cell may be incomplete (but valid). For example, if the source /// cell is styled and we failed to allocate space for styles, the @@ -2030,6 +2357,23 @@ const ReflowCursor = struct { // Copy style data. if (cell.hasStyling()) style: { + // Fast path: styled cells come in long runs sharing the + // same style. If this source style was just mapped into + // the current destination page, bump the ref count + // directly and skip the set lookup. The destination id is + // guaranteed alive because a previously written cell in + // this page holds a reference, and the cache is reset + // whenever the destination page changes (see init). + if (self.style_cache.src_page == src_page and + self.style_cache.src_id == cell.style_id) + { + const id = self.style_cache.dst_id; + self.page.styles.use(self.page.memory, id); + self.page_row.styled = true; + self.page_cell.style_id = id; + break :style; + } + const style = src_page.styles.get( src_page.memory, cell.style_id, @@ -2072,6 +2416,14 @@ const ReflowCursor = struct { }; } orelse cell.style_id; + // Update our style cache with the latest style set so runs + // of cells with the same style are faster to write. + self.style_cache = .{ + .src_page = src_page, + .src_id = cell.style_id, + .dst_id = id, + }; + self.page_row.styled = true; self.page_cell.style_id = id; } @@ -4005,6 +4357,45 @@ inline fn createPage( opts: CreatePage, ) Allocator.Error!*List.Node { // log.debug("create page cap={}", .{opts.cap}); + + // If we have a node available for recycling (only during reflow, + // see recycle_node), reuse it directly rather than going through + // the memory pool. + if (self.recycle_node) |node| recycle: { + // Only a standard pool-owned resident node can be rebuilt + // in place for a standard-size layout. + if (opts.exact_size) break :recycle; + if (node.owned != .pool) break :recycle; + if (node.data != .resident) break :recycle; + const layout = Page.layout(opts.cap); + if (layout.total_size > std_size) break :recycle; + + self.recycle_node = null; + + // The pool guarantees that buffers it hands out are zeroed. + // A pool-owned page dirties only its Page.memory prefix of + // the underlying standard-size item, so zeroing that prefix + // re-establishes the guarantee (this mirrors destroyNodeExt, + // minus the decommit). + const page = &node.data.resident; + const item: *align(std.heap.page_size_min) [std_size]u8 = + @ptrCast(@alignCast(page.memory.ptr)); + @memset(page.memory, 0); + + // Accounting: a pool-owned node always accounts for a full + // pool item in page_size, so destroying the node and + // creating a new pooled one is a net zero. + + node.* = .{ + .data = .{ .resident = .initBuf(.init(item), layout) }, + .serial = self.page_serial, + .owned = .pool, + }; + node.page().size.rows = 0; + self.page_serial += 1; + return node; + } + return try createPageExt( &self.pool, opts, @@ -16252,7 +16643,7 @@ test "PageList resize reflow exceeds grapheme memory forcing capacity increase" const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, .{ .cols = 2, .rows = 10, .max_size = 0 }); + var s = try init(alloc, .{ .cols = 4, .rows = 10, .max_size = 0 }); defer s.deinit(); try testing.expectEqual(@as(usize, 1), s.totalPages()); @@ -16274,44 +16665,38 @@ test "PageList resize reflow exceeds grapheme memory forcing capacity increase" try std.testing.expectEqual(s.pages.last.?, s.pages.first.?.next); } - // We use almost all grapheme alloc capacity with a grapheme in the final - // row of the first page, and do the same on the first row of the second - // page. We also mark the row as wrapped so that when we resize with more - // cols the row unwraps and we have a single row that requires almost two - // times the base grapheme alloc capacity. + // We use all grapheme alloc capacity with four maximum-sized graphemes on + // each page. The two rows form one wrapped logical line across the page + // boundary, so resizing wider moves all eight graphemes into one page and + // requires almost two times the base grapheme alloc capacity. // // This forces the reflow to increase capacity. // - // +--+ = PAGE 0 + // +----+ = PAGE 0 // : : - // | X… <- where X is a grapheme which uses almost all the capacity. - // +--+ - // +--+ = PAGE 1 - // …X | <- X here also almost hits grapheme cap. - // +--+ + // |XXXX| <- four capped graphemes in one wrapped row. + // +----+ + // +----+ = PAGE 1 + // |XXXX| <- four more capped graphemes continue the logical line. + // +----+ - // Almost hit grapheme alloc cap in bottom right of first page. - // Mark the final row as wrapped. + const suffixes: [pagepkg.grapheme_max_len]u21 = @splat('a'); + + // Fill the final row of the first page and mark it as wrapped. { const page = s.pages.first.?.page(); - const rac = page.getRowAndCell(page.size.cols - 1, page.size.rows - 1); - rac.row.wrap = true; - rac.cell.* = .{ - .content_tag = .codepoint, - .content = .{ .codepoint = .{ .data = 'X' } }, - }; - try page.setGraphemes( - rac.row, - rac.cell, - &@as( - [ - @divFloor( - pagepkg.grapheme_bytes_default - 1, - @sizeOf(u21), - ) - ]u21, - @splat('a'), - ), + const y = page.size.rows - 1; + const row = page.getRow(y); + row.wrap = true; + + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .init('X'); + try page.setGraphemes(rac.row, rac.cell, &suffixes); + } + try std.testing.expectEqual( + page.grapheme_alloc.capacityBytes(), + page.grapheme_alloc.usedBytes(page.memory), ); try std.testing.expectError( error.OutOfMemory, @@ -16323,29 +16708,17 @@ test "PageList resize reflow exceeds grapheme memory forcing capacity increase" ); } - // Almost hit grapheme alloc cap in top left of second page. - // Mark the first row as a wrap continuation. + // Fill the first row of the second page and mark it as a continuation. { const page = s.pages.last.?.page(); - const rac = page.getRowAndCell(0, 0); - rac.row.wrap = true; - rac.cell.* = .{ - .content_tag = .codepoint, - .content = .{ .codepoint = .{ .data = 'X' } }, - }; - try page.setGraphemes( - rac.row, - rac.cell, - &@as( - [ - @divFloor( - pagepkg.grapheme_bytes_default - 1, - @sizeOf(u21), - ) - ]u21, - @splat('a'), - ), - ); + const row = page.getRow(0); + row.wrap_continuation = true; + + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .init('X'); + try page.setGraphemes(rac.row, rac.cell, &suffixes); + } try std.testing.expectError( error.OutOfMemory, page.grapheme_alloc.alloc( diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 88e7edf7c..34e7e30f4 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -166,12 +166,18 @@ pub const Action = union(enum) { // of invisible characters we don't want to handle right // now. - // All others do the default behavior + // All others do the default behavior. Note the max + // depth of 1: this is only used for logging so we + // only want the top-level fields. Larger depths + // instantiate a recursive formatter for every nested + // type of every action payload (e.g. the full + // osc.Command union), which costs tens of kilobytes + // of binary size. else => try writer.printValue( "any", .{}, @field(self, u_field.name), - 3, + 1, ), } } @@ -386,10 +392,7 @@ inline fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { // We only allow colon or mixed separators for the 'm' command. if (c != 'm' and self.params_sep.count() > 0) { @branchHint(.cold); - log.warn( - "CSI colon or mixed separators only allowed for 'm' command, got: {f}", - .{result}, - ); + warnCsiSepMismatch(result.csi_dispatch); break :csi_dispatch null; } @@ -406,6 +409,20 @@ inline fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { }; } +/// Log a warning for a CSI dispatch with colon/mixed separators on a +/// non-'m' command. +/// +/// This is noinline on purpose so that this unlikely (cold) behavior +/// doesn't bloat the hot dispatch path which has been measured to actually +/// affect both binary size and performance due to icache busts. +noinline fn warnCsiSepMismatch(csi: Action.CSI) void { + @branchHint(.cold); + log.warn( + "CSI colon or mixed separators only allowed for 'm' command, got: {f}", + .{csi}, + ); +} + pub inline fn clear(self: *Parser) void { self.intermediates_idx = 0; self.params_idx = 0; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 8cc2787e1..41149f6da 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -17,6 +17,7 @@ const PageList = @import("PageList.zig"); const selection_codepoints = @import("selection_codepoints.zig"); const StringMap = @import("StringMap.zig"); const ScreenFormatter = @import("formatter.zig").ScreenFormatter; +const PinMap = @import("formatter.zig").PinMap; const osc = @import("osc.zig"); const pagepkg = @import("page.zig"); const point = @import("point.zig"); @@ -333,14 +334,12 @@ pub fn init( }; if (comptime build_options.kitty_graphics) { - // This can't fail because the storage is always empty at this point - // and the only fail-able case is that we have to evict images. result.kitty_images.setLimit( io, alloc, &result, opts.kitty_image_storage_limit, - ) catch unreachable; + ); result.kitty_images.image_limits = opts.kitty_image_loading_limits; } @@ -2910,7 +2909,7 @@ pub fn selectionString( formatter.content = .{ .selection = opts.sel }; // If we have a string map, we need to set that up. - var pins: std.ArrayList(Pin) = .empty; + var pins: PinMap.Map = .empty; defer pins.deinit(alloc); if (opts.map != null) formatter.pin_map = .{ .alloc = alloc, @@ -2928,11 +2927,13 @@ pub fn selectionString( const map_string = try alloc.dupeZ(u8, text); errdefer alloc.free(map_string); try selectionString_tw.check(.copy_map); - const map_pins = try pins.toOwnedSlice(alloc); map.* = .{ .string = map_string, - .map = map_pins, + .map = pins, }; + + // Ownership of the pin map moved to the string map. + pins = .empty; } return text; diff --git a/src/terminal/StringMap.zig b/src/terminal/StringMap.zig index 9b2fa3ca8..b06a6d176 100644 --- a/src/terminal/StringMap.zig +++ b/src/terminal/StringMap.zig @@ -6,9 +6,9 @@ const std = @import("std"); const build_options = @import("terminal_options"); const oni = @import("oniguruma"); const point = @import("point.zig"); +const PinMap = @import("formatter.zig").PinMap; const Selection = @import("Selection.zig"); const Screen = @import("Screen.zig"); -const Pin = @import("PageList.zig").Pin; const Allocator = std.mem.Allocator; // Retry budget for StringMap regex searches. @@ -18,11 +18,15 @@ const Allocator = std.mem.Allocator; const oni_search_retry_limit = 100_000; string: [:0]const u8, -map: []Pin, + +/// Mapping of string byte offsets to pins. See PinMap for the +/// storage details. +map: PinMap.Map, pub fn deinit(self: StringMap, alloc: Allocator) void { alloc.free(self.string); - alloc.free(self.map); + var map = self.map; + map.deinit(alloc); } /// Returns an iterator that yields the next match of the given regex. @@ -106,8 +110,8 @@ pub const Match = struct { pub fn selection(self: Match) Selection { const start_idx: usize = @intCast(self.region.starts()[0]); const end_idx: usize = @intCast(self.region.ends()[0] - 1); - const start_pt = self.map.map[self.offset + start_idx]; - const end_pt = self.map.map[self.offset + end_idx]; + const start_pt = self.map.map.get(self.offset + start_idx).?; + const end_pt = self.map.map.get(self.offset + end_idx).?; return .init(start_pt, end_pt, false); } }; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index d47fd474f..b157994f5 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -371,7 +371,10 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void { /// for handling escape sequences split across write boundaries), you /// must store and reuse the returned stream. pub fn vtStream(self: *Terminal) Stream { - return .initAlloc(self.gpa(), self.vtHandler()); + return Stream.init(.{ + .allocator = self.gpa(), + .handler = self.vtHandler(), + }); } /// This is the handler-side only for vtStream. @@ -487,9 +490,19 @@ pub fn printString(self: *Terminal, str: []const u8) !void { /// Print the previous printed character a repeated amount of times. pub fn printRepeat(self: *Terminal, count_req: usize) !void { - if (self.previous_char) |c| { - const count = @max(count_req, 1); - for (0..count) |_| try self.print(c); + const c = self.previous_char orelse return; + var remaining = @max(count_req, 1); + + // Print the repeated codepoint in slices so that eligible runs + // take the batched printSlice fast path. printSlice is semantically + // identical to calling print per codepoint: ineligible characters + // or terminal states (insert mode, grapheme clustering, hyperlinks, + // etc.) fall back to the per-codepoint print() path internally. + var buf: [4096]u32 = @splat(c); + while (remaining > 0) { + const n = @min(remaining, buf.len); + try self.printSlice(buf[0..n]); + remaining -= n; } } @@ -1301,28 +1314,24 @@ pub fn print(self: *Terminal, c: u21) !void { if (prev.cell.wide != .wide) break :narrow; prev.cell.wide = .narrow; - // Remove the wide spacer tail - const cell = self.screens.active.cursorCellLeft(prev.left - 1); - cell.wide = .narrow; - - // Back track the cursor so that we don't end up with - // an extra space after the character. Since xterm is - // not VS aware, it cannot be used as a reference for - // this behavior; but it does follow the principle of - // least surprise, and also matches the behavior that - // can be observed in Kitty, which is one of the only - // other VS aware terminals. - if (self.screens.active.cursor.x == right_limit - 1) { - // If we're already at the right edge, we stay - // here and set the pending wrap to false since - // when we pend a wrap, we only move our cursor once - // even for wide chars (tests verify). - self.screens.active.cursor.pending_wrap = false; - } else { - // Otherwise, move back. - self.screens.active.cursorLeft(1); + // Remove the wide spacer tail. The previous cell may be + // under the cursor, so locate the tail from the wide base + // rather than by subtracting from the cursor distance. + const prev_x = self.screens.active.cursor.x - prev.left; + if (prev_x < self.cols - 1) { + const cells: [*]Cell = @ptrCast(prev.cell); + cells[1].wide = .narrow; } + // Place the cursor one cell after the now-narrow base, + // clamped to the right edge. Usually this moves the cursor + // back from after the old tail, but saved cursor state or + // changed margins can leave it directly on the base. + self.screens.active.cursor.pending_wrap = false; + self.screens.active.cursorHorizontalAbsolute( + @min(prev_x + 1, right_limit - 1), + ); + break :narrow; }, @@ -1549,7 +1558,7 @@ fn printCell( // So integrity checks pass. We fix this up later so we don't // need to do this without safety checks. - if (comptime std.debug.runtime_safety) { + if (comptime build_options.slow_runtime_safety) { cell.wide = .narrow; } @@ -3258,9 +3267,14 @@ pub fn eraseLine( break :left .{ 0, x + 1 }; }, - // Note that it seems like complete should reset the soft-wrap - // state of the line but in xterm it does not. - .complete => .{ 0, self.cols }, + .complete => complete: { + // Xterm preserves this flag for EL2, but it also doesn't reflow + // rows when resizing. Since we do, the erased row must no longer + // continue onto the next row. + self.screens.active.cursorResetWrap(); + + break :complete .{ 0, self.cols }; + }, else => { log.err("unimplemented erase line mode: {}", .{mode}); @@ -3546,12 +3560,12 @@ pub fn setKittyGraphicsSizeLimit( self: *Terminal, alloc: Allocator, limit: usize, -) !void { +) void { if (comptime !build_options.kitty_graphics) return; var it = self.screens.all.iterator(); while (it.next()) |entry| { const screen: *Screen = entry.value.*; - try screen.kitty_images.setLimit(self.io(), alloc, screen, limit); + screen.kitty_images.setLimit(self.io(), alloc, screen, limit); } } @@ -3586,58 +3600,63 @@ pub fn printAttributes(self: *Terminal, buf: []u8) ![]const u8 { try writer.writeByte('0'); const pen = self.screens.active.cursor.style; - var attrs: [8]u8 = @splat(0); + var attrs: [9]u8 = @splat(0); var i: usize = 0; if (pen.flags.bold) { - attrs[i] = '1'; + attrs[i] = 1; i += 1; } if (pen.flags.faint) { - attrs[i] = '2'; + attrs[i] = 2; i += 1; } if (pen.flags.italic) { - attrs[i] = '3'; + attrs[i] = 3; i += 1; } if (pen.flags.underline != .none) { - attrs[i] = '4'; + attrs[i] = 4; + i += 1; + } + + if (pen.flags.overline) { + attrs[i] = 53; i += 1; } if (pen.flags.blink) { - attrs[i] = '5'; + attrs[i] = 5; i += 1; } if (pen.flags.inverse) { - attrs[i] = '7'; + attrs[i] = 7; i += 1; } if (pen.flags.invisible) { - attrs[i] = '8'; + attrs[i] = 8; i += 1; } if (pen.flags.strikethrough) { - attrs[i] = '9'; + attrs[i] = 9; i += 1; } - for (attrs[0..i]) |c| { - // Preserve underline styles. Kind of a hack to special case '4' + for (attrs[0..i]) |attr| { + // Preserve underline styles. Kind of a hack to special case 4 // here but its easier than changing how we do all attributes. - if (c == '4' and pen.flags.underline != .single) { + if (attr == 4 and pen.flags.underline != .single) { try writer.print(";4:{}", .{@intFromEnum(pen.flags.underline)}); continue; } - try writer.print(";{c}", .{c}); + try writer.print(";{}", .{attr}); } switch (pen.fg_color) { @@ -4851,6 +4870,29 @@ test "Terminal: zero-width character attaches to pending wrap cell" { try testing.expectEqualStrings("xå̲", str); } +test "Terminal: caps zero-width codepoints attached to one cell" { + var t = try init(testing.io, testing.allocator, .{ .cols = 2, .rows = 2 }); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, false); + try t.print('A'); + + const initial_capacity = t.screens.active.cursor.page_pin.node.capacity().grapheme_bytes; + for (0..pagepkg.grapheme_max_len * 4) |_| try t.print(0x0301); + + const list_cell = t.screens.active.pages.getCell(.{ + .screen = .{ .x = 0, .y = 0 }, + }).?; + try testing.expectEqual( + @as(usize, pagepkg.grapheme_max_len), + list_cell.node.page().lookupGrapheme(list_cell.cell).?.len, + ); + try testing.expectEqual( + initial_capacity, + list_cell.node.capacity().grapheme_bytes, + ); +} + // https://github.com/mitchellh/ghostty/issues/1400 test "Terminal: print single very long line" { var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); @@ -5733,6 +5775,68 @@ test "Terminal: VS15 to make narrow character with pending wrap" { } } +test "Terminal: VS15 narrows wide cell under cursor with wraparound disabled" { + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, true); + t.modes.set(.wraparound, false); + + // First create a wide cell spanning columns 4 and 5. + t.setCursorPos(1, 4); + try t.print(0x2614); + + // Make column 4 the right margin and put the cursor on the wide base. + // With wraparound disabled, grapheme lookup selects the cell under the + // cursor when it has content. + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(1, 4); + t.setCursorPos(1, 4); + try t.print(0xFE0E); + + try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + const base = t.screens.active.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?.cell; + try testing.expectEqual(Cell.Wide.narrow, base.wide); + try testing.expect(base.hasGrapheme()); + const tail = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?.cell; + try testing.expectEqual(Cell.Wide.narrow, tail.wide); +} + +test "Terminal: VS15 narrows wide cell under restored pending cursor" { + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, true); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(1, 4); + + // Save a pending-wrap cursor at column 4. + t.setCursorPos(1, 4); + try t.print('X'); + try testing.expect(t.screens.active.cursor.pending_wrap); + t.saveCursor(); + + // Widen the margin and replace that cell with a wide character. + t.setLeftAndRightMargin(1, 5); + t.setCursorPos(1, 4); + try t.print(0x2614); + + // Restoring also restores pending_wrap, so grapheme lookup selects the + // wide base under the cursor rather than its spacer tail. + t.restoreCursor(); + try testing.expect(t.screens.active.cursor.pending_wrap); + try t.print(0xFE0E); + + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + const base = t.screens.active.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?.cell; + try testing.expectEqual(Cell.Wide.narrow, base.wide); + try testing.expect(base.hasGrapheme()); + const tail = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?.cell; + try testing.expectEqual(Cell.Wide.narrow, tail.wide); +} + test "Terminal: VS16 to make wide character on next line" { var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 3 }); defer t.deinit(testing.allocator); @@ -13502,6 +13606,35 @@ test "Terminal: eraseLine complete preserves background sgr" { } } +test "Terminal: eraseLine complete resets wrap" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE123") |c| try t.print(c); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.row.wrap); + } + + t.setCursorPos(1, 1); + t.eraseLine(.complete, false); + + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(!list_cell.row.wrap); + } + try t.print('X'); + try t.resize(alloc, .{ .rows = 5, .cols = 10 }); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\n123", str); + } +} + test "Terminal: eraseLine complete protected attributes respected with iso" { const alloc = testing.allocator; const io_impl = testing.io; @@ -13929,11 +14062,12 @@ test "Terminal: printAttributes" { try t.setAttribute(.inverse); try t.setAttribute(.invisible); try t.setAttribute(.strikethrough); + try t.setAttribute(.overline); try t.setAttribute(.{ .direct_color_fg = .{ .r = 100, .g = 200, .b = 255 } }); try t.setAttribute(.{ .direct_color_bg = .{ .r = 101, .g = 102, .b = 103 } }); defer t.setAttribute(.unset) catch unreachable; const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0;1;2;3;4;5;7;8;9;38:2::100:200:255;48:2::101:102:103", buf); + try testing.expectEqualStrings("0;1;2;3;4;53;5;7;8;9;38:2::100:200:255;48:2::101:102:103", buf); } const Case = struct { diff --git a/src/terminal/apc.zig b/src/terminal/apc.zig index 121f906a0..0da13b3b3 100644 --- a/src/terminal/apc.zig +++ b/src/terminal/apc.zig @@ -13,6 +13,10 @@ const log = std.log.scoped(.terminal_apc); pub const Handler = struct { state: State = .inactive, + /// Maximum content bytes retained for unsupported APC identifiers. Zero + /// drops and ignores unknown APC values. + unknown_max_bytes: usize = 0, + /// Maximum bytes each APC protocol can buffer. This is to prevent /// malicious input from causing us to allocate too much memory. /// If you want to be lazy and set a single value for all protocols, @@ -23,8 +27,7 @@ pub const Handler = struct { }), /// Protocols recognized by this APC handler. When a protocol is absent, - /// matching APC sequences are ignored so callers see the same behavior as - /// an unsupported protocol: no command execution and no response. + /// matching APC sequences are ignored and are not reported as unknown. enabled: std.EnumSet(Protocol) = .initFull(), pub fn deinit(self: *Handler) void { @@ -50,51 +53,70 @@ pub const Handler = struct { // recognize it so there is no need to store the data in memory. .ignore => return, + // Unsupported APC content is retained only when enabled. + .unknown => |*unknown| unknown.append(&.{byte}), + // We identify the APC command by the first byte. .identify => |*id| id: { // Kitty graphics is detected immediately on the `G` byte, // since commands begin immediately after with no termination // character after the 'G'. - if (comptime build_options.kitty_graphics) { - if (id.len == 0 and - byte == 'G' and - self.enabled.contains(.kitty)) - { - self.state = .{ .kitty = .init( - alloc, - self.max_bytes.get(.kitty) orelse - Protocol.defaultMaxBytes(.kitty), - ) }; - break :id; + if (id.len == 0 and byte == 'G') { + if (comptime build_options.kitty_graphics) { + if (self.enabled.contains(.kitty)) { + self.state = .{ .kitty = .init( + alloc, + self.max_bytes.get(.kitty) orelse + Protocol.defaultMaxBytes(.kitty), + ) }; + } else { + self.state = .ignore; + } + } else { + self.state = .ignore; } + break :id; } // If we hit `;` then identify... if (byte == ';') { const str = id.buf[0..id.len]; - if (std.mem.eql(u8, str, "25a1") and - self.enabled.contains(.glyph)) - { - self.state = .{ .glyph = .init( - alloc, - self.max_bytes.get(.glyph) orelse - Protocol.defaultMaxBytes(.glyph), - ) }; + if (std.mem.eql(u8, str, glyph.identifier)) { + if (self.enabled.contains(.glyph)) { + self.state = .{ .glyph = .init( + alloc, + self.max_bytes.get(.glyph) orelse + Protocol.defaultMaxBytes(.glyph), + ) }; + } else { + self.state = .ignore; + } } else { - self.state = .ignore; + self.beginUnknown(alloc, str, &.{byte}); } break :id; } - // If we're out of space to buffer then we're done. + // If we're out of identification space, the identifier is + // unsupported. Preserve the buffered prefix before replacing + // the identify union state. if (id.len >= id.buf.len) { - self.state = .ignore; + self.beginUnknown(alloc, id.buf[0..id.len], &.{byte}); break :id; } + const expected_idx: usize = id.len; id.buf[id.len] = byte; id.len += 1; + + // Once the buffered input is no longer a prefix of a known + // protocol, it is an unsupported identifier. + if (self.unknown_max_bytes > 0 and + byte != glyph.identifier[expected_idx]) + { + self.beginUnknown(alloc, id.buf[0..id.len], &.{}); + } }, .kitty => |*p| if (comptime build_options.kitty_graphics) { @@ -113,6 +135,27 @@ pub const Handler = struct { } } + /// Transition from protocol identification to bounded unknown capture. + fn beginUnknown( + self: *Handler, + alloc: Allocator, + prefix: []const u8, + suffix: []const u8, + ) void { + const max_bytes = self.unknown_max_bytes; + if (max_bytes == 0) { + self.state = .ignore; + return; + } + + // Build the replacement before overwriting identify because prefix + // points into that union field. + var unknown: UnknownBuilder = .init(alloc, max_bytes); + unknown.append(prefix); + unknown.append(suffix); + self.state = .{ .unknown = unknown }; + } + /// Feed a slice of bytes to the handler. This is equivalent to /// calling feed for each byte in order, but protocol payload bytes /// are passed through in bulk so large payloads (e.g. Kitty graphics @@ -126,6 +169,12 @@ pub const Handler = struct { // We're ignoring this APC command; drop the whole slice. .ignore => return, + // We're capturing an unknown APC command, so store it. + .unknown => |*unknown| { + unknown.append(rem); + return; + }, + // Identification consumes at most a few bytes; step // through them one at a time until the state changes. .identify => { @@ -154,6 +203,8 @@ pub const Handler = struct { } } + /// Complete the current APC. The caller owns a returned result and must + /// call `Command.deinit` with the allocator used while feeding the APC. pub fn end(self: *Handler) ?Command { defer { self.state.deinit(); @@ -163,11 +214,12 @@ pub const Handler = struct { return switch (self.state) { .inactive => unreachable, .ignore, .identify => null, + .unknown => |*unknown| .{ .unknown = unknown.toOwned() }, .kitty => |*p| kitty: { if (comptime !build_options.kitty_graphics) unreachable; // Use the same allocator that was used to create the parser. - const alloc = p.arena.child_allocator; + const alloc = p.alloc; const command = p.complete(alloc) catch |err| { log.warn("kitty graphics protocol error: {}", .{err}); break :kitty null; @@ -207,7 +259,7 @@ pub const State = union(enum) { /// identify: struct { len: u3 = 0, - buf: [4]u8 = undefined, + buf: [glyph.identifier.len]u8 = undefined, }, /// Kitty graphics protocol @@ -219,9 +271,15 @@ pub const State = union(enum) { /// Glyph protocol glyph: glyph.CommandParser, + /// An unsupported APC retained for the optional unknown callback. + /// Keep this after recognized protocol states so their tag values and + /// generated dispatch stay stable when unknown capture is unused. + unknown: UnknownBuilder, + pub fn deinit(self: *State) void { switch (self.*) { .inactive, .ignore, .identify => {}, + .unknown => |*v| v.deinit(), .glyph => |*v| v.deinit(), .kitty => |*v| if (comptime build_options.kitty_graphics) v.deinit() @@ -231,6 +289,95 @@ pub const State = union(enum) { } }; +/// UnknownBuilder is responsible for accumulating bytes for an +/// unidentified APC command if unknown capture is enabled. +const UnknownBuilder = struct { + data: std.ArrayList(u8) = .empty, + alloc: Allocator, + max_bytes: usize, + truncated: bool = false, + + fn init(alloc: Allocator, max_bytes: usize) UnknownBuilder { + return .{ + .alloc = alloc, + .max_bytes = max_bytes, + }; + } + + fn deinit(self: *UnknownBuilder) void { + self.data.deinit(self.alloc); + self.data = .empty; + } + + // Append some bytes to the unknown capture. This flags as truncated + // if allocation fails or we reach our byte limit, therefore + // it can't fail. + fn append(self: *UnknownBuilder, bytes: []const u8) void { + if (bytes.len == 0) return; + const current = self.data.items.len; + + // Determine how many bytes we can store in this append and + // if it is less than our input, then we have to note we're + // truncating. + const retained = @min(bytes.len, self.max_bytes -| current); + if (retained < bytes.len) self.truncated = true; + + // If we require more bytes than our capacity allows then we + // need to grow. + const required = current + retained; + if (required > self.data.capacity) { + const capacity = @min( + self.max_bytes, + @max(required, @max(self.data.capacity *| 2, 1)), + ); + self.data.ensureTotalCapacityPrecise( + self.alloc, + capacity, + ) catch { + self.truncated = true; + return; + }; + } + + self.data.appendSliceAssumeCapacity(bytes[0..retained]); + } + + /// Convert the current capture state to an Unknown where allocator + /// ownership shifts to Unknown. Removes any accumulated unknown + /// capture in this struct. + /// + /// This can't fail because if there is an allocator issue we return + /// an empty truncate-flagged Unknown. + fn toOwned(self: *UnknownBuilder) Unknown { + // toOwnedSlice allows us to reuse data but this makes error + // handling a little simpler. + const content = self.data.toOwnedSlice(self.alloc) catch { + self.data.deinit(self.alloc); + self.data = .empty; + return .{ + .content = self.data.items, + .truncated = true, + }; + }; + + return .{ + .content = content, + .truncated = self.truncated, + }; + } +}; + +/// An unsupported APC returned by `Handler.end`. +pub const Unknown = struct { + content: []u8, + truncated: bool, + + pub fn deinit(self: *Unknown, alloc: Allocator) void { + if (self.content.len > 0) alloc.free(self.content); + self.* = undefined; + } +}; + /// Possible APC command types. pub const Protocol = enum { kitty, @@ -248,16 +395,28 @@ pub const Protocol = enum { .glyph => 1 * 1024 * 1024, }; } + + /// Return the largest default buffer limit across every APC protocol. + /// Consumers that must retain any unfinished APC can derive their limit + /// here instead of duplicating a particular protocol's current default. + pub fn maxDefaultBytes() usize { + var result: usize = 0; + for (std.enums.values(Protocol)) |protocol| { + result = @max(result, protocol.defaultMaxBytes()); + } + return result; + } }; -/// Possible APC commands. -pub const Command = union(Protocol) { +/// A recognized or unsupported APC command. +pub const Command = union(enum) { kitty: if (build_options.kitty_graphics) kitty_gfx.Command else void, glyph: glyph.Request, + unknown: Unknown, pub fn deinit(self: *Command, alloc: Allocator) void { switch (self.*) { @@ -267,6 +426,7 @@ pub const Command = union(Protocol) { unreachable, .glyph => |*v| v.deinit(alloc), + .unknown => |*v| v.deinit(alloc), } } }; @@ -281,6 +441,64 @@ test "unknown APC command" { try testing.expect(h.end() == null); } +test "capture unknown APC command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{ .unknown_max_bytes = 5 }; + defer h.deinit(); + h.start(); + h.feedSlice(alloc, "abcd;payload"); + + var result = h.end().?; + defer result.deinit(alloc); + const unknown = &result.unknown; + try testing.expectEqualStrings("abcd;", unknown.content); + try testing.expect(unknown.truncated); +} + +test "capture short unknown APC command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{ .unknown_max_bytes = 16 }; + defer h.deinit(); + h.start(); + h.feed(alloc, 'X'); + + var result = h.end().?; + const unknown = &result.unknown; + try testing.expectEqualStrings("X", unknown.content); + try testing.expect(!unknown.truncated); + result.deinit(alloc); + + h.unknown_max_bytes = 1; + h.start(); + h.feedSlice(alloc, "XYZ"); + result = h.end().?; + const truncated = &result.unknown; + try testing.expectEqualStrings("X", truncated.content); + try testing.expect(truncated.truncated); + result.deinit(alloc); +} + +test "disabled known APC protocol is not unknown" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{ .unknown_max_bytes = 64 }; + defer h.deinit(); + h.enable(.glyph, false); + h.start(); + h.feedSlice(alloc, "25a1;q;cp=E0A0"); + try testing.expect(h.end() == null); + + // An incomplete known protocol identifier is malformed, not unknown. + h.start(); + h.feedSlice(alloc, "25a"); + try testing.expect(h.end() == null); +} + test "garbage Kitty command" { if (comptime !build_options.kitty_graphics) return error.SkipZigTest; @@ -363,9 +581,9 @@ test "valid Kitty command" { const input = "Gf=24,s=10,v=20,hello=world"; for (input) |c| h.feed(alloc, c); - var cmd = h.end().?; - defer cmd.deinit(alloc); - try testing.expect(cmd == .kitty); + var result = h.end().?; + defer result.deinit(alloc); + try testing.expect(result == .kitty); } test "identify with unrecognized command" { @@ -425,10 +643,10 @@ test "valid glyph command" { h.start(); for ("25a1;q;cp=E0A0") |c| h.feed(alloc, c); - var cmd = h.end().?; - defer cmd.deinit(alloc); - try testing.expect(cmd == .glyph); - try testing.expect(cmd.glyph == .query); + var result = h.end().?; + defer result.deinit(alloc); + try testing.expect(result == .glyph); + try testing.expect(result.glyph == .query); } test "feedSlice valid Kitty command" { @@ -441,12 +659,12 @@ test "feedSlice valid Kitty command" { h.start(); h.feedSlice(alloc, "Gf=24,s=10,v=20;aGVsbG8="); - var cmd = h.end().?; - defer cmd.deinit(alloc); - try testing.expect(cmd == .kitty); + var result = h.end().?; + defer result.deinit(alloc); + try testing.expect(result == .kitty); // The payload is base64-decoded by the parser on completion. - try testing.expectEqualStrings("hello", cmd.kitty.data); + try testing.expectEqualStrings("hello", result.kitty.data); } test "feedSlice identify split across slices" { @@ -461,12 +679,12 @@ test "feedSlice identify split across slices" { h.feedSlice(alloc, "f=24,s=10,"); h.feedSlice(alloc, "v=20;aGVsbG8="); - var cmd = h.end().?; - defer cmd.deinit(alloc); - try testing.expect(cmd == .kitty); + var result = h.end().?; + defer result.deinit(alloc); + try testing.expect(result == .kitty); // The payload is base64-decoded by the parser on completion. - try testing.expectEqualStrings("hello", cmd.kitty.data); + try testing.expectEqualStrings("hello", result.kitty.data); } test "feedSlice unknown APC command is ignored" { @@ -489,10 +707,10 @@ test "feedSlice valid glyph command" { h.start(); h.feedSlice(alloc, "25a1;q;cp=E0A0"); - var cmd = h.end().?; - defer cmd.deinit(alloc); - try testing.expect(cmd == .glyph); - try testing.expect(cmd.glyph == .query); + var result = h.end().?; + defer result.deinit(alloc); + try testing.expect(result == .glyph); + try testing.expect(result.glyph == .query); } test "feedSlice kitty max bytes exceeded" { diff --git a/src/terminal/apc/glyph.zig b/src/terminal/apc/glyph.zig index 727e1ab4d..13454a3f3 100644 --- a/src/terminal/apc/glyph.zig +++ b/src/terminal/apc/glyph.zig @@ -150,6 +150,9 @@ const std = @import("std"); +/// APC identifier for the glyph protocol. +pub const identifier = "25a1"; + pub const request = @import("glyph/request.zig"); pub const response = @import("glyph/response.zig"); pub const execute = @import("glyph/execute.zig").execute; diff --git a/src/terminal/c/io.zig b/src/terminal/c/io.zig new file mode 100644 index 000000000..014935dc0 --- /dev/null +++ b/src/terminal/c/io.zig @@ -0,0 +1,594 @@ +//! C byte-stream callbacks and adapters for `std.Io`. +//! +//! The public callbacks deliberately expose a smaller contract than Zig's I/O +//! interfaces: reads are synchronous progress/EOF/error, and writes accept an +//! entire slice or fail. These adapters translate that contract while keeping +//! enough state to map failures back to distinct C result codes. + +const std = @import("std"); +const assert = std.debug.assert; +const lib = @import("../lib.zig"); + +/// C: GhosttyReaderFn +pub const ReaderFn = *const fn ( + userdata: ?*anyopaque, + buffer: [*]u8, + capacity: usize, + out_read: *usize, +) callconv(lib.calling_conv) bool; + +/// C: GhosttyWriterFn +pub const WriterFn = *const fn ( + userdata: ?*anyopaque, + data: [*]const u8, + len: usize, +) callconv(lib.calling_conv) bool; + +/// C: GhosttyReader +pub const Reader = extern struct { + read: ?ReaderFn = null, + userdata: ?*anyopaque = null, + + pub fn valid(self: Reader) bool { + return self.read != null; + } +}; + +/// C: GhosttyWriter +pub const Writer = extern struct { + write: ?WriterFn = null, + userdata: ?*anyopaque = null, + + pub fn valid(self: Writer) bool { + return self.write != null; + } +}; + +/// Adapts a `GhosttyReader` to `std.Io.Reader`. +/// +/// The adapter must have a stable address while `interface` is in use. Its +/// interface starts unbuffered so `init` can safely return it by value. A +/// one-byte buffer is attached lazily when an operation such as `peekByte` +/// requires buffering. This also prevents reads beyond a requested framing +/// boundary. +pub const ReaderAdapter = struct { + /// Copy of the C callback pair. The pointed-to userdata remains borrowed. + source: Reader, + + /// Zig-facing interface. Its vtable recovers this enclosing adapter with + /// `@fieldParentPtr`, which is why the adapter's address must remain stable. + interface: std.Io.Reader, + + /// Bytes successfully obtained from the callback. + offset: usize = 0, + + /// The callback returned false. This distinguishes an I/O failure from + /// the callback returning true with zero bytes at end-of-file. + callback_failed: bool = false, + + /// The callback was NULL, returned too many bytes, or overflowed offset. + invalid_read: bool = false, + + /// The callback returned true with zero bytes. EOF is permanent. + eof: bool = false, + + /// `std.Io.Reader` needs backing storage for `peekByte`. One byte is + /// intentional: it satisfies that operation without allowing the adapter + /// to pull a second byte across a snapshot checkpoint or FINISH boundary. + peek_buffer: [1]u8 = undefined, + + /// Scratch space used only when the destination writer has no writable + /// buffer of its own. 4 KiB amortizes callback overhead for streaming + /// operations while keeping every adapter's fixed allocation modest. The + /// caller's `limit` always truncates this slice, so the size cannot cause + /// framing read-ahead. + transfer_buffer: [4096]u8 = undefined, + + pub fn init(source: Reader) ReaderAdapter { + return .{ + .source = source, + .interface = .{ + .vtable = &.{ + .stream = stream, + .rebase = rebase, + }, + // `rebase` attaches peek_buffer after the adapter reaches its + // stable destination address. + .buffer = &.{}, + .seek = 0, + .end = 0, + }, + }; + } + + pub fn reader(self: *ReaderAdapter) *std.Io.Reader { + // Calling this only after the adapter reaches its final address makes + // the parent-pointer recovery in `stream` and `rebase` valid. + return &self.interface; + } + + /// Perform exactly one C callback invocation into caller-selected storage. + /// This is the single place that interprets the public callback contract. + fn readInto(self: *ReaderAdapter, destination: []u8) std.Io.Reader.Error!usize { + assert(destination.len > 0); + + // Failure and EOF are sticky. In particular, a callback that reports + // EOF is never polled again because the C API defines it as permanent. + if (self.callback_failed or self.invalid_read) return error.ReadFailed; + if (self.eof) return error.EndOfStream; + + // A missing callback is an invalid C value rather than external I/O + // failure, so record it separately for the public result mapper. + const read_fn = self.source.read orelse { + self.invalid_read = true; + return error.ReadFailed; + }; + + // Initialize this ourselves: a callback returning false is not + // required to initialize out_read. + var read_len: usize = 0; + if (!read_fn( + self.source.userdata, + destination.ptr, + destination.len, + &read_len, + )) { + self.callback_failed = true; + return error.ReadFailed; + } + + // Treat a callback that violates the capacity contract as an invalid + // reader without constructing an out-of-bounds slice. + if (read_len > destination.len) { + self.invalid_read = true; + return error.ReadFailed; + } + // Successful zero-length reads are the C representation of EOF. + if (read_len == 0) { + self.eof = true; + return error.EndOfStream; + } + + // Offset counts only bytes actually supplied by successful callbacks. + // Saturating or wrapping would make SOURCE_OFFSET untrustworthy. + self.offset = std.math.add(usize, self.offset, read_len) catch { + self.invalid_read = true; + return error.ReadFailed; + }; + return read_len; + } + + /// `stream` is the fundamental read primitive in `std.Io.Reader`'s + /// vtable. It moves at most `limit` bytes from the C source to the Zig + /// writer and reports the number transferred. Higher-level operations + /// such as `readSliceAll` and `peekByte` are implemented in terms of it. + fn stream( + reader_: *std.Io.Reader, + writer: *std.Io.Writer, + limit: std.Io.Limit, + ) std.Io.Reader.StreamError!usize { + // A zero limit is a successful no-op and must not invoke user code. + if (limit == .nothing) return 0; + + // The std.Io callback receives only the embedded interface pointer. + // Recover the adapter to reach the C callback and accounting flags. + const self: *ReaderAdapter = @alignCast(@fieldParentPtr( + "interface", + reader_, + )); + + // Use the destination's storage directly when possible. This is the + // normal path for readSlice operations and for filling peek_buffer. + const direct = limit.slice(writer.unusedCapacitySlice()); + if (direct.len > 0) { + const read_len = try self.readInto(direct); + // `readInto` initialized these bytes outside the Writer API, so + // explicitly publish them to the destination. + writer.advance(read_len); + return read_len; + } + + // An unbuffered Writer has no direct destination storage. Read into + // bounded adapter storage and then let its drain implementation take + // ownership of the complete slice. + const transfer = limit.slice(&self.transfer_buffer); + assert(transfer.len > 0); + const read_len = try self.readInto(transfer); + // writeAll either transfers this complete callback result or reports + // failure; returning a partial count would violate stream's contract. + try writer.writeAll(transfer[0..read_len]); + return read_len; + } + + /// Give buffered reader operations contiguous storage on first demand. + /// Initialization cannot point at `peek_buffer` because `init` returns the + /// adapter by value and its final address is not known until afterward. + fn rebase( + reader_: *std.Io.Reader, + capacity: usize, + ) std.Io.Reader.RebaseError!void { + const self: *ReaderAdapter = @alignCast(@fieldParentPtr( + "interface", + reader_, + )); + + if (reader_.buffer.len == 0) { + // No operation could have buffered bytes before storage existed. + assert(reader_.seek == 0); + assert(reader_.end == 0); + reader_.buffer = &self.peek_buffer; + } + + // Like other std.Io.Reader implementations, contiguous peek capacity + // is limited by the reader's declared buffer capacity. + assert(capacity <= reader_.buffer.len); + // Avoid the default memmove when the unread tail already has room. + if (reader_.buffer.len - reader_.seek >= capacity) return; + return std.Io.Reader.defaultRebase(reader_, capacity); + } +}; + +/// Adapts a `GhosttyWriter` to an unbuffered `std.Io.Writer`. +/// +/// Keeping the Zig writer unbuffered ensures success means the C callback has +/// already accepted every byte; there is no hidden flush step at API return. +pub const WriterAdapter = struct { + /// Copy of the C callback pair. The pointed-to userdata remains borrowed. + destination: Writer, + + /// Zig-facing interface whose drain vtable points back to this adapter. + interface: std.Io.Writer, + + /// Bytes accepted by successful callback invocations. + offset: usize = 0, + + /// The callback returned false. + callback_failed: bool = false, + + /// The callback was NULL or offset accounting overflowed. + invalid_write: bool = false, + + pub fn init(destination: Writer) WriterAdapter { + return .{ + .destination = destination, + .interface = .{ + // With no buffer, every Zig write reaches `drain` immediately. + .vtable = &.{ .drain = drain }, + .buffer = &.{}, + .end = 0, + }, + }; + } + + pub fn writer(self: *WriterAdapter) *std.Io.Writer { + // As with ReaderAdapter, the returned interface borrows this stable + // enclosing address for parent-pointer recovery in the vtable. + return &self.interface; + } + + /// Submit one ordinary slice through the all-or-nothing C write callback. + fn writeAll(self: *WriterAdapter, data: []const u8) std.Io.Writer.Error!void { + // Empty writes carry no information and should not call foreign code. + if (data.len == 0) return; + + // Make failures sticky so a higher-level Zig retry cannot produce a + // misleading second callback after the output is already partial. + if (self.callback_failed or self.invalid_write) return error.WriteFailed; + + // Missing callbacks are invalid arguments; false callback returns are + // external I/O errors. Preserve that distinction for the C wrapper. + const write_fn = self.destination.write orelse { + self.invalid_write = true; + return error.WriteFailed; + }; + if (!write_fn(self.destination.userdata, data.ptr, data.len)) { + self.callback_failed = true; + return error.WriteFailed; + } + + // Count bytes only after the callback accepts the complete slice. + self.offset = std.math.add(usize, self.offset, data.len) catch { + self.invalid_write = true; + return error.WriteFailed; + }; + } + + /// Implement the sole primitive required by an unbuffered std.Io.Writer. + /// + /// Zig represents a vector write as ordinary slices followed by the last + /// slice repeated `splat` times. The C callback has no vector form, so this + /// method expands that representation into ordered all-or-nothing calls + /// and returns the total logical byte count consumed. + fn drain( + writer_: *std.Io.Writer, + data: []const []const u8, + splat: usize, + ) std.Io.Writer.Error!usize { + assert(data.len > 0); // The final element is always the splat pattern. + assert(writer_.end == 0); // This adapter intentionally has no buffer. + + // The vtable receives the embedded Writer rather than our adapter. + const self: *WriterAdapter = @alignCast(@fieldParentPtr( + "interface", + writer_, + )); + + var consumed: usize = 0; + // Every element except the last is written exactly once. + for (data[0 .. data.len - 1]) |slice| { + try self.writeAll(slice); + consumed = std.math.add(usize, consumed, slice.len) catch { + self.invalid_write = true; + return error.WriteFailed; + }; + } + + // std.Io uses the final element as a compact repeated pattern. A + // splat of zero means the final element is not part of this write. + const pattern = data[data.len - 1]; + for (0..splat) |_| { + try self.writeAll(pattern); + consumed = std.math.add(usize, consumed, pattern.len) catch { + self.invalid_write = true; + return error.WriteFailed; + }; + } + return consumed; + } +}; + +test "C reader and writer layouts keep callback first" { + try std.testing.expectEqual(@as(usize, 0), @offsetOf(Reader, "read")); + try std.testing.expectEqual(@sizeOf(?ReaderFn), @offsetOf(Reader, "userdata")); + try std.testing.expectEqual(@sizeOf(?ReaderFn) + @sizeOf(?*anyopaque), @sizeOf(Reader)); + + try std.testing.expectEqual(@as(usize, 0), @offsetOf(Writer, "write")); + try std.testing.expectEqual(@sizeOf(?WriterFn), @offsetOf(Writer, "userdata")); + try std.testing.expectEqual(@sizeOf(?WriterFn) + @sizeOf(?*anyopaque), @sizeOf(Writer)); +} + +test "ReaderAdapter supports short reads and permanent EOF" { + const Context = struct { + data: []const u8, + offset: usize = 0, + calls: usize = 0, + + fn read( + userdata: ?*anyopaque, + buffer: [*]u8, + capacity: usize, + out_read: *usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + self.calls += 1; + const remaining = self.data[self.offset..]; + const len = @min(remaining.len, capacity, 2); + @memcpy(buffer[0..len], remaining[0..len]); + self.offset += len; + out_read.* = len; + return true; + } + }; + + var context: Context = .{ .data = "abcdef" }; + var adapter: ReaderAdapter = .init(.{ + .read = &Context.read, + .userdata = &context, + }); + + var actual: [6]u8 = undefined; + try adapter.interface.readSliceAll(&actual); + try std.testing.expectEqualStrings("abcdef", &actual); + try std.testing.expectEqual(@as(usize, 6), adapter.offset); + try std.testing.expect(!adapter.eof); + + try std.testing.expectError(error.EndOfStream, adapter.interface.takeByte()); + try std.testing.expect(adapter.eof); + try std.testing.expect(!adapter.callback_failed); + + const calls_at_eof = context.calls; + try std.testing.expectError(error.EndOfStream, adapter.interface.takeByte()); + try std.testing.expectEqual(calls_at_eof, context.calls); +} + +test "ReaderAdapter peek reads only the requested byte" { + const Context = struct { + data: []const u8, + offset: usize = 0, + last_capacity: usize = 0, + + fn read( + userdata: ?*anyopaque, + buffer: [*]u8, + capacity: usize, + out_read: *usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + self.last_capacity = capacity; + const remaining = self.data[self.offset..]; + const len = @min(remaining.len, capacity); + @memcpy(buffer[0..len], remaining[0..len]); + self.offset += len; + out_read.* = len; + return true; + } + }; + + var context: Context = .{ .data = "ab" }; + var adapter: ReaderAdapter = .init(.{ + .read = &Context.read, + .userdata = &context, + }); + + try std.testing.expectEqual(@as(u8, 'a'), try adapter.interface.peekByte()); + try std.testing.expectEqual(@as(usize, 1), context.last_capacity); + try std.testing.expectEqual(@as(usize, 1), adapter.offset); + try std.testing.expectEqual(@as(u8, 'a'), try adapter.interface.takeByte()); + try std.testing.expectEqual(@as(usize, 1), adapter.offset); +} + +test "ReaderAdapter distinguishes callback failure and invalid length" { + const Failing = struct { + fn read( + _: ?*anyopaque, + _: [*]u8, + _: usize, + _: *usize, + ) callconv(lib.calling_conv) bool { + return false; + } + }; + var failing: ReaderAdapter = .init(.{ .read = &Failing.read }); + try std.testing.expectError(error.ReadFailed, failing.interface.takeByte()); + try std.testing.expect(failing.callback_failed); + try std.testing.expect(!failing.eof); + try std.testing.expect(!failing.invalid_read); + + const Oversized = struct { + fn read( + _: ?*anyopaque, + _: [*]u8, + capacity: usize, + out_read: *usize, + ) callconv(lib.calling_conv) bool { + out_read.* = capacity + 1; + return true; + } + }; + var oversized: ReaderAdapter = .init(.{ .read = &Oversized.read }); + try std.testing.expectError(error.ReadFailed, oversized.interface.takeByte()); + try std.testing.expect(!oversized.callback_failed); + try std.testing.expect(!oversized.eof); + try std.testing.expect(oversized.invalid_read); +} + +test "ReaderAdapter rejects a null callback" { + var adapter: ReaderAdapter = .init(.{}); + try std.testing.expectError(error.ReadFailed, adapter.interface.takeByte()); + try std.testing.expect(adapter.invalid_read); + try std.testing.expect(!adapter.callback_failed); +} + +test "WriterAdapter writes vectors and splats" { + const Context = struct { + data: [32]u8 = undefined, + len: usize = 0, + + fn write( + userdata: ?*anyopaque, + data: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + @memcpy(self.data[self.len..][0..len], data[0..len]); + self.len += len; + return true; + } + }; + + var context: Context = .{}; + var adapter: WriterAdapter = .init(.{ + .write = &Context.write, + .userdata = &context, + }); + + var vectors: [2][]const u8 = .{ "ab", "cd" }; + try adapter.interface.writeVecAll(&vectors); + var repeated: [1][]const u8 = .{"!"}; + try adapter.interface.writeSplatAll(&repeated, 2); + + try std.testing.expectEqualStrings("abcd!!", context.data[0..context.len]); + try std.testing.expectEqual(@as(usize, 6), adapter.offset); + try std.testing.expect(!adapter.callback_failed); +} + +test "WriterAdapter makes callback failure sticky" { + const Context = struct { + calls: usize = 0, + + fn write( + userdata: ?*anyopaque, + _: [*]const u8, + _: usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + self.calls += 1; + return false; + } + }; + + var context: Context = .{}; + var adapter: WriterAdapter = .init(.{ + .write = &Context.write, + .userdata = &context, + }); + try std.testing.expectError(error.WriteFailed, adapter.interface.writeAll("x")); + try std.testing.expect(adapter.callback_failed); + try std.testing.expectEqual(@as(usize, 0), adapter.offset); + + try std.testing.expectError(error.WriteFailed, adapter.interface.writeAll("y")); + try std.testing.expectEqual(@as(usize, 1), context.calls); +} + +test "WriterAdapter rejects a null callback" { + var adapter: WriterAdapter = .init(.{}); + try std.testing.expectError(error.WriteFailed, adapter.interface.writeAll("x")); + try std.testing.expect(adapter.invalid_write); + try std.testing.expect(!adapter.callback_failed); +} + +test "ReaderAdapter streams into an unbuffered WriterAdapter" { + const ReadContext = struct { + data: []const u8, + offset: usize = 0, + + fn read( + userdata: ?*anyopaque, + buffer: [*]u8, + capacity: usize, + out_read: *usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + const remaining = self.data[self.offset..]; + const len = @min(remaining.len, capacity); + @memcpy(buffer[0..len], remaining[0..len]); + self.offset += len; + out_read.* = len; + return true; + } + }; + const WriteContext = struct { + data: [8]u8 = undefined, + len: usize = 0, + + fn write( + userdata: ?*anyopaque, + data: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + @memcpy(self.data[self.len..][0..len], data[0..len]); + self.len += len; + return true; + } + }; + + var read_context: ReadContext = .{ .data = "stream" }; + var reader: ReaderAdapter = .init(.{ + .read = &ReadContext.read, + .userdata = &read_context, + }); + var write_context: WriteContext = .{}; + var writer: WriterAdapter = .init(.{ + .write = &WriteContext.write, + .userdata = &write_context, + }); + + try reader.interface.streamExact(&writer.interface, "stream".len); + try std.testing.expectEqualStrings( + "stream", + write_context.data[0..write_context.len], + ); + try std.testing.expectEqual(@as(usize, "stream".len), reader.offset); + try std.testing.expectEqual(@as(usize, "stream".len), writer.offset); +} diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index 310b79cff..396b463fb 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -261,8 +261,8 @@ fn imageGetTyped( .height => out.* = image.height, .format => out.* = image.format, .compression => out.* = image.compression, - .data_ptr => out.* = image.data.ptr, - .data_len => out.* = image.data.len, + .data_ptr => out.* = (image.data.bytes() orelse return .no_value).ptr, + .data_len => out.* = image.data.len(), .generation => out.* = image.generation, } @@ -595,9 +595,9 @@ pub fn placement_render_info( /// the placement's origin has scrolled above the top of the viewport. /// /// A placement is considered not visible if it is a virtual (unicode -/// placeholder) placement, or if it is fully off-screen (its bottom -/// edge is above the viewport or its top edge is at or below the -/// viewport's last row). +/// placeholder) placement, its tracked content has been pruned, or it is +/// fully off-screen (its bottom edge is above the viewport or its top edge is +/// at or below the viewport's last row). fn computeViewportPos( p: *const kitty_storage.ImageStorage.Placement, image: *const Image, @@ -609,6 +609,7 @@ fn computeViewportPos( .pin => |pin| pin, .virtual => return .{ .col = 0, .row = 0, .visible = false }, }; + if (pin.garbage) return .{ .col = 0, .row = 0, .visible = false }; // Convert both the placement's pin and the viewport's top-left // corner to screen-absolute coordinates so we can subtract them @@ -631,9 +632,8 @@ fn computeViewportPos( // is above the viewport, or its top edge is at or below the // viewport's last row. const grid_size = p.gridSize(image.*, t); - const rows_i32: i32 = @intCast(grid_size.rows); - const term_rows: i32 = @intCast(t.rows); - const visible = vp_row + rows_i32 > 0 and vp_row < term_rows; + const bottom_row = @as(i64, vp_row) + @as(i64, grid_size.rows); + const visible = bottom_row > 0 and vp_row < @as(i32, t.rows); return .{ .col = vp_col, .row = vp_row, .visible = visible }; } @@ -990,6 +990,57 @@ test "image_get_handle and image_get with transmitted image" { try testing.expect(data_len > 0); } +test "image_get exposes pending metadata without a data pointer" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer terminal_c.free(t); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + const alloc = lib.alloc.default(&lib.alloc.test_allocator); + const pending = try graphics.addPendingImage(testing.io, alloc, terminal_c.zigTerminal(t).?.screens.active, .{ + .id = 42, + .number = 7, + .width = 1, + .height = 4, + .format = .rgb, + .data = .{ .pending = 12 }, + }); + + const img = image_get_handle(graphics, 42); + try testing.expect(img != null); + + var number: u32 = 0; + try testing.expectEqual(Result.success, image_get(img, .number, @ptrCast(&number))); + try testing.expectEqual(@as(u32, 7), number); + + var data_len: usize = 0; + try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&data_len))); + try testing.expectEqual(@as(usize, 12), data_len); + + var data_ptr: [*]const u8 = undefined; + try testing.expectEqual(Result.no_value, image_get(img, .data_ptr, @ptrCast(&data_ptr))); + + const pixels = try alloc.dupe(u8, "*" ** 12); + try testing.expect(pending.complete(graphics, testing.io, pixels)); + const completed_img = image_get_handle(graphics, 42); + try testing.expect(completed_img != null); + try testing.expectEqual(Result.success, image_get(completed_img, .data_ptr, @ptrCast(&data_ptr))); + try testing.expectEqualSlices(u8, pixels, data_ptr[0..data_len]); +} + test "placement_rect with transmit and display" { if (comptime !build_options.kitty_graphics) return error.SkipZigTest; @@ -1571,6 +1622,53 @@ test "placement_render_info returns all fields" { try testing.expectEqual(0, ri.source_y); try testing.expectEqual(1, ri.source_width); try testing.expectEqual(2, ri.source_height); + + const entry = iter.?.entry.?; + const pin = switch (entry.value_ptr.location) { + .pin => |pin| pin, + .virtual => unreachable, + }; + pin.garbage = true; + + ri = .{}; + try testing.expectEqual(Result.success, placement_render_info(iter, img, t, &ri)); + try testing.expect(!ri.viewport_visible); + + var rect: selection_c.CSelection = undefined; + try testing.expectEqual(Result.no_value, placement_rect(iter, img, t, &rect)); +} + +test "placement_render_info handles maximum grid dimensions" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=1,r=4294967295,C=1;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var ri: PlacementRenderInfo = .{}; + try testing.expectEqual(Result.success, placement_render_info(iter, img, t, &ri)); + try testing.expect(ri.viewport_visible); + try testing.expectEqual(std.math.maxInt(u32), ri.grid_rows); } test "placement_render_info off-screen sets viewport_visible false" { diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 37dc57684..3615984cc 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -10,6 +10,7 @@ pub const focus = @import("focus.zig"); pub const formatter = @import("formatter.zig"); pub const grid_ref = @import("grid_ref.zig"); pub const grid_ref_tracked = @import("grid_ref_tracked.zig"); +pub const io = @import("io.zig"); pub const kitty_graphics = @import("kitty_graphics.zig"); pub const kitty_graphics_get = kitty_graphics.get; pub const kitty_graphics_image = kitty_graphics.image_get_handle; @@ -41,6 +42,7 @@ pub const paste = @import("paste.zig"); pub const row = @import("row.zig"); pub const sgr = @import("sgr.zig"); pub const size_report = @import("size_report.zig"); +pub const snapshot = @import("snapshot.zig"); pub const style = @import("style.zig"); pub const sys = @import("sys.zig"); pub const terminal = @import("terminal.zig"); @@ -182,13 +184,15 @@ pub const terminal_reset = terminal.reset; pub const terminal_resize = terminal.resize; pub const terminal_set = terminal.set; pub const terminal_vt_write = terminal.vt_write; +pub const terminal_vt_write_until_ground = terminal.vt_write_until_ground; pub const terminal_scroll_viewport = terminal.scroll_viewport; pub const terminal_compression_activity = terminal.compression_activity; pub const terminal_compress = terminal.compress; -pub const terminal_mode_get = terminal.mode_get; -pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; pub const terminal_get_multi = terminal.get_multi; +pub const terminal_continuation_write = terminal.continuation_write; +pub const terminal_continuation_buf = terminal.continuation_buf; +pub const terminal_continuation_alloc = terminal.continuation_alloc; pub const terminal_select_word = selection.word; pub const terminal_select_word_between = selection.word_between; pub const terminal_select_line = selection.line; @@ -214,6 +218,19 @@ pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; +pub const snapshot_encode = snapshot.encode; +pub const snapshot_encode_buf = snapshot.encode_buf; +pub const snapshot_encode_alloc = snapshot.encode_alloc; +pub const snapshot_decoder_new = snapshot.decoder_new; +pub const snapshot_decoder_new_buf = snapshot.decoder_new_buf; +pub const snapshot_decoder_free = snapshot.decoder_free; +pub const snapshot_decoder_set = snapshot.decoder_set; +pub const snapshot_decoder_get = snapshot.decoder_get; +pub const snapshot_decoder_get_multi = snapshot.decoder_get_multi; +pub const snapshot_decoder_ready = snapshot.decoder_ready; +pub const snapshot_decoder_next = snapshot.decoder_next; +pub const snapshot_decoder_decode = snapshot.decoder_decode; + pub const type_json = types.get_json; pub const unicode_codepoint_width = unicode.codepoint_width; @@ -238,6 +255,7 @@ test { _ = color_scheme; _ = grid_ref; _ = grid_ref_tracked; + _ = io; _ = kitty_graphics; _ = row; _ = focus; @@ -254,6 +272,7 @@ test { _ = paste; _ = sgr; _ = size_report; + _ = snapshot; _ = style; _ = sys; _ = terminal; diff --git a/src/terminal/c/result.zig b/src/terminal/c/result.zig index 328663285..a127d4386 100644 --- a/src/terminal/c/result.zig +++ b/src/terminal/c/result.zig @@ -5,4 +5,6 @@ pub const Result = enum(c_int) { invalid_value = -2, out_of_space = -3, no_value = -4, + io_error = -5, + limit_exceeded = -6, }; diff --git a/src/terminal/c/snapshot.zig b/src/terminal/c/snapshot.zig new file mode 100644 index 000000000..45ac8a1e3 --- /dev/null +++ b/src/terminal/c/snapshot.zig @@ -0,0 +1,1226 @@ +//! C ABI wrappers for snapshot encoding and incremental restoration. + +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const io_c = @import("io.zig"); +const Result = @import("result.zig").Result; +const snapshot_core = @import("../snapshot/main.zig"); +const apc = @import("../apc.zig"); + +/// Snapshot readers accept the largest default APC payload. Deriving this from +/// the APC protocols keeps snapshot validation in sync as protocols change. +/// This remains independent of opt-in terminal-side continuation tracking. +const default_max_continuation_bytes = apc.Protocol.maxDefaultBytes(); +const terminal_c = @import("terminal.zig"); + +/// C: GhosttySnapshotDecoderOption. +/// +/// Options configure validation policy before snapshot decoding begins. +pub const DecoderOption = enum(c_int) { + max_continuation_bytes = 0, + _, +}; + +/// C: GhosttySnapshotDecoderData. +/// +/// Data keys select typed values exposed by the decoder getter APIs. +pub const DecoderData = enum(c_int) { + invalid = 0, + max_continuation_bytes = 1, + source_offset = 2, + history_rows_primary = 3, + history_rows_alternate = 4, + progress_screen = 5, + progress_rows = 6, + progress_remaining = 7, + _, + + /// Return the Zig type stored through the C API output pointer for a key. + fn OutType(comptime self: DecoderData) type { + return switch (self) { + .invalid => void, + .max_continuation_bytes, .source_offset => usize, + .history_rows_primary, .history_rows_alternate => u64, + .progress_screen => terminal_c.TerminalScreen, + .progress_rows => usize, + .progress_remaining => u32, + _ => void, + }; + } +}; + +/// Owns the source adapter and all state needed by an opaque C decoder handle. +const DecoderWrapper = struct { + /// A callback or borrowed-buffer source with uniform reader access. + const Source = union(enum) { + callback: io_c.ReaderAdapter, + buffer: std.Io.Reader, + + /// Return the stable `std.Io.Reader` used by the snapshot decoder. + fn reader(self: *Source) *std.Io.Reader { + return switch (self.*) { + .callback => |*value| &value.interface, + .buffer => |*value| value, + }; + } + + /// Return the number of source bytes consumed without reading ahead. + fn offset(self: *const Source) usize { + return switch (self.*) { + .callback => |*value| value.offset, + .buffer => |*value| value.seek, + }; + } + }; + + /// READY metadata that remains queryable through FINISH or a later error. + const Metadata = struct { + history_rows: std.EnumMap(terminal_c.TerminalScreen, u64), + }; + + /// Decoder lifecycle and the data available in each phase. + const State = union(enum) { + configuring, + + history: struct { + terminal: terminal_c.Terminal, + metadata: Metadata, + progress: ?snapshot_core.Decoder.Progress = null, + }, + + finished: Metadata, + + /// READY failures have no metadata. Failures from `next` retain the + /// READY metadata even though decoding cannot continue. + failed: ?Metadata, + }; + + alloc: std.mem.Allocator, + source: Source, + decoder: snapshot_core.Decoder, + state: State, + max_continuation_bytes: usize, +}; + +/// C: GhosttySnapshotDecoder, an opaque nullable decoder handle. +pub const Decoder = ?*DecoderWrapper; + +/// Create a decoder backed by a synchronous C reader callback. +pub fn decoder_new( + alloc_: ?*const CAllocator, + out_: ?*Decoder, + reader: io_c.Reader, +) callconv(lib.calling_conv) Result { + // Validate C-owned output and callback pointers before allocating anything. + const out = out_ orelse return .invalid_value; + out.* = null; + if (reader.read == null) return .invalid_value; + + // Store the adapter inline so its std.Io.Reader has a stable address. + return decoderNewSource(alloc_, out, .{ + .callback = io_c.ReaderAdapter.init(reader), + }); +} + +/// Create a decoder backed by immutable, caller-owned bytes. +pub fn decoder_new_buf( + alloc_: ?*const CAllocator, + out_: ?*Decoder, + ptr: ?[*]const u8, + len: usize, +) callconv(lib.calling_conv) Result { + // Normalize the C pointer and length into a borrowed Zig slice. + const out = out_ orelse return .invalid_value; + out.* = null; + const bytes: []const u8 = if (ptr) |value| + value[0..len] + else if (len == 0) + &.{} + else + return .invalid_value; + + // The fixed reader records its current seek position as the source offset. + return decoderNewSource(alloc_, out, .{ + .buffer = .fixed(bytes), + }); +} + +/// Allocate and initialize a decoder around either supported source type. +fn decoderNewSource( + alloc_: ?*const CAllocator, + out: *Decoder, + source: DecoderWrapper.Source, +) Result { + // Allocate first so the source and its embedded reader never move again. + const alloc = lib.alloc.default(alloc_); + const wrapper = alloc.create(DecoderWrapper) catch return .out_of_memory; + errdefer alloc.destroy(wrapper); + + // Initialize the core decoder only after its reader has a stable address. + wrapper.* = undefined; + wrapper.alloc = alloc; + wrapper.source = source; + wrapper.state = .configuring; + wrapper.max_continuation_bytes = default_max_continuation_bytes; + wrapper.decoder = .init(wrapper.source.reader()); + out.* = wrapper; + return .success; +} + +/// Destroy a decoder without taking ownership of any returned terminal. +pub fn decoder_free(decoder_: Decoder) callconv(lib.calling_conv) void { + const decoder = decoder_ orelse return; + const alloc = decoder.alloc; + alloc.destroy(decoder); +} + +/// Set a decoder option while the decoder is still configuring. +pub fn decoder_set( + decoder_: Decoder, + option: DecoderOption, + value_: ?*const anyopaque, +) callconv(lib.calling_conv) Result { + // Options freeze as soon as any decoding attempt starts. + const decoder = decoder_ orelse return .invalid_value; + switch (decoder.state) { + .configuring => {}, + else => return .invalid_value, + } + + // Interpret the erased C pointer according to the selected option. + const value = value_ orelse return .invalid_value; + switch (option) { + .max_continuation_bytes => decoder.max_continuation_bytes = + @as(*const usize, @ptrCast(@alignCast(value))).*, + _ => return .invalid_value, + } + return .success; +} + +/// Read one typed decoder value through the type-erased C API. +pub fn decoder_get( + decoder_: Decoder, + data: DecoderData, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + // Enumerate known keys so each branch recovers the correct output type. + return switch (data) { + inline .invalid, + .max_continuation_bytes, + .source_offset, + .history_rows_primary, + .history_rows_alternate, + .progress_screen, + .progress_rows, + .progress_remaining, + => |comptime_data| decoderGetTyped( + decoder_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + _ => .invalid_value, + }; +} + +/// Implement a getter after the key has selected its concrete output type. +fn decoderGetTyped( + decoder_: Decoder, + comptime data: DecoderData, + out: *data.OutType(), +) Result { + const decoder = decoder_ orelse return .invalid_value; + switch (data) { + // Decoder-wide configuration and source state. + .invalid => return .invalid_value, + .max_continuation_bytes => { + if (decoderFailed(decoder)) return .no_value; + out.* = decoder.max_continuation_bytes; + }, + .source_offset => { + if (decoderFailed(decoder)) return .no_value; + out.* = decoder.source.offset(); + }, + + // READY metadata remains available after history decoding completes. + .history_rows_primary => { + const metadata = decoderMetadata(decoder) orelse return .no_value; + out.* = metadata.history_rows.get(.primary) orelse return .no_value; + }, + .history_rows_alternate => { + const metadata = decoderMetadata(decoder) orelse return .no_value; + out.* = metadata.history_rows.get(.alternate) orelse + return .no_value; + }, + + // Progress describes only the most recent successful `next` call. + .progress_screen => { + const progress = decoderProgress(decoder) orelse return .no_value; + out.* = progress.key; + }, + .progress_rows => { + const progress = decoderProgress(decoder) orelse return .no_value; + out.* = progress.rows; + }, + .progress_remaining => { + const progress = decoderProgress(decoder) orelse return .no_value; + out.* = progress.remaining; + }, + else => return .invalid_value, + } + return .success; +} + +/// Return whether a consuming operation has permanently failed. +fn decoderFailed(decoder: *const DecoderWrapper) bool { + return switch (decoder.state) { + .failed => true, + else => false, + }; +} + +/// Return READY metadata in every state where it remains meaningful. +fn decoderMetadata( + decoder: *const DecoderWrapper, +) ?DecoderWrapper.Metadata { + return switch (decoder.state) { + .history => |value| value.metadata, + .finished => |value| value, + .failed => |value| value, + .configuring => null, + }; +} + +/// Return page progress only during incremental history decoding. +fn decoderProgress( + decoder: *const DecoderWrapper, +) ?snapshot_core.Decoder.Progress { + return switch (decoder.state) { + .history => |value| value.progress, + else => null, + }; +} + +/// Query several typed decoder values in order, stopping at the first error. +pub fn decoder_get_multi( + decoder_: Decoder, + count: usize, + keys_: ?[*]const DecoderData, + values_: ?[*]?*anyopaque, + out_written: ?*usize, +) callconv(lib.calling_conv) Result { + // Invalid arrays and first-key failures both report zero completed writes. + if (out_written) |written| written.* = 0; + const keys = keys_ orelse return .invalid_value; + const values = values_ orelse return .invalid_value; + + // Reuse the single-key path so availability rules remain identical. + for (0..count) |i| { + const result = decoder_get(decoder_, keys[i], values[i]); + if (result != .success) { + if (out_written) |written| written.* = i; + return result; + } + } + if (out_written) |written| written.* = count; + return .success; +} + +/// Decode through READY and return a renderable terminal for incremental use. +pub fn decoder_ready( + decoder_: Decoder, + out_: ?*terminal_c.Terminal, +) callconv(lib.calling_conv) Result { + // Validate the output and require an untouched decoder lifecycle. + const out = out_ orelse return .invalid_value; + out.* = null; + const decoder = decoder_ orelse return .invalid_value; + switch (decoder.state) { + .configuring => {}, + else => return .invalid_value, + } + + // Decode READY and transfer the decoded state into a C-owned terminal. + const ready = decoderReadyTerminal(decoder) catch |err| { + decoder.state = .{ .failed = null }; + return decoderMapError(decoder, err); + }; + + // Keep only the terminal handle and queryable metadata for page decoding. + decoder.state = .{ .history = .{ + .terminal = ready.terminal, + .metadata = ready.metadata, + } }; + out.* = ready.terminal; + return .success; +} + +/// Decode and apply exactly one history page, or validate FINISH. +pub fn decoder_next( + decoder_: Decoder, +) callconv(lib.calling_conv) Result { + // FINISH is idempotent; every other non-history state rejects `next`. + const decoder = decoder_ orelse return .invalid_value; + switch (decoder.state) { + .finished => return .no_value, + .history => {}, + else => return .invalid_value, + } + + // Resolve the terminal retained by READY and clear stale page progress. + const history = &decoder.state.history; + const terminal = history.terminal orelse return .invalid_value; + const native = terminal_c.zigTerminal(terminal).?; + history.progress = null; + + // Consume one validated record and preserve READY metadata on failure. + const progress = decoder.decoder.next(decoder.alloc, native) catch |err| { + const metadata = history.metadata; + decoder.state = .{ .failed = metadata }; + return decoderMapError(decoder, err); + }; + + // Publish page progress, or transition permanently to validated FINISH. + if (progress) |value| { + history.progress = value; + return .success; + } + + const metadata = history.metadata; + decoder.state = .{ .finished = metadata }; + return .no_value; +} + +/// Decode and validate the complete snapshot transactionally. +pub fn decoder_decode( + decoder_: Decoder, + out_: ?*terminal_c.Terminal, +) callconv(lib.calling_conv) Result { + // Validate the output and require an untouched decoder lifecycle. + const out = out_ orelse return .invalid_value; + out.* = null; + const decoder = decoder_ orelse return .invalid_value; + switch (decoder.state) { + .configuring => {}, + else => return .invalid_value, + } + + // Build the terminal from the renderable READY prefix. + const ready = decoderReadyTerminal(decoder) catch |err| { + decoder.state = .{ .failed = null }; + return decoderMapError(decoder, err); + }; + const native = terminal_c.zigTerminal(ready.terminal).?; + + // Apply every history page and require a valid FINISH record. + while (true) { + const progress = decoder.decoder.next(decoder.alloc, native) catch |err| { + terminal_c.free(ready.terminal); + decoder.state = .{ .failed = ready.metadata }; + return decoderMapError(decoder, err); + }; + if (progress == null) break; + } + + // Publish the terminal only after the entire snapshot succeeds. + decoder.state = .{ .finished = ready.metadata }; + out.* = ready.terminal; + return .success; +} + +/// Values produced while moving a core READY result into a C terminal. +const ReadyTerminal = struct { + terminal: terminal_c.Terminal, + metadata: DecoderWrapper.Metadata, +}; + +/// Decode READY, create terminal-owned I/O, and construct the C terminal. +fn decoderReadyTerminal(decoder: *DecoderWrapper) anyerror!ReadyTerminal { + // Terminal I/O is intentionally allocated only when decoding begins. + const io = try terminal_c.Io.init(decoder.alloc); + + // Decode READY while the fresh I/O implementation is still locally owned. + var decoded = decoder.decoder.ready( + decoder.alloc, + io.io(), + .{ .max_continuation_bytes = decoder.max_continuation_bytes }, + ) catch |err| { + io.deinit(decoder.alloc); + return err; + }; + defer decoded.deinit(decoder.alloc); + + // Copy small query metadata before `fromDecoded` consumes the core result. + const metadata: DecoderWrapper.Metadata = .{ + .history_rows = decoded.history_rows, + }; + + // Move terminal state and I/O into their final C-owned allocation. + const terminal = try terminal_c.fromDecoded( + decoder.alloc, + io, + &decoded, + ); + return .{ .terminal = terminal, .metadata = metadata }; +} + +/// Map decoder and source-adapter failures to public C result codes. +fn decoderMapError(decoder: *DecoderWrapper, err: anyerror) Result { + // Callback protocol failures are more specific than the core read error. + switch (decoder.source) { + .callback => |adapter| { + if (adapter.invalid_read) return .invalid_value; + if (adapter.callback_failed) return .io_error; + }, + .buffer => {}, + } + + // A successful zero-byte read is clean EOF by contract. Reaching it before + // the required marker therefore means truncated snapshot data, not an + // external I/O failure, and deliberately maps to invalid_value below. + return switch (err) { + error.OutOfMemory => .out_of_memory, + error.ContinuationLimitExceeded => .limit_exceeded, + error.ContinuationDisabled, + error.ContinuationUnavailable, + error.DecoderNotReady, + error.DecoderFailed, + => .invalid_value, + error.InvalidContinuation => .invalid_value, + else => .invalid_value, + }; +} + +/// Export the allocator-owned continuation required by snapshot encoding. +fn continuationOwned(terminal: terminal_c.Terminal) struct { + result: Result, + bytes: []u8, +} { + // The terminal allocator also owns the temporary continuation copy. + const native = terminal_c.zigTerminal(terminal) orelse return .{ + .result = .invalid_value, + .bytes = &.{}, + }; + const alloc = native.gpa(); + + // Snapshotting can prove an untracked ground stream needs no replay bytes; + // only non-ground streams require tracking to have retained their prefix. + const bytes = terminal_c.continuationAllocIo( + terminal, + alloc, + true, + ) catch |err| { + return .{ + .result = switch (err) { + error.InvalidValue => .invalid_value, + error.OutOfMemory => .out_of_memory, + error.ContinuationDisabled, + error.ContinuationUnavailable, + => .invalid_value, + }, + .bytes = &.{}, + }; + }; + return .{ .result = .success, .bytes = bytes }; +} + +/// Convert exported bytes to the core snapshot continuation representation. +fn continuationValue(bytes: []const u8) snapshot_core.Continuation { + return if (bytes.len == 0) .ground else .{ .bytes = bytes }; +} + +/// Encode a terminal snapshot to a synchronous C writer callback. +pub fn encode( + terminal: terminal_c.Terminal, + writer: io_c.Writer, +) callconv(lib.calling_conv) Result { + // Validate both handles and preflight the terminal continuation. + const native = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + if (writer.write == null) return .invalid_value; + const continuation = continuationOwned(terminal); + if (continuation.result != .success) return continuation.result; + defer native.gpa().free(continuation.bytes); + + // Stream the snapshot directly through the callback adapter. + var adapter: io_c.WriterAdapter = .init(writer); + snapshot_core.encode( + native.gpa(), + &adapter.interface, + native, + .{ .continuation = continuationValue(continuation.bytes) }, + ) catch |err| return mapEncodeError(err, &adapter); + return .success; +} + +/// Encode a terminal snapshot into a caller-provided fixed buffer. +pub fn encode_buf( + terminal: terminal_c.Terminal, + buf: ?[*]u8, + buf_len: usize, + out_written_: ?*usize, +) callconv(lib.calling_conv) Result { + // Validate output storage and preflight the terminal continuation. + const out_written = out_written_ orelse return .invalid_value; + out_written.* = 0; + if (buf == null and buf_len != 0) return .invalid_value; + const native = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const continuation = continuationOwned(terminal); + if (continuation.result != .success) return continuation.result; + defer native.gpa().free(continuation.bytes); + + // A null size query becomes a zero-capacity fixed writer and follows the + // same WriteFailed counting path as every other undersized destination. + const destination: []u8 = if (buf) |ptr| ptr[0..buf_len] else &.{}; + var writer: std.Io.Writer = .fixed(destination); + snapshot_core.encode( + native.gpa(), + &writer, + native, + .{ .continuation = continuationValue(continuation.bytes) }, + ) catch |err| switch (err) { + error.WriteFailed => { + // The fixed writer may already contain a valid snapshot prefix. + // Repeat only on this failure path to report the exact capacity. + var counter: std.Io.Writer.Discarding = .init(&.{}); + snapshot_core.encode( + native.gpa(), + &counter.writer, + native, + .{ .continuation = continuationValue(continuation.bytes) }, + ) catch |count_err| return mapEncodeError(count_err, null); + out_written.* = std.math.cast(usize, counter.count) orelse + return .limit_exceeded; + return .out_of_space; + }, + else => return mapEncodeError(err, null), + }; + out_written.* = writer.end; + return .success; +} + +/// Encode a terminal snapshot into a newly allocated C-owned buffer. +pub fn encode_alloc( + terminal: terminal_c.Terminal, + alloc_: ?*const CAllocator, + out_ptr_: ?*?[*]u8, + out_len_: ?*usize, +) callconv(lib.calling_conv) Result { + // Initialize outputs before validating terminal state or allocating bytes. + const out_ptr = out_ptr_ orelse return .invalid_value; + const out_len = out_len_ orelse return .invalid_value; + out_ptr.* = null; + out_len.* = 0; + + // Preflight continuation state before building an allocating writer. + const native = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const continuation = continuationOwned(terminal); + if (continuation.result != .success) return continuation.result; + defer native.gpa().free(continuation.bytes); + + // Encode once, then transfer ownership of the writer's completed slice. + const alloc = lib.alloc.default(alloc_); + var writer: std.Io.Writer.Allocating = .init(alloc); + defer writer.deinit(); + snapshot_core.encode( + native.gpa(), + &writer.writer, + native, + .{ .continuation = continuationValue(continuation.bytes) }, + ) catch |err| return mapEncodeError(err, null); + const bytes = writer.toOwnedSlice() catch return .out_of_memory; + out_ptr.* = bytes.ptr; + out_len.* = bytes.len; + return .success; +} + +/// Map core encoder and callback-adapter failures to public result codes. +fn mapEncodeError( + err: anyerror, + adapter: ?*const io_c.WriterAdapter, +) Result { + // Callback validation happens before encoding, so invalid_write can only + // mean output accounting overflow; callback_failed is external I/O. + if (adapter) |value| { + if (value.invalid_write) return .limit_exceeded; + if (value.callback_failed) return .io_error; + } + + // Fixed and allocating writers surface only core allocation or size errors. + return switch (err) { + error.OutOfMemory, error.WriteFailed => .out_of_memory, + error.Overflow, + error.PayloadTooLarge, + error.PageCountOverflow, + error.ScrollbackLimitOverflow, + error.StringTooLong, + => .limit_exceeded, + else => .invalid_value, + }; +} + +/// Enable continuation tracking for tests that encode C terminal snapshots. +fn testEnableContinuation(terminal: terminal_c.Terminal) !void { + const limit: usize = default_max_continuation_bytes; + try testing.expectEqual(Result.success, terminal_c.set( + terminal, + .continuation_max_bytes, + &limit, + )); +} + +test "decoder option and empty source" { + var decoder: Decoder = null; + try testing.expectEqual(Result.success, decoder_new_buf( + &lib.alloc.test_allocator, + &decoder, + null, + 0, + )); + defer decoder_free(decoder); + + var limit: usize = 0; + try testing.expectEqual(Result.success, decoder_get( + decoder, + .max_continuation_bytes, + &limit, + )); + try testing.expectEqual(default_max_continuation_bytes, limit); + + limit = 1234; + try testing.expectEqual(Result.success, decoder_set( + decoder, + .max_continuation_bytes, + &limit, + )); + limit = 0; + try testing.expectEqual(Result.success, decoder_get( + decoder, + .max_continuation_bytes, + &limit, + )); + try testing.expectEqual(1234, limit); + + var written: usize = 99; + try testing.expectEqual(Result.invalid_value, decoder_get_multi( + decoder, + 1, + null, + null, + &written, + )); + try testing.expectEqual(@as(usize, 0), written); + + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.invalid_value, decoder_decode( + decoder, + &terminal, + )); + try testing.expectEqual(null, terminal); +} + +test "snapshot C API full round trip restores continuation" { + var source: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &source, + 20, + 4, + )); + defer terminal_c.free(source); + try testEnableContinuation(source); + terminal_c.vt_write(source, "hello\x1b[31", "hello\x1b[31".len); + + var encoded_ptr: ?[*]u8 = null; + var encoded_len: usize = 0; + try testing.expectEqual(Result.success, encode_alloc( + source, + &lib.alloc.test_allocator, + &encoded_ptr, + &encoded_len, + )); + const encoded = encoded_ptr.?[0..encoded_len]; + defer lib.alloc.default(&lib.alloc.test_allocator).free(encoded); + + var decoder: Decoder = null; + try testing.expectEqual(Result.success, decoder_new_buf( + &lib.alloc.test_allocator, + &decoder, + encoded.ptr, + encoded.len, + )); + defer decoder_free(decoder); + + var restored: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, decoder_decode(decoder, &restored)); + defer terminal_c.free(restored); + + const source_text = try terminal_c.zigTerminal(source).?.plainString(testing.allocator); + defer testing.allocator.free(source_text); + const restored_text = try terminal_c.zigTerminal(restored).?.plainString(testing.allocator); + defer testing.allocator.free(restored_text); + try testing.expectEqualStrings(source_text, restored_text); + + var restored_limit: usize = std.math.maxInt(usize); + try testing.expectEqual(Result.success, terminal_c.get( + restored, + .continuation_max_bytes, + &restored_limit, + )); + try testing.expectEqual(@as(usize, 0), restored_limit); + + // Parser state is restored, but its tracking policy is not. The terminal + // cannot be snapshotted again until the restored sequence reaches ground. + var restored_required: usize = 0; + try testing.expectEqual(Result.invalid_value, encode_buf( + restored, + null, + 0, + &restored_required, + )); + terminal_c.vt_write(restored, "m", 1); + try testing.expectEqual(Result.out_of_space, encode_buf( + restored, + null, + 0, + &restored_required, + )); + try testing.expect(restored_required > 0); + + var offset: usize = 0; + try testing.expectEqual(Result.success, decoder_get( + decoder, + .source_offset, + &offset, + )); + try testing.expectEqual(encoded.len, offset); + try testing.expectEqual(Result.no_value, decoder_next(decoder)); + + var limited: Decoder = null; + try testing.expectEqual(Result.success, decoder_new_buf( + &lib.alloc.test_allocator, + &limited, + encoded.ptr, + encoded.len, + )); + defer decoder_free(limited); + const limit: usize = 3; + try testing.expectEqual(Result.success, decoder_set( + limited, + .max_continuation_bytes, + &limit, + )); + var rejected: terminal_c.Terminal = null; + try testing.expectEqual(Result.limit_exceeded, decoder_decode( + limited, + &rejected, + )); + try testing.expectEqual(null, rejected); + try testing.expectEqual(Result.invalid_value, decoder_set( + limited, + .max_continuation_bytes, + &limit, + )); + var failed_offset: usize = 0; + try testing.expectEqual(Result.no_value, decoder_get( + limited, + .source_offset, + &failed_offset, + )); + + const disabled: usize = 0; + try testing.expectEqual(Result.success, terminal_c.set( + source, + .continuation_max_bytes, + &disabled, + )); + var required: usize = 0; + try testing.expectEqual(Result.invalid_value, encode_buf( + source, + null, + 0, + &required, + )); +} + +test "snapshot encoding accepts an untracked ground terminal" { + var source: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &source, + 10, + 3, + )); + defer terminal_c.free(source); + + var limit: usize = std.math.maxInt(usize); + try testing.expectEqual(Result.success, terminal_c.get( + source, + .continuation_max_bytes, + &limit, + )); + try testing.expectEqual(@as(usize, 0), limit); + + terminal_c.vt_write(source, "ground", "ground".len); + var required: usize = 0; + try testing.expectEqual(Result.out_of_space, encode_buf( + source, + null, + 0, + &required, + )); + try testing.expect(required > 0); + + // Once untracked input leaves ground, the missing prefix is unknowable. + terminal_c.vt_write(source, "\x1b[31", "\x1b[31".len); + try testing.expectEqual(Result.invalid_value, encode_buf( + source, + null, + 0, + &required, + )); +} + +test "snapshot callbacks stop at FINISH and map I/O failures" { + var source: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &source, + 10, + 3, + )); + defer terminal_c.free(source); + try testEnableContinuation(source); + terminal_c.vt_write(source, "callback", "callback".len); + + var required: usize = 0; + try testing.expectEqual(Result.out_of_space, encode_buf( + source, + null, + 0, + &required, + )); + const buffer_encoded = try testing.allocator.alloc(u8, required); + defer testing.allocator.free(buffer_encoded); + var buffer_written: usize = 0; + try testing.expectEqual(Result.success, encode_buf( + source, + buffer_encoded.ptr, + buffer_encoded.len, + &buffer_written, + )); + try testing.expectEqual(required, buffer_written); + + // A non-null undersized buffer takes the fixed-first path. It may contain + // the prefix written before capacity exhaustion while still reporting the + // exact complete size. + var short: [10]u8 = @splat(0xAA); + var short_required: usize = 0; + try testing.expectEqual(Result.out_of_space, encode_buf( + source, + &short, + short.len, + &short_required, + )); + try testing.expectEqual(required, short_required); + try testing.expectEqualStrings("GHOSTSNP", short[0..8]); + + const encoded = try testing.allocator.alloc(u8, required); + defer testing.allocator.free(encoded); + + const WriteContext = struct { + destination: []u8, + offset: usize = 0, + + fn write( + userdata: ?*anyopaque, + data: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + if (len > self.destination.len - self.offset) return false; + @memcpy(self.destination[self.offset..][0..len], data[0..len]); + self.offset += len; + return true; + } + }; + var write_context: WriteContext = .{ .destination = encoded }; + try testing.expectEqual(Result.success, encode(source, .{ + .write = &WriteContext.write, + .userdata = &write_context, + })); + try testing.expectEqual(required, write_context.offset); + try testing.expectEqualSlices(u8, buffer_encoded, encoded); + + var failing = testing.FailingAllocator.init(testing.allocator, .{ + .fail_index = 0, + }); + const failing_zig = failing.allocator(); + const failing_c: CAllocator = .fromZig(&failing_zig); + var failed_ptr: ?[*]u8 = null; + var failed_len: usize = 99; + try testing.expectEqual(Result.out_of_memory, encode_alloc( + source, + &failing_c, + &failed_ptr, + &failed_len, + )); + try testing.expectEqual(null, failed_ptr); + try testing.expectEqual(@as(usize, 0), failed_len); + + const FailWriter = struct { + fn write(_: ?*anyopaque, _: [*]const u8, _: usize) callconv(lib.calling_conv) bool { + return false; + } + }; + try testing.expectEqual(Result.io_error, encode(source, .{ + .write = &FailWriter.write, + })); + + const combined = try testing.allocator.alloc(u8, encoded.len + 4); + defer testing.allocator.free(combined); + @memcpy(combined[0..encoded.len], encoded); + @memcpy(combined[encoded.len..], "tail"); + + const ReadContext = struct { + source: []const u8, + offset: usize = 0, + + fn read( + userdata: ?*anyopaque, + destination: [*]u8, + capacity: usize, + out_read: *usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + const remaining = self.source[self.offset..]; + const len = @min(remaining.len, capacity, 3); + @memcpy(destination[0..len], remaining[0..len]); + self.offset += len; + out_read.* = len; + return true; + } + }; + var read_context: ReadContext = .{ .source = combined }; + var decoder: Decoder = null; + try testing.expectEqual(Result.success, decoder_new( + &lib.alloc.test_allocator, + &decoder, + .{ .read = &ReadContext.read, .userdata = &read_context }, + )); + defer decoder_free(decoder); + var restored: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, decoder_decode(decoder, &restored)); + defer terminal_c.free(restored); + try testing.expectEqual(encoded.len, read_context.offset); + try testing.expectEqualStrings("tail", read_context.source[read_context.offset..]); + + const FailReader = struct { + fn read( + _: ?*anyopaque, + _: [*]u8, + _: usize, + _: *usize, + ) callconv(lib.calling_conv) bool { + return false; + } + }; + var failed_decoder: Decoder = null; + try testing.expectEqual(Result.success, decoder_new( + &lib.alloc.test_allocator, + &failed_decoder, + .{ .read = &FailReader.read }, + )); + defer decoder_free(failed_decoder); + var failed_terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.io_error, decoder_decode( + failed_decoder, + &failed_terminal, + )); + + const EofReader = struct { + fn read( + _: ?*anyopaque, + _: [*]u8, + _: usize, + out_read: *usize, + ) callconv(lib.calling_conv) bool { + out_read.* = 0; + return true; + } + }; + var eof_decoder: Decoder = null; + try testing.expectEqual(Result.success, decoder_new( + &lib.alloc.test_allocator, + &eof_decoder, + .{ .read = &EofReader.read }, + )); + defer decoder_free(eof_decoder); + // Successful EOF before READY/FINISH is truncated snapshot data. It is + // intentionally INVALID_VALUE; only a false callback is IO_ERROR. + try testing.expectEqual(Result.invalid_value, decoder_decode( + eof_decoder, + &failed_terminal, + )); + + const InvalidReader = struct { + fn read( + _: ?*anyopaque, + _: [*]u8, + capacity: usize, + out_read: *usize, + ) callconv(lib.calling_conv) bool { + out_read.* = capacity + 1; + return true; + } + }; + var invalid_decoder: Decoder = null; + try testing.expectEqual(Result.success, decoder_new( + &lib.alloc.test_allocator, + &invalid_decoder, + .{ .read = &InvalidReader.read }, + )); + defer decoder_free(invalid_decoder); + try testing.expectEqual(Result.invalid_value, decoder_decode( + invalid_decoder, + &failed_terminal, + )); +} + +test "snapshot incremental decoder exposes READY and page progress" { + var source: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &source, + 215, + 2, + )); + defer terminal_c.free(source); + try testEnableContinuation(source); + // Keep multiple complete pages. The ordinary C-terminal byte limit keeps + // at least one page but can evict older ones before snapshot encoding. + try testing.expectEqual(Result.success, terminal_c.set( + source, + .scrollback_max_bytes, + null, + )); + const first_page_rows = terminal_c.zigTerminal(source).?.screens + .get(.primary).?.pages.pages.first.?.capacity().rows; + for (0..@as(usize, first_page_rows) * 3) |_| + terminal_c.vt_write(source, "x\r\n", 3); + + var encoded_ptr: ?[*]u8 = null; + var encoded_len: usize = 0; + try testing.expectEqual(Result.success, encode_alloc( + source, + &lib.alloc.test_allocator, + &encoded_ptr, + &encoded_len, + )); + const encoded = encoded_ptr.?[0..encoded_len]; + defer lib.alloc.default(&lib.alloc.test_allocator).free(encoded); + + var decoder: Decoder = null; + try testing.expectEqual(Result.success, decoder_new_buf( + &lib.alloc.test_allocator, + &decoder, + encoded.ptr, + encoded.len, + )); + defer decoder_free(decoder); + + var restored: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, decoder_ready(decoder, &restored)); + defer terminal_c.free(restored); + + var history_rows: u64 = 0; + try testing.expectEqual(Result.success, decoder_get( + decoder, + .history_rows_primary, + &history_rows, + )); + try testing.expect(history_rows > 0); + + var page_count: usize = 0; + while (true) { + const result = decoder_next(decoder); + if (result == .no_value) break; + try testing.expectEqual(Result.success, result); + page_count += 1; + + var screen: terminal_c.TerminalScreen = undefined; + var rows: usize = 0; + var remaining: u32 = 0; + const keys = [_]DecoderData{ + .progress_screen, + .progress_rows, + .progress_remaining, + }; + const values = [_]?*anyopaque{ &screen, &rows, &remaining }; + var written: usize = 0; + try testing.expectEqual(Result.success, decoder_get_multi( + decoder, + keys.len, + &keys, + @constCast(&values), + &written, + )); + try testing.expectEqual(keys.len, written); + try testing.expectEqual(terminal_c.TerminalScreen.primary, screen); + try testing.expect(rows > 0); + } + try testing.expect(page_count > 0); + var rows: usize = 0; + try testing.expectEqual(Result.no_value, decoder_get( + decoder, + .progress_rows, + &rows, + )); + try testing.expectEqual(Result.no_value, decoder_next(decoder)); + + var source_total: usize = 0; + var restored_total: usize = 0; + try testing.expectEqual(Result.success, terminal_c.get( + source, + .total_rows, + &source_total, + )); + try testing.expectEqual(Result.success, terminal_c.get( + restored, + .total_rows, + &restored_total, + )); + try testing.expectEqual(source_total, restored_total); + + var dropped_decoder: Decoder = null; + try testing.expectEqual(Result.success, decoder_new_buf( + &lib.alloc.test_allocator, + &dropped_decoder, + encoded.ptr, + encoded.len, + )); + defer decoder_free(dropped_decoder); + var dropped: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, decoder_ready( + dropped_decoder, + &dropped, + )); + defer terminal_c.free(dropped); + try testing.expectEqual(Result.success, terminal_c.resize( + dropped, + 214, + 2, + 0, + 0, + )); + try testing.expectEqual(Result.success, decoder_next(dropped_decoder)); + var dropped_rows: usize = 1; + try testing.expectEqual(Result.success, decoder_get( + dropped_decoder, + .progress_rows, + &dropped_rows, + )); + try testing.expectEqual(@as(usize, 0), dropped_rows); + while (decoder_next(dropped_decoder) == .success) {} + try testing.expectEqual(Result.no_value, decoder_next(dropped_decoder)); +} diff --git a/src/terminal/c/sys.zig b/src/terminal/c/sys.zig index 0cf0c6e3b..319f17985 100644 --- a/src/terminal/c/sys.zig +++ b/src/terminal/c/sys.zig @@ -3,6 +3,7 @@ const builtin = @import("builtin"); const lib = @import("../lib.zig"); const CAllocator = lib.alloc.Allocator; const terminal_sys = @import("../sys.zig"); +const stderr = @import("../../os/stderr.zig"); const Result = @import("result.zig").Result; /// C: GhosttySysImage @@ -211,8 +212,12 @@ pub fn logFn( /// Formats each message as "[level](scope): message\n". Can be passed /// directly to ghostty_sys_set(GHOSTTY_SYS_OPT_LOG, &ghostty_sys_log_stderr). /// -/// Uses std.debug.lockStderrWriter for thread-safe, mutex-protected output. -/// On freestanding/wasm targets this is a no-op (no stderr available). +/// Each log line is emitted with a single raw write to stderr, which keeps +/// concurrent log lines from interleaving. We intentionally avoid +/// `std.debug.lockStderr` because it routes through `std.Options.debug_io` +/// and would keep the entire `std.Io.Threaded` vtable alive in the binary +/// (see `os/stderr.zig`). On freestanding/wasm targets this is a no-op +/// (no stderr available). pub fn logStderr( _: ?*anyopaque, level: LogLevel, @@ -233,16 +238,32 @@ pub fn logStderr( .debug => "debug", }; - var buffer: [64]u8 = undefined; - var locked_stderr = std.debug.lockStderr(&buffer); - defer std.debug.unlockStderr(); - nosuspend { - if (scope.len > 0) { - locked_stderr.file_writer.interface.print("[{s}]({s}): {s}\n", .{ level_text, scope, message }) catch {}; - } else { - locked_stderr.file_writer.interface.print("[{s}]: {s}\n", .{ level_text, message }) catch {}; - } + // Large enough for a full logFn chunk plus the level/scope prefix. + var buffer: [LogEmitter.buffer_size + 128]u8 = undefined; + const line: ?[]const u8 = if (scope.len > 0) + std.fmt.bufPrint(&buffer, "[{s}]({s}): {s}\n", .{ level_text, scope, message }) catch null + else + std.fmt.bufPrint(&buffer, "[{s}]: {s}\n", .{ level_text, message }) catch null; + if (line) |v| { + stderr.write(v); + return; } + + // The line didn't fit in our buffer (an embedder called us directly + // with a very large message). Fall back to writing the pieces + // separately; interleaving with other threads is possible here but + // this is a best-effort diagnostic path. + stderr.write("["); + stderr.write(level_text); + stderr.write("]"); + if (scope.len > 0) { + stderr.write("("); + stderr.write(scope); + stderr.write(")"); + } + stderr.write(": "); + stderr.write(message); + stderr.write("\n"); } test "set decode_png with null clears" { diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 85f4dbcf4..24c5edb39 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -28,6 +28,8 @@ const selection_c = @import("selection.zig"); const style_c = @import("style.zig"); const color = @import("../color.zig"); const clipboard = @import("../clipboard.zig"); +const c_io = @import("io.zig"); +const snapshot_core = @import("../snapshot/main.zig"); const Result = @import("result.zig").Result; const assert = @import("../../quirks.zig").inlineAssert; @@ -37,23 +39,76 @@ const max_path_bytes = if (builtin.os.tag == .freestanding) 4096 else std.fs.max const log = std.log.scoped(.terminal_c); +/// C terminals do not retain replay bytes unless the embedding application +/// opts in through `GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES`. +pub const default_continuation_max_bytes: usize = 0; + +/// Owns the `std.Io` implementation retained by every C terminal. +/// +/// Snapshot decoding creates this before the native terminal exists and +/// transfers it into the final C wrapper after READY. +pub const Io = struct { + impl: Impl, + + /// Platform-specific storage backing the public `std.Io` value. + /// + /// Where supported (POSIX) we use TinyIo, which is stateless and + /// supports exactly the operations the terminal needs at a fraction + /// of the code size (see lib/TinyIo.zig). On Windows we use + /// `std.Io.Threaded` since TinyIo doesn't implement the NT + /// operations. On the remaining targets (e.g. freestanding wasm) + /// TinyIo degrades to `std.Io.failing`, which is correct: they have + /// no filesystem. + const Impl = if (builtin.os.tag == .windows) + *std.Io.Threaded + else + lib.TinyIo; + + /// Allocation failures possible while constructing an I/O owner. + pub const Error = error{OutOfMemory}; + + /// Allocate the native I/O implementation when the platform requires it. + pub fn init(alloc: std.mem.Allocator) Error!Io { + if (comptime Impl == lib.TinyIo) return .{ .impl = .init }; + + const ptr = alloc.create(std.Io.Threaded) catch + return error.OutOfMemory; + ptr.* = .init_single_threaded; + return .{ .impl = ptr }; + } + + /// Return the value passed to native terminal construction and decoding. + pub fn io(self: Io) std.Io { + return self.impl.io(); + } + + /// Release an I/O implementation that has not already been transferred. + pub fn deinit(self: Io, alloc: std.mem.Allocator) void { + // Note: this must not name `std.Io.Threaded` in the condition + // because resolving that type trips its container-level comptime + // checks on targets it doesn't support (e.g. wasm32-freestanding). + if (comptime Impl != lib.TinyIo) { + self.impl.deinit(); + alloc.destroy(self.impl); + } + } +}; + /// Wrapper around ZigTerminal that tracks additional state for C API usage, /// such as the persistent VT stream needed to handle escape sequences split /// across multiple vt_write calls. const TerminalWrapper = struct { - const IoImpl = if (builtin.os.tag != .freestanding) *std.Io.Threaded else void; - terminal: *ZigTerminal, - /// We need to keep an I/O instance here as part of the terminal since we - /// have no way of taking it in the C API. This is set up in `new` and - /// destroyed on `free`. - /// - /// This is set to null on freestanding platforms, which get std.Io.failing - /// instead. - io_impl: IoImpl, + /// C construction has no I/O argument, so the wrapper retains the owner + /// created by `new` or transferred from snapshot decoding until `free`. + /// Freestanding owners contain no native allocation and expose failing I/O. + io: Io, /// We also need to store a temp dir path for some operations (e.g., kitty /// graphics). This provides stable storage for the API calls. tmp_dir_path: [max_path_bytes]u8, + /// The terminfo name reported for XTGETTCAP "TN". The stream handler holds + /// a slice into this. + terminfo_name_buf: [Handler.max_terminfo_name_bytes]u8, stream: Stream, effects: Effects = .{}, tracked_grid_refs: std.AutoArrayHashMapUnmanaged(*grid_ref_tracked_c.TrackedGridRef, void) = .{}, @@ -104,9 +159,53 @@ pub const ProgressReport = extern struct { progress: i8, }; -/// C callback state for terminal effects. Trampolines are always -/// installed on the stream handler; they check these fields and -/// no-op when the corresponding callback is null. +/// A borrowed unsupported string sequence. +/// +/// C: GhosttyTerminalUnknownStringSequence +pub const UnknownStringSequence = extern struct { + truncated: bool, + content: lib.String, +}; + +/// An unsupported terminal sequence reported to the C callback. +/// +/// C: GhosttyTerminalUnknownSequence +pub const UnknownSequence = union(Tag) { + apc: UnknownStringSequence, + + /// C: GhosttyTerminalUnknownSequenceTag + pub const Tag = lib.Enum(lib.target, &.{"apc"}); + + const c_union = lib.TaggedUnion( + lib.target, + @This(), + // A future borrowed CSI payload may need parameter, separator, and + // intermediate arrays. Reserve 128 bytes so that representation and + // other structured sequence types can be added without an ABI break. + [16]u64, + ); + pub const C = c_union.C; + pub const CValue = c_union.CValue; + pub const cval = c_union.cval; +}; + +/// A terminal mode and boolean value used for mode configuration. +/// +/// C: GhosttyTerminalModeConfig +pub const ModeConfig = extern struct { + mode: modes.ModeTag.Backing, + value: bool, + + fn toMode(self: ModeConfig) ?modes.Mode { + const tag: modes.ModeTag = @bitCast(self.mode); + return modes.modeFromInt(tag.value, tag.ansi); + } +}; + +/// C callback state for terminal effects. Most trampolines are always +/// installed on the stream handler; they check these fields and no-op when +/// the corresponding callback is null. The unknown-sequence trampoline is +/// installed dynamically to preserve its null fast path. const Effects = struct { userdata: ?*anyopaque = null, write_pty: ?WritePtyFn = null, @@ -121,6 +220,7 @@ const Effects = struct { progress_report: ?ProgressReportFn = null, size_cb: ?SizeFn = null, clipboard_write: ?ClipboardWriteFn = null, + unknown_sequence: ?UnknownSequenceFn = null, /// Scratch buffer for DA1 feature codes. The device attributes /// trampoline converts C feature codes into this buffer and returns @@ -168,6 +268,10 @@ const Effects = struct { /// C function pointer type for the progress_report callback. pub const ProgressReportFn = *const fn (Terminal, ?*anyopaque, *const ProgressReport) callconv(lib.calling_conv) void; + /// C function pointer type for the unknown_sequence callback. The request + /// and its content are borrowed for the callback duration. + pub const UnknownSequenceFn = *const fn (Terminal, ?*anyopaque, *const UnknownSequence.C) callconv(lib.calling_conv) void; + /// C function pointer type for the size callback. /// Returns true and fills out_size if size is available, /// or returns false to silently ignore the query. @@ -351,6 +455,26 @@ const Effects = struct { func(@ptrCast(wrapper), wrapper.effects.userdata, &c_report); } + fn unknownSequenceTrampoline( + handler: *Handler, + sequence: Handler.UnknownSequence, + ) void { + const wrapper = TerminalWrapper.fromHandler(handler); + const func = wrapper.effects.unknown_sequence orelse return; + const value = UnknownSequence.cval(switch (sequence) { + .apc => |apc_value| .{ + .apc = .{ + .truncated = apc_value.truncated, + .content = .{ + .ptr = apc_value.content.ptr, + .len = apc_value.content.len, + }, + }, + }, + }); + func(@ptrCast(wrapper), wrapper.effects.userdata, &value); + } + fn sizeTrampoline(handler: *Handler) ?size_report.Size { const wrapper = TerminalWrapper.fromHandler(handler); const func = wrapper.effects.size_cb orelse return null; @@ -373,6 +497,136 @@ pub fn zigTerminal(terminal_: Terminal) ?*ZigTerminal { return (terminal_ orelse return null).terminal; } +/// Attach the persistent C stream and I/O owner to a heap-stable terminal. +/// The caller retains ownership of both inputs if wrapper allocation fails. +fn wrap( + alloc: std.mem.Allocator, + t: *ZigTerminal, + io: Io, + continuation_max_bytes: usize, +) error{OutOfMemory}!Terminal { + const wrapper = alloc.create(TerminalWrapper) catch + return error.OutOfMemory; + + // Trampolines are always installed so setting C callbacks later takes + // effect immediately. + var handler: Stream.Handler = t.vtHandler(); + handler.effects = .{ + .write_pty = &Effects.writePtyTrampoline, + .bell = &Effects.bellTrampoline, + .color_scheme = &Effects.colorSchemeTrampoline, + .desktop_notification = &Effects.desktopNotificationTrampoline, + .device_attributes = &Effects.deviceAttributesTrampoline, + .enquiry = &Effects.enquiryTrampoline, + .xtversion = &Effects.xtversionTrampoline, + .title_changed = &Effects.titleChangedTrampoline, + .pwd_changed = &Effects.pwdChangedTrampoline, + .progress_report = &Effects.progressReportTrampoline, + .size = &Effects.sizeTrampoline, + .clipboard_write = &Effects.clipboardWriteTrampoline, + }; + + wrapper.* = .{ + .terminal = t, + .io = io, + .tmp_dir_path = undefined, + .terminfo_name_buf = undefined, + .stream = Stream.init(.{ + .allocator = alloc, + .handler = handler, + .continuation_max_bytes = continuation_max_bytes, + }), + }; + return wrapper; +} + +pub const RestoreContinuationError = error{ + OutOfMemory, + ContinuationDisabled, + ContinuationUnavailable, + InvalidContinuation, +}; + +/// Replay a decoded continuation exactly once into a newly created C terminal +/// and verify that the persistent stream exports the identical canonical +/// bytes. The terminal remains valid on error and may be freed normally. +pub fn restoreContinuation( + terminal_: Terminal, + continuation: []const u8, +) RestoreContinuationError!void { + const wrapper = terminal_ orelse return error.InvalidContinuation; + if (continuation.len > 0) wrapper.stream.nextSlice(continuation); + + var exported: std.Io.Writer.Allocating = .init(wrapper.terminal.gpa()); + defer exported.deinit(); + wrapper.stream.writeContinuation(&exported.writer) catch |err| switch (err) { + error.WriteFailed => return error.OutOfMemory, + error.ContinuationDisabled => return error.ContinuationDisabled, + error.ContinuationUnavailable => return error.ContinuationUnavailable, + }; + if (!std.mem.eql(u8, continuation, exported.written())) { + return error.InvalidContinuation; + } +} + +pub const FromDecodedError = error{ + OutOfMemory, + InvalidContinuation, +}; + +/// Transfer a core snapshot result into a caller-owned C terminal. +/// +/// This function consumes `io` on every path. The decoded terminal is +/// transferred only after its final heap address has been allocated; its +/// continuation remains in `decoded` and is replayed before returning. +/// Replay uses a temporary exact-size tracker which is disabled before the +/// terminal crosses the C ABI, restoring the ordinary C default policy. +pub fn fromDecoded( + alloc: std.mem.Allocator, + io: Io, + decoded: *snapshot_core.Decoded, +) FromDecodedError!Terminal { + const native = alloc.create(ZigTerminal) catch { + io.deinit(alloc); + return error.OutOfMemory; + }; + native.* = decoded.toOwned(); + + const continuation = switch (decoded.continuation) { + .ground => "", + .bytes => |bytes| bytes, + }; + + // Non-ground state needs tracking only long enough to verify that replay + // reconstructed the exact canonical continuation. Ground state needs no + // tracker at all. + const terminal = wrap(alloc, native, io, continuation.len) catch |err| { + native.deinit(alloc); + alloc.destroy(native); + io.deinit(alloc); + return err; + }; + errdefer free(terminal); + + if (continuation.len > 0) { + restoreContinuation(terminal, continuation) catch |err| return switch (err) { + // The decoded bytes exactly fit this fresh tracker's cap, so + // losing them while replaying can only be an allocation failure. + error.OutOfMemory, + error.ContinuationUnavailable, + => error.OutOfMemory, + error.ContinuationDisabled, + error.InvalidContinuation, + => error.InvalidContinuation, + }; + } + + // Decoder validation limits are not terminal runtime policy. A restored + // terminal always starts with the same disabled tracking default as new. + setContinuationMaxBytes(terminal.?, default_continuation_max_bytes); + return terminal; +} + const NewError = error{ InvalidValue, OutOfMemory, @@ -399,7 +653,7 @@ fn new_( alloc_: ?*const CAllocator, cols: size.CellCountInt, rows: size.CellCountInt, -) NewError!*TerminalWrapper { +) NewError!Terminal { if (cols == 0 or rows == 0) return error.InvalidValue; const alloc = lib.alloc.default(alloc_); @@ -407,21 +661,12 @@ fn new_( return error.OutOfMemory; errdefer alloc.destroy(t); - const wrapper = alloc.create(TerminalWrapper) catch - return error.OutOfMemory; - errdefer alloc.destroy(wrapper); - - const has_nonfailing_io = builtin.os.tag != .freestanding; - const io_impl: TerminalWrapper.IoImpl = if (has_nonfailing_io) io_impl: { - const ptr = try alloc.create(std.Io.Threaded); - ptr.* = .init_single_threaded; - break :io_impl ptr; - } else {}; - errdefer if (has_nonfailing_io) alloc.destroy(io_impl); + const io = try Io.init(alloc); + errdefer io.deinit(alloc); // Setup our terminal t.* = try .init( - if (has_nonfailing_io) io_impl.io() else std.Io.failing, + io.io(), alloc, .{ .cols = cols, @@ -435,32 +680,12 @@ fn new_( // Shells can still opt in with OSC 133;A;redraw=1. t.flags.shell_redraws_prompt = .false; - // Setup our stream with trampolines always installed so that - // setting C callbacks at any time takes effect immediately. - var handler: Stream.Handler = t.vtHandler(); - handler.effects = .{ - .write_pty = &Effects.writePtyTrampoline, - .bell = &Effects.bellTrampoline, - .color_scheme = &Effects.colorSchemeTrampoline, - .desktop_notification = &Effects.desktopNotificationTrampoline, - .device_attributes = &Effects.deviceAttributesTrampoline, - .enquiry = &Effects.enquiryTrampoline, - .xtversion = &Effects.xtversionTrampoline, - .title_changed = &Effects.titleChangedTrampoline, - .pwd_changed = &Effects.pwdChangedTrampoline, - .progress_report = &Effects.progressReportTrampoline, - .size = &Effects.sizeTrampoline, - .clipboard_write = &Effects.clipboardWriteTrampoline, - }; - - wrapper.* = .{ - .terminal = t, - .io_impl = io_impl, - .tmp_dir_path = undefined, // Only used if temporary directory is set with API calls - .stream = .initAlloc(alloc, handler), - }; - - return wrapper; + return try wrap( + alloc, + t, + io, + default_continuation_max_bytes, + ); } pub fn vt_write( @@ -472,6 +697,249 @@ pub fn vt_write( wrapper.stream.nextSlice(ptr[0..len]); } +pub fn vt_write_until_ground( + terminal_: Terminal, + ptr_: ?[*]const u8, + len: usize, + out_consumed_: ?*usize, +) callconv(lib.calling_conv) Result { + const out_consumed = out_consumed_ orelse return .invalid_value; + out_consumed.* = 0; + + const wrapper = terminal_ orelse return .invalid_value; + const input: []const u8 = if (ptr_) |ptr| + ptr[0..len] + else if (len == 0) + "" + else + return .invalid_value; + + if (wrapper.stream.nextSliceUntilGround(input)) |consumed| { + out_consumed.* = consumed; + return .success; + } + + out_consumed.* = len; + return .no_value; +} + +pub const ContinuationWriteError = error{ + InvalidValue, + WriteFailed, + ContinuationDisabled, + ContinuationUnavailable, +}; + +/// Write the exact replay-safe continuation for a C terminal to a Zig writer. +/// Snapshot encoding uses this helper so it can preflight continuation state +/// without converting its allocator or writer through the C ABI. +pub fn continuationWriteIo( + terminal_: Terminal, + writer: *std.Io.Writer, +) ContinuationWriteError!void { + // Keep handle validation here so the three public output forms and the + // snapshot encoder all use exactly the same continuation preflight. + const wrapper = terminal_ orelse return error.InvalidValue; + + // Stream owns the tracker and distinguishes disabled tracking from a + // tracker that lost bytes after allocation failure or limit overflow. + try wrapper.stream.writeContinuation(writer); +} + +pub const ContinuationAllocError = error{ + InvalidValue, + OutOfMemory, + ContinuationDisabled, + ContinuationUnavailable, +}; + +/// Return an allocator-owned copy of the terminal's replay-safe continuation. +/// The caller owns the returned slice and must free it with `alloc`. +/// +/// If `allow_untracked_ground` is true, disabled tracking is accepted only +/// when the stream is provably at ground and an owned empty slice is returned. +pub fn continuationAllocIo( + terminal_: Terminal, + alloc: std.mem.Allocator, + allow_untracked_ground: bool, +) ContinuationAllocError![]u8 { + const wrapper = terminal_ orelse return error.InvalidValue; + if (allow_untracked_ground and + wrapper.stream.continuation == null and + wrapper.stream.ground()) + { + return alloc.dupe(u8, "") catch error.OutOfMemory; + } + + // The allocating writer gives snapshot encoding and the public allocation + // API one common way to obtain an exact owned continuation. + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + + // A write failure from an Allocating writer can only be allocation failure; + // no external callback participates in this path. + continuationWriteIo(terminal_, &aw.writer) catch |err| switch (err) { + error.InvalidValue => return error.InvalidValue, + error.WriteFailed => return error.OutOfMemory, + error.ContinuationDisabled => return error.ContinuationDisabled, + error.ContinuationUnavailable => return error.ContinuationUnavailable, + }; + + // Transfer the buffer out before the deferred writer cleanup runs. + return aw.toOwnedSlice() catch error.OutOfMemory; +} + +/// Map errors that are intrinsic to continuation export. Public callback +/// failures receive finer classification in `continuation_write` below. +fn continuationErrorResult(err: ContinuationWriteError) Result { + return switch (err) { + error.InvalidValue => .invalid_value, + error.WriteFailed => .io_error, + error.ContinuationDisabled => .invalid_value, + error.ContinuationUnavailable => .invalid_value, + }; +} + +pub fn continuation_write( + terminal_: Terminal, + writer: c_io.Writer, +) callconv(lib.calling_conv) Result { + // Reject the missing callback before invoking the common helper so this is + // classified as a bad argument rather than a write failure. + if (writer.write == null) return .invalid_value; + + // The callback was validated above, so invalid_write can only mean output + // accounting overflow. Keep that distinct from callback rejection. + var adapter: c_io.WriterAdapter = .init(writer); + continuationWriteIo(terminal_, &adapter.interface) catch |err| { + if (err == error.WriteFailed) { + if (adapter.invalid_write) return .limit_exceeded; + if (adapter.callback_failed) return .io_error; + } + return continuationErrorResult(err); + }; + return .success; +} + +pub fn continuation_buf( + terminal_: Terminal, + out_: ?[*]u8, + out_len: usize, + out_written_: ?*usize, +) callconv(lib.calling_conv) Result { + const out_written = out_written_ orelse return .invalid_value; + // All failure paths leave deterministic output metadata. + out_written.* = 0; + if (out_ == null and out_len != 0) return .invalid_value; + + if (out_ == null) { + // A null/zero destination is the explicit size-query form. Discarding + // runs the real exporter, so disabled or unavailable tracking is still + // detected before a required length is reported. + var discarding: std.Io.Writer.Discarding = .init(&.{}); + continuationWriteIo(terminal_, &discarding.writer) catch |err| + return continuationErrorResult(err); + out_written.* = @intCast(discarding.count); + return .out_of_space; + } + + // Fixed writers report WriteFailed when capacity is exhausted. The stream + // exporter itself remains all-or-nothing from the API's perspective. + var writer: std.Io.Writer = .fixed(out_.?[0..out_len]); + continuationWriteIo(terminal_, &writer) catch |err| switch (err) { + error.WriteFailed => { + // Re-run against a counter to return the full required capacity, + // not merely the prefix that fit in the caller's buffer. + var discarding: std.Io.Writer.Discarding = .init(&.{}); + continuationWriteIo(terminal_, &discarding.writer) catch |count_err| + return continuationErrorResult(count_err); + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + else => return continuationErrorResult(err), + }; + + // `end` is the initialized prefix of the fixed destination. + out_written.* = writer.end; + return .success; +} + +pub fn continuation_alloc( + terminal_: Terminal, + alloc_: ?*const CAllocator, + out_ptr_: ?*?[*]u8, + out_len_: ?*usize, +) callconv(lib.calling_conv) Result { + const out_ptr = out_ptr_ orelse return .invalid_value; + const out_len = out_len_ orelse return .invalid_value; + // Make ownership unambiguous even if validation or allocation fails. + out_ptr.* = null; + out_len.* = 0; + + // Resolve NULL to libghostty-vt's default allocator before entering the + // shared Zig allocation path. + const bytes = continuationAllocIo( + terminal_, + lib.alloc.default(alloc_), + false, + ) catch |err| return switch (err) { + error.InvalidValue => .invalid_value, + error.OutOfMemory => .out_of_memory, + error.ContinuationDisabled => .invalid_value, + error.ContinuationUnavailable => .invalid_value, + }; + + // Ownership crosses the ABI here; callers release this exact pointer and + // length with ghostty_free and the same allocator selection. + out_ptr.* = bytes.ptr; + out_len.* = bytes.len; + return .success; +} + +fn continuationMaxBytes(wrapper: *const TerminalWrapper) usize { + // Absence of a tracker is the public representation of disabled tracking. + return if (wrapper.stream.continuation) |tracker| + tracker.max_bytes + else + 0; +} + +/// Change continuation tracking policy without disturbing normal VT parser +/// state. Bytes which were not retained while disabled or after exceeding an +/// earlier cap cannot be reconstructed: export remains unavailable until a +/// later feed reaches ground or contains a new replay start. +fn setContinuationMaxBytes(wrapper: *TerminalWrapper, max_bytes: usize) void { + if (max_bytes == 0) { + // Disabling releases retained bytes immediately. Parser and UTF-8 state + // continue normally; only future replay/export information is lost. + if (wrapper.stream.continuation) |*tracker| tracker.deinit(); + wrapper.stream.continuation = null; + return; + } + + if (wrapper.stream.continuation) |*tracker| { + // Changing a live cap preserves retained bytes when they still fit. + tracker.max_bytes = max_bytes; + if (tracker.bytes.items.len > max_bytes) { + // Once a retained prefix is discarded it cannot be reconstructed + // from parser state alone. Mark it broken until Stream observes a + // ground state or a new replay-safe sequence start. + tracker.bytes.clearRetainingCapacity(); + tracker.broken = true; + } + return; + } + + // Enabling from zero starts an empty tracker owned by the terminal's + // allocator. It can immediately track only if no earlier bytes are needed. + wrapper.stream.continuation = .init(wrapper.terminal.gpa(), max_bytes); + if (!wrapper.stream.ground()) { + // The parser was already mid-sequence while tracking was disabled, so + // exporting now would omit an unknown prefix. + wrapper.stream.continuation.?.broken = true; + } +} + pub fn compression_activity( terminal_: Terminal, out_activity_: ?*u64, @@ -527,6 +995,13 @@ pub const Option = enum(c_int) { scrollback_max_lines = 28, desktop_notification = 29, progress_report = 30, + continuation_max_bytes = 31, + title_report = 32, + mode_default = 33, + mode = 34, + unknown_sequence = 35, + unknown_max_bytes = 36, + terminfo_name = 37, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -544,23 +1019,28 @@ pub const Option = enum(c_int) { .progress_report => ?Effects.ProgressReportFn, .size_cb => ?Effects.SizeFn, .clipboard_write => ?Effects.ClipboardWriteFn, - .title, .pwd => ?*const lib.String, + .unknown_sequence => ?Effects.UnknownSequenceFn, + .title, .pwd, .terminfo_name => ?*const lib.String, .color_foreground, .color_background, .color_cursor => ?*const color.RGB.C, .color_palette => ?*const color.PaletteC, .kitty_image_storage_limit => ?*const u64, .kitty_image_medium_file, .kitty_image_medium_shared_mem, .glyph_protocol, + .title_report, => ?*const bool, .kitty_image_medium_temp_file => ?*const lib.String, .apc_max_bytes, .apc_max_bytes_kitty, .scrollback_max_bytes, .scrollback_max_lines, + .continuation_max_bytes, + .unknown_max_bytes, => ?*const usize, .selection => ?*const selection_c.CSelection, .default_cursor_style => ?*const TerminalCursorStyle, .default_cursor_blink => ?*const bool, + .mode, .mode_default => ?*const ModeConfig, }; } }; @@ -607,6 +1087,17 @@ fn setTyped( .progress_report => wrapper.effects.progress_report = value, .size_cb => wrapper.effects.size_cb = value, .clipboard_write => wrapper.effects.clipboard_write = value, + .unknown_sequence => { + wrapper.effects.unknown_sequence = value; + wrapper.stream.handler.unknown_sequence = if (value != null) + &Effects.unknownSequenceTrampoline + else + null; + }, + .title_report => wrapper.stream.handler.title_report = if (value) |ptr| + ptr.* + else + false, .title => { const str = if (value) |v| v.ptr[0..v.len] else ""; wrapper.terminal.setTitle(str) catch return .out_of_memory; @@ -615,6 +1106,15 @@ fn setTyped( const str = if (value) |v| v.ptr[0..v.len] else ""; wrapper.terminal.setPwd(str) catch return .out_of_memory; }, + .terminfo_name => { + const str = if (value) |v| v.ptr[0..v.len] else ""; + if (str.len > wrapper.terminfo_name_buf.len) return .invalid_value; + @memcpy(wrapper.terminfo_name_buf[0..str.len], str); + wrapper.stream.handler.terminfo_name = if (str.len > 0) + wrapper.terminfo_name_buf[0..str.len] + else + null; + }, .color_foreground => { wrapper.terminal.colors.foreground.default = if (value) |v| .fromC(v.*) else null; wrapper.terminal.flags.dirty.palette = true; @@ -639,7 +1139,7 @@ fn setTyped( var it = wrapper.terminal.screens.all.iterator(); while (it.next()) |entry| { const screen = entry.value.*; - screen.kitty_images.setLimit(screen.io, screen.alloc, screen, limit) catch return .out_of_memory; + screen.kitty_images.setLimit(screen.io, screen.alloc, screen, limit); } }, .kitty_image_medium_file, @@ -717,6 +1217,24 @@ fn setTyped( .scrollback_max_lines => wrapper.terminal.setScrollbackMaxLines( if (value) |ptr| ptr.* else null, ), + .continuation_max_bytes => setContinuationMaxBytes( + wrapper, + if (value) |ptr| ptr.* else default_continuation_max_bytes, + ), + .unknown_max_bytes => wrapper.stream.handler.apc_handler.unknown_max_bytes = + if (value) |ptr| ptr.* else 0, + .mode, .mode_default => { + const config = (value orelse return .invalid_value).*; + const mode = config.toMode() orelse return .invalid_value; + switch (option) { + .mode => wrapper.terminal.modes.set(mode, config.value), + .mode_default => { + if (!modes.defaultConfigurable(mode)) return .invalid_value; + wrapper.terminal.modes.setDefault(mode, config.value); + }, + else => unreachable, + } + }, } return .success; } @@ -787,30 +1305,6 @@ pub fn reset(terminal_: Terminal) callconv(lib.calling_conv) void { t.fullReset(); } -pub fn mode_get( - terminal_: Terminal, - tag: modes.ModeTag.Backing, - out_value: *bool, -) callconv(lib.calling_conv) Result { - const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; - const mode_tag: modes.ModeTag = @bitCast(tag); - const mode = modes.modeFromInt(mode_tag.value, mode_tag.ansi) orelse return .invalid_value; - out_value.* = t.modes.get(mode); - return .success; -} - -pub fn mode_set( - terminal_: Terminal, - tag: modes.ModeTag.Backing, - value: bool, -) callconv(lib.calling_conv) Result { - const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; - const mode_tag: modes.ModeTag = @bitCast(tag); - const mode = modes.modeFromInt(mode_tag.value, mode_tag.ansi) orelse return .invalid_value; - t.modes.set(mode, value); - return .success; -} - /// C: GhosttyKittyGraphics pub const KittyGraphics = kitty_gfx_c.KittyGraphics; @@ -858,6 +1352,10 @@ pub const TerminalData = enum(c_int) { vt_processing_error = 33, scrollback_max_bytes = 34, scrollback_max_lines = 35, + continuation_max_bytes = 36, + mode = 37, + vt_ground = 38, + cursor_at_prompt = 39, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: TerminalData) type { @@ -869,6 +1367,8 @@ pub const TerminalData = enum(c_int) { .mouse_tracking, .viewport_active, .vt_processing_error, + .vt_ground, + .cursor_at_prompt, => bool, .active_screen => TerminalScreen, .kitty_keyboard_flags => u8, @@ -879,6 +1379,7 @@ pub const TerminalData = enum(c_int) { .scrollback_rows, .scrollback_max_bytes, .scrollback_max_lines, + .continuation_max_bytes, => usize, .width_px, .height_px => u32, .color_foreground, @@ -896,6 +1397,7 @@ pub const TerminalData = enum(c_int) { .kitty_image_medium_temp_file => lib.String, .kitty_graphics => KittyGraphics, .selection => selection_c.CSelection, + .mode => ModeConfig, }; } }; @@ -912,12 +1414,14 @@ pub fn get( }; } + const out_ptr = out orelse return .invalid_value; + return switch (data) { .invalid => .invalid_value, inline else => |comptime_data| getTyped( terminal_, comptime_data, - @ptrCast(@alignCast(out)), + @ptrCast(@alignCast(out_ptr)), ), }; } @@ -1015,6 +1519,7 @@ fn getTyped( ), .viewport_active => out.* = t.screens.active.pages.viewport == .active, .vt_processing_error => out.* = wrapper.stream.handler.semantic_failure, + .vt_ground => out.* = wrapper.stream.ground(), .scrollback_max_bytes => { const max = t.screens.get(.primary).?.pages.limits.bytes.explicit; if (max == std.math.maxInt(usize)) return .no_value; @@ -1025,6 +1530,12 @@ fn getTyped( if (max == std.math.maxInt(usize)) return .no_value; out.* = max; }, + .continuation_max_bytes => out.* = continuationMaxBytes(wrapper), + .mode => { + const mode = out.toMode() orelse return .invalid_value; + out.value = t.modes.get(mode); + }, + .cursor_at_prompt => out.* = t.cursorIsAtPrompt(), } return .success; @@ -1109,15 +1620,19 @@ pub fn free(terminal_: Terminal) callconv(lib.calling_conv) void { wrapper.tracked_grid_refs.deinit(alloc); wrapper.stream.deinit(); t.deinit(alloc); - if (builtin.os.tag != .freestanding) { - // Deinit is always safe to call, even for single-threaded instances - wrapper.io_impl.deinit(); - alloc.destroy(wrapper.io_impl); - } + wrapper.io.deinit(alloc); alloc.destroy(t); alloc.destroy(wrapper); } +fn testEnableContinuation(terminal: Terminal) !void { + const limit: usize = 1024; + try testing.expectEqual( + Result.success, + set(terminal, .continuation_max_bytes, &limit), + ); +} + test "new/free" { var t: Terminal = null; try testing.expectEqual(Result.success, new( @@ -1151,6 +1666,318 @@ test "new invalid value" { try testing.expect(t == null); } +test "continuation option and data" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + var value: usize = 0; + try testing.expectEqual( + Result.success, + get(t, .continuation_max_bytes, @ptrCast(&value)), + ); + try testing.expectEqual(default_continuation_max_bytes, value); + + const custom: usize = 1234; + try testing.expectEqual( + Result.success, + set(t, .continuation_max_bytes, &custom), + ); + try testing.expectEqual( + Result.success, + get(t, .continuation_max_bytes, @ptrCast(&value)), + ); + try testing.expectEqual(custom, value); + + try testing.expectEqual( + Result.success, + set(t, .continuation_max_bytes, null), + ); + try testing.expectEqual( + Result.success, + get(t, .continuation_max_bytes, @ptrCast(&value)), + ); + try testing.expectEqual(default_continuation_max_bytes, value); + + const disabled: usize = 0; + try testing.expectEqual( + Result.success, + set(t, .continuation_max_bytes, &disabled), + ); + try testing.expectEqual( + Result.success, + get(t, .continuation_max_bytes, @ptrCast(&value)), + ); + try testing.expectEqual(@as(usize, 0), value); + + var written: usize = 99; + try testing.expectEqual( + Result.invalid_value, + continuation_buf(t, null, 0, &written), + ); + try testing.expectEqual(@as(usize, 0), written); +} + +test "continuation buffer and allocator export exact suffix" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + try testEnableContinuation(t); + + vt_write(t, "A\x1b[31", 5); + + var required: usize = 0; + try testing.expectEqual( + Result.out_of_space, + continuation_buf(t, null, 0, &required), + ); + try testing.expectEqual(@as(usize, 4), required); + + var short: [2]u8 = undefined; + try testing.expectEqual( + Result.out_of_space, + continuation_buf(t, &short, short.len, &required), + ); + try testing.expectEqual(@as(usize, 4), required); + + var buf: [8]u8 = undefined; + var written: usize = 0; + try testing.expectEqual( + Result.success, + continuation_buf(t, &buf, buf.len, &written), + ); + try testing.expectEqualStrings("\x1b[31", buf[0..written]); + + var out_ptr: ?[*]u8 = null; + var out_len: usize = 0; + try testing.expectEqual(Result.success, continuation_alloc( + t, + &lib.alloc.test_allocator, + &out_ptr, + &out_len, + )); + const allocated = out_ptr orelse return error.TestExpectedEqual; + defer lib.alloc.default(&lib.alloc.test_allocator).free(allocated[0..out_len]); + try testing.expectEqualStrings("\x1b[31", allocated[0..out_len]); + + vt_write(t, "m", 1); + try testing.expectEqual( + Result.out_of_space, + continuation_buf(t, null, 0, &required), + ); + try testing.expectEqual(@as(usize, 0), required); +} + +test "continuation export tracks split UTF-8" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + try testEnableContinuation(t); + + const prefix = [_]u8{ 0xF0, 0x9F }; + vt_write(t, &prefix, prefix.len); + + var buf: [4]u8 = undefined; + var written: usize = 0; + try testing.expectEqual( + Result.success, + continuation_buf(t, &buf, buf.len, &written), + ); + try testing.expectEqualSlices(u8, &prefix, buf[0..written]); + + const suffix = [_]u8{ 0x98, 0x80 }; + vt_write(t, &suffix, suffix.len); + try testing.expectEqual( + Result.out_of_space, + continuation_buf(t, null, 0, &written), + ); + try testing.expectEqual(@as(usize, 0), written); +} + +test "continuation callback writer reports success and failure" { + const Sink = struct { + bytes: [8]u8 = undefined, + len: usize = 0, + + fn write( + userdata: ?*anyopaque, + data: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + if (len > self.bytes.len - self.len) return false; + @memcpy(self.bytes[self.len..][0..len], data[0..len]); + self.len += len; + return true; + } + + fn fail( + _: ?*anyopaque, + _: [*]const u8, + _: usize, + ) callconv(lib.calling_conv) bool { + return false; + } + }; + + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + try testEnableContinuation(t); + vt_write(t, "\x1b[31", 4); + + var sink: Sink = .{}; + try testing.expectEqual(Result.success, continuation_write(t, .{ + .write = &Sink.write, + .userdata = &sink, + })); + try testing.expectEqualStrings("\x1b[31", sink.bytes[0..sink.len]); + + try testing.expectEqual(Result.io_error, continuation_write(t, .{ + .write = &Sink.fail, + })); + try testing.expectEqual( + Result.invalid_value, + continuation_write(t, .{}), + ); + try testing.expectEqual(Result.invalid_value, continuation_write(null, .{ + .write = &Sink.fail, + })); +} + +test "continuation runtime reconfiguration recovers safely" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + const enough: usize = 8; + try testing.expectEqual( + Result.success, + set(t, .continuation_max_bytes, &enough), + ); + vt_write(t, "\x1b[123", 5); + const too_small: usize = 2; + try testing.expectEqual( + Result.success, + set(t, .continuation_max_bytes, &too_small), + ); + + var written: usize = 99; + try testing.expectEqual( + Result.invalid_value, + continuation_buf(t, null, 0, &written), + ); + try testing.expectEqual(@as(usize, 0), written); + + // Raising the limit cannot reconstruct discarded bytes, but a new ESC is + // a complete replay start and repairs tracking without first grounding. + try testing.expectEqual( + Result.success, + set(t, .continuation_max_bytes, &enough), + ); + vt_write(t, "\x1b[", 2); + var buf: [8]u8 = undefined; + try testing.expectEqual( + Result.success, + continuation_buf(t, &buf, buf.len, &written), + ); + try testing.expectEqualStrings("\x1b[", buf[0..written]); + + const disabled: usize = 0; + try testing.expectEqual( + Result.success, + set(t, .continuation_max_bytes, &disabled), + ); + try testing.expectEqual( + Result.success, + set(t, .continuation_max_bytes, &enough), + ); + try testing.expectEqual( + Result.invalid_value, + continuation_buf(t, null, 0, &written), + ); + + // Completing the sequence reaches ground and resets the broken marker. + vt_write(t, "m", 1); + try testing.expectEqual( + Result.out_of_space, + continuation_buf(t, null, 0, &written), + ); + try testing.expectEqual(@as(usize, 0), written); +} + +test "continuation internal allocation and restoration" { + var source: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &source, + 80, + 24, + )); + defer free(source); + try testEnableContinuation(source); + vt_write(source, "\x1b[31", 4); + + const alloc = lib.alloc.default(&lib.alloc.test_allocator); + const bytes = try continuationAllocIo(source, alloc, false); + defer alloc.free(bytes); + try testing.expectEqualStrings("\x1b[31", bytes); + + var restored: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &restored, + 80, + 24, + )); + defer free(restored); + try testEnableContinuation(restored); + try restoreContinuation(restored, bytes); + + const reexported = try continuationAllocIo(restored, alloc, false); + defer alloc.free(reexported); + try testing.expectEqualStrings(bytes, reexported); + + var invalid: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &invalid, + 80, + 24, + )); + defer free(invalid); + try testEnableContinuation(invalid); + try testing.expectError( + error.InvalidContinuation, + restoreContinuation(invalid, "A"), + ); +} + test "set scrollback limits" { var t: Terminal = null; try testing.expectEqual(Result.success, new( @@ -1538,7 +2365,7 @@ test "resize shrinks both axes with cursor at bottom" { try testing.expectEqual(23, t.?.terminal.rows); } -test "mode_get and mode_set" { +test "set and get mode" { var t: Terminal = null; try testing.expectEqual(Result.success, new( &lib.alloc.test_allocator, @@ -1548,40 +2375,31 @@ test "mode_get and mode_set" { )); defer free(t); - var value: bool = undefined; - // DEC mode 25 (cursor_visible) defaults to true const cursor_visible: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 25, .ansi = false }); - try testing.expectEqual(Result.success, mode_get(t, cursor_visible, &value)); - try testing.expect(value); + var config: ModeConfig = .{ .mode = cursor_visible, .value = undefined }; + try testing.expectEqual(Result.success, get(t, .mode, @ptrCast(&config))); + try testing.expect(config.value); // Set it to false - try testing.expectEqual(Result.success, mode_set(t, cursor_visible, false)); - try testing.expectEqual(Result.success, mode_get(t, cursor_visible, &value)); - try testing.expect(!value); + config.value = false; + try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config))); + try testing.expectEqual(Result.success, get(t, .mode, @ptrCast(&config))); + try testing.expect(!config.value); // ANSI mode 4 (insert) defaults to false const insert: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 4, .ansi = true }); - try testing.expectEqual(Result.success, mode_get(t, insert, &value)); - try testing.expect(!value); + config.mode = insert; + try testing.expectEqual(Result.success, get(t, .mode, @ptrCast(&config))); + try testing.expect(!config.value); - try testing.expectEqual(Result.success, mode_set(t, insert, true)); - try testing.expectEqual(Result.success, mode_get(t, insert, &value)); - try testing.expect(value); + config.value = true; + try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config))); + try testing.expectEqual(Result.success, get(t, .mode, @ptrCast(&config))); + try testing.expect(config.value); } -test "mode_get null" { - var value: bool = undefined; - const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 25, .ansi = false }); - try testing.expectEqual(Result.invalid_value, mode_get(null, tag, &value)); -} - -test "mode_set null" { - const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 25, .ansi = false }); - try testing.expectEqual(Result.invalid_value, mode_set(null, tag, true)); -} - -test "mode_get unknown mode" { +test "set mode default updates current and reset value" { var t: Terminal = null; try testing.expectEqual(Result.success, new( &lib.alloc.test_allocator, @@ -1591,12 +2409,49 @@ test "mode_get unknown mode" { )); defer free(t); - var value: bool = undefined; - const unknown: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 9999, .ansi = false }); - try testing.expectEqual(Result.invalid_value, mode_get(t, unknown, &value)); + const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ + .value = 2027, + .ansi = false, + }); + var config: ModeConfig = .{ .mode = tag, .value = true }; + try testing.expectEqual(Result.success, set( + t, + .mode_default, + @ptrCast(&config), + )); + try testing.expect(t.?.terminal.modes.get(.grapheme_cluster)); + try testing.expect(t.?.terminal.modes.default.grapheme_cluster); + + config.value = false; + try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config))); + try testing.expect(!t.?.terminal.modes.get(.grapheme_cluster)); + try testing.expect(t.?.terminal.modes.default.grapheme_cluster); + + // Setting the default also unconditionally replaces the current value. + config.value = true; + try testing.expectEqual(Result.success, set( + t, + .mode_default, + @ptrCast(&config), + )); + try testing.expect(t.?.terminal.modes.get(.grapheme_cluster)); + + config.value = false; + try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config))); + vt_write(t, "\x1bc", 2); + try testing.expect(t.?.terminal.modes.get(.grapheme_cluster)); + + config.value = false; + try testing.expectEqual(Result.success, set( + t, + .mode_default, + @ptrCast(&config), + )); + try testing.expect(!t.?.terminal.modes.get(.grapheme_cluster)); + try testing.expect(!t.?.terminal.modes.default.grapheme_cluster); } -test "mode_set unknown mode" { +test "set mode default validates configuration" { var t: Terminal = null; try testing.expectEqual(Result.success, new( &lib.alloc.test_allocator, @@ -1606,8 +2461,56 @@ test "mode_set unknown mode" { )); defer free(t); - const unknown: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 9999, .ansi = false }); - try testing.expectEqual(Result.invalid_value, mode_set(t, unknown, true)); + try testing.expectEqual(Result.invalid_value, set( + t, + .mode_default, + null, + )); + + var config: ModeConfig = .{ + .mode = @bitCast(modes.ModeTag{ .value = 9999, .ansi = false }), + .value = true, + }; + try testing.expectEqual(Result.invalid_value, set( + t, + .mode_default, + @ptrCast(&config), + )); + + config.mode = @bitCast(modes.ModeTag{ .value = 1047, .ansi = false }); + try testing.expectEqual(Result.invalid_value, set( + t, + .mode_default, + @ptrCast(&config), + )); +} + +test "set and get mode null" { + const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 25, .ansi = false }); + var config: ModeConfig = .{ .mode = tag, .value = true }; + try testing.expectEqual(Result.invalid_value, set(null, .mode, @ptrCast(&config))); + try testing.expectEqual(Result.invalid_value, get(null, .mode, @ptrCast(&config))); +} + +test "set and get mode validate configuration" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + try testing.expectEqual(Result.invalid_value, set(t, .mode, null)); + try testing.expectEqual(Result.invalid_value, get(t, .mode, null)); + + var config: ModeConfig = .{ + .mode = @bitCast(modes.ModeTag{ .value = 9999, .ansi = false }), + .value = true, + }; + try testing.expectEqual(Result.invalid_value, set(t, .mode, @ptrCast(&config))); + try testing.expectEqual(Result.invalid_value, get(t, .mode, @ptrCast(&config))); } test "vt_write" { @@ -1627,6 +2530,189 @@ test "vt_write" { try testing.expectEqualStrings("Hello", str); } +test "vt_write_until_ground result contract" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + // Input remains untouched when the stream is already at ground. + var consumed: usize = 99; + const untouched = "unprocessed"; + try testing.expectEqual( + Result.success, + vt_write_until_ground(t, untouched, untouched.len, &consumed), + ); + try testing.expectEqual(@as(usize, 0), consumed); + + // Complete a split CSI and stop before inspecting the printable suffix. + vt_write(t, "\x1b[31", 4); + const input = "mABC\x1b["; + try testing.expectEqual( + Result.success, + vt_write_until_ground(t, input, input.len, &consumed), + ); + try testing.expectEqual(@as(usize, 1), consumed); + try testing.expect(t.?.stream.ground()); + + var str = try t.?.terminal.plainString(testing.allocator); + try testing.expectEqualStrings("", str); + testing.allocator.free(str); + + // The untouched suffix can be processed after work at the boundary. + vt_write(t, input.ptr + consumed, input.len - consumed); + str = try t.?.terminal.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC", str); + try testing.expect(!t.?.stream.ground()); + + // Exhausting input while unfinished is distinct from reaching ground on + // the final byte, even though both consume the entire input. + try testing.expectEqual( + Result.no_value, + vt_write_until_ground(t, "123", 3, &consumed), + ); + try testing.expectEqual(@as(usize, 3), consumed); + try testing.expect(!t.?.stream.ground()); + + try testing.expectEqual( + Result.success, + vt_write_until_ground(t, "m", 1, &consumed), + ); + try testing.expectEqual(@as(usize, 1), consumed); + try testing.expect(t.?.stream.ground()); +} + +test "vt_write_until_ground handles UTF-8 and abort boundaries" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + var ground: bool = false; + try testing.expectEqual( + Result.success, + get(t, .vt_ground, @ptrCast(&ground)), + ); + try testing.expect(ground); + + vt_write(t, &.{0xF0}, 1); + try testing.expectEqual( + Result.success, + get(t, .vt_ground, @ptrCast(&ground)), + ); + try testing.expect(!ground); + + const utf8_suffix = [_]u8{ 0x9F, 0x98, 0x84 }; + var consumed: usize = 99; + try testing.expectEqual( + Result.success, + vt_write_until_ground(t, &utf8_suffix, utf8_suffix.len, &consumed), + ); + try testing.expectEqual(utf8_suffix.len, consumed); + try testing.expect(t.?.stream.ground()); + + // A malformed continuation resets the decoder and reaches ground after + // processing the retry byte. + vt_write(t, &.{ 0xE0, 0xA0 }, 2); + try testing.expectEqual( + Result.success, + vt_write_until_ground(t, "A", 1, &consumed), + ); + try testing.expectEqual(@as(usize, 1), consumed); + try testing.expect(t.?.stream.ground()); + + vt_write(t, "\x1b[123", 5); + try testing.expectEqual( + Result.success, + vt_write_until_ground(t, &.{0x18}, 1, &consumed), + ); + try testing.expectEqual(@as(usize, 1), consumed); + try testing.expect(t.?.stream.ground()); +} + +test "vt_write_until_ground invokes effects only for consumed input" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + const S = struct { + var bell_count: usize = 0; + + fn bell(_: Terminal, _: ?*anyopaque) callconv(lib.calling_conv) void { + bell_count += 1; + } + }; + S.bell_count = 0; + try testing.expectEqual(Result.success, set(t, .bell, @ptrCast(&S.bell))); + + vt_write(t, "\x1b[31", 4); + const input = "m\x07"; + var consumed: usize = 99; + try testing.expectEqual( + Result.success, + vt_write_until_ground(t, input, input.len, &consumed), + ); + try testing.expectEqual(@as(usize, 1), consumed); + try testing.expectEqual(@as(usize, 0), S.bell_count); + + vt_write(t, input.ptr + consumed, input.len - consumed); + try testing.expectEqual(@as(usize, 1), S.bell_count); +} + +test "vt_write_until_ground validates arguments" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + var consumed: usize = 99; + try testing.expectEqual( + Result.invalid_value, + vt_write_until_ground(null, "x", 1, &consumed), + ); + try testing.expectEqual(@as(usize, 0), consumed); + + consumed = 99; + try testing.expectEqual( + Result.invalid_value, + vt_write_until_ground(t, null, 1, &consumed), + ); + try testing.expectEqual(@as(usize, 0), consumed); + + try testing.expectEqual( + Result.invalid_value, + vt_write_until_ground(t, "", 0, null), + ); + + // NULL represents a valid empty slice. While unfinished it consumes zero + // bytes and reports that no ground boundary was found. + vt_write(t, "\x1b[", 2); + consumed = 99; + try testing.expectEqual( + Result.no_value, + vt_write_until_ground(t, null, 0, &consumed), + ); + try testing.expectEqual(@as(usize, 0), consumed); +} + test "vt_write split escape sequence" { var t: Terminal = null; try testing.expectEqual(Result.success, new( @@ -1772,12 +2858,40 @@ test "get cursor_visible" { try testing.expect(visible); // DEC mode 25 controls cursor visibility - const cursor_visible_mode: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 25, .ansi = false }); - try testing.expectEqual(Result.success, mode_set(t, cursor_visible_mode, false)); + var config: ModeConfig = .{ + .mode = @bitCast(modes.ModeTag{ .value = 25, .ansi = false }), + .value = false, + }; + try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config))); try testing.expectEqual(Result.success, get(t, .cursor_visible, @ptrCast(&visible))); try testing.expect(!visible); } +test "get cursor_at_prompt" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + var at_prompt: bool = undefined; + try testing.expectEqual(Result.success, get(t, .cursor_at_prompt, @ptrCast(&at_prompt))); + try testing.expect(!at_prompt); + + const prompt_start = "\x1b]133;A\x07"; + vt_write(t, prompt_start, prompt_start.len); + try testing.expectEqual(Result.success, get(t, .cursor_at_prompt, @ptrCast(&at_prompt))); + try testing.expect(at_prompt); + + const alternate_screen = "\x1b[?1049h"; + vt_write(t, alternate_screen, alternate_screen.len); + try testing.expectEqual(Result.success, get(t, .cursor_at_prompt, @ptrCast(&at_prompt))); + try testing.expect(!at_prompt); +} + test "get active_screen" { var t: Terminal = null; try testing.expectEqual(Result.success, new( @@ -1829,34 +2943,50 @@ test "get mouse_tracking" { try testing.expect(!tracking); // Enable X10 mouse (DEC mode 9) - const x10_mode: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 9, .ansi = false }); - try testing.expectEqual(Result.success, mode_set(t, x10_mode, true)); + var config: ModeConfig = .{ + .mode = @bitCast(modes.ModeTag{ .value = 9, .ansi = false }), + .value = true, + }; + try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config))); try testing.expectEqual(Result.success, get(t, .mouse_tracking, @ptrCast(&tracking))); try testing.expect(tracking); // Disable X10, enable normal mouse (DEC mode 1000) - try testing.expectEqual(Result.success, mode_set(t, x10_mode, false)); - const normal_mode: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1000, .ansi = false }); - try testing.expectEqual(Result.success, mode_set(t, normal_mode, true)); + config.value = false; + try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config))); + config = .{ + .mode = @bitCast(modes.ModeTag{ .value = 1000, .ansi = false }), + .value = true, + }; + try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config))); try testing.expectEqual(Result.success, get(t, .mouse_tracking, @ptrCast(&tracking))); try testing.expect(tracking); // Disable normal, enable button mouse (DEC mode 1002) - try testing.expectEqual(Result.success, mode_set(t, normal_mode, false)); - const button_mode: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1002, .ansi = false }); - try testing.expectEqual(Result.success, mode_set(t, button_mode, true)); + config.value = false; + try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config))); + config = .{ + .mode = @bitCast(modes.ModeTag{ .value = 1002, .ansi = false }), + .value = true, + }; + try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config))); try testing.expectEqual(Result.success, get(t, .mouse_tracking, @ptrCast(&tracking))); try testing.expect(tracking); // Disable button, enable any mouse (DEC mode 1003) - try testing.expectEqual(Result.success, mode_set(t, button_mode, false)); - const any_mode: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1003, .ansi = false }); - try testing.expectEqual(Result.success, mode_set(t, any_mode, true)); + config.value = false; + try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config))); + config = .{ + .mode = @bitCast(modes.ModeTag{ .value = 1003, .ansi = false }), + .value = true, + }; + try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config))); try testing.expectEqual(Result.success, get(t, .mouse_tracking, @ptrCast(&tracking))); try testing.expect(tracking); // Disable all - should be false again - try testing.expectEqual(Result.success, mode_set(t, any_mode, false)); + config.value = false; + try testing.expectEqual(Result.success, set(t, .mode, @ptrCast(&config))); try testing.expectEqual(Result.success, get(t, .mouse_tracking, @ptrCast(&tracking))); try testing.expect(!tracking); } @@ -2867,6 +3997,73 @@ test "xtversion without callback reports default" { try testing.expectEqualStrings("\x1BP>|libghostty\x1B\\", S.last_data.?); } +test "set terminfo_name option" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + }; + defer S.deinit(); + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + + // While no name is set the query goes unanswered; other capabilities + // are still served from the static map. + const query = "\x1BP+q" ++ std.fmt.bytesToHex("TN", .upper) ++ "\x1B\\"; + vt_write(t, query, query.len); + try testing.expect(S.last_data == null); + const co_query = "\x1BP+q" ++ std.fmt.bytesToHex("Co", .upper) ++ "\x1B\\"; + vt_write(t, co_query, co_query.len); + try testing.expectEqualStrings( + "\x1BP1+r" ++ std.fmt.bytesToHex("Co", .upper) ++ "=" ++ + std.fmt.bytesToHex("256", .upper) ++ "\x1B\\", + S.last_data.?, + ); + S.deinit(); + + // The name is copied, so the caller's buffer can go away afterwards. + var name: [14]u8 = "xterm-256color".*; + const value: lib.String = .{ .ptr = &name, .len = name.len }; + try testing.expectEqual(Result.success, set(t, .terminfo_name, @ptrCast(&value))); + @memset(&name, 'z'); + + vt_write(t, query, query.len); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings( + "\x1BP1+r" ++ std.fmt.bytesToHex("TN", .upper) ++ "=" ++ + std.fmt.bytesToHex("xterm-256color", .upper) ++ "\x1B\\", + S.last_data.?, + ); + + // Clearing with NULL leaves the query unanswered again. + S.deinit(); + try testing.expectEqual(Result.success, set(t, .terminfo_name, null)); + vt_write(t, query, query.len); + try testing.expect(S.last_data == null); + + // Names beyond the maximum are rejected rather than truncated. + const long: [Handler.max_terminfo_name_bytes + 1]u8 = @splat('a'); + const long_value: lib.String = .{ .ptr = &long, .len = long.len }; + try testing.expectEqual(Result.invalid_value, set(t, .terminfo_name, @ptrCast(&long_value))); +} + test "set title_changed callback" { var t: Terminal = null; try testing.expectEqual(Result.success, new( @@ -3062,6 +4259,114 @@ test "set progress_report callback" { try testing.expectEqual(@as(usize, cases.len), S.count); } +test "set unknown_sequence callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + const S = struct { + var count: usize = 0; + var last_terminal: Terminal = null; + var last_userdata: ?*anyopaque = null; + var last_tag: UnknownSequence.Tag = .apc; + var last_truncated: bool = false; + var content: [64]u8 = undefined; + var content_len: usize = 0; + + fn unknownSequence( + terminal_: Terminal, + ud: ?*anyopaque, + sequence: *const UnknownSequence.C, + ) callconv(lib.calling_conv) void { + count += 1; + last_terminal = terminal_; + last_userdata = ud; + last_tag = sequence.tag; + const apc_value = sequence.value.apc; + last_truncated = apc_value.truncated; + content_len = @min(apc_value.content.len, content.len); + @memcpy(content[0..content_len], apc_value.content.ptr[0..content_len]); + } + }; + S.count = 0; + S.last_terminal = null; + S.last_userdata = null; + S.last_tag = .apc; + S.last_truncated = false; + S.content_len = 0; + + var sentinel: u8 = 101; + try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel))); + + const max_bytes: usize = 8; + try testing.expectEqual(Result.success, set( + t, + .unknown_max_bytes, + @ptrCast(&max_bytes), + )); + try testing.expectEqual(max_bytes, t.?.stream.handler.apc_handler.unknown_max_bytes); + + // A byte limit without a callback performs no external effect. + const before_callback = "\x1B_abc;xy\x1B\\"; + vt_write(t, before_callback, before_callback.len); + try testing.expectEqual(@as(usize, 0), S.count); + try testing.expect(t.?.stream.handler.unknown_sequence == null); + + try testing.expectEqual(Result.success, set( + t, + .unknown_sequence, + @ptrCast(&S.unknownSequence), + )); + try testing.expect(t.?.stream.handler.unknown_sequence != null); + + // Split a complete APC across writes to exercise persistent parser state. + const seq_a = "\x1B_abc;"; + const seq_b = "xy\x1B\\"; + vt_write(t, seq_a, seq_a.len); + try testing.expectEqual(@as(usize, 0), S.count); + vt_write(t, seq_b, seq_b.len); + try testing.expectEqual(@as(usize, 1), S.count); + try testing.expectEqual(t, S.last_terminal); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); + try testing.expectEqual(UnknownSequence.Tag.apc, S.last_tag); + try testing.expect(!S.last_truncated); + try testing.expectEqualStrings("abc;xy", S.content[0..S.content_len]); + + // Content beyond the generic limit is omitted and marked truncated. + const truncated = "\x1B_abcdefghijkl\x1B\\"; + vt_write(t, truncated, truncated.len); + try testing.expectEqual(@as(usize, 2), S.count); + try testing.expect(S.last_truncated); + try testing.expectEqualStrings("abcdefgh", S.content[0..S.content_len]); + + // CAN aborts the APC and must not invoke the callback. + const aborted = "\x1B_abcdef\x18"; + vt_write(t, aborted, aborted.len); + try testing.expectEqual(@as(usize, 2), S.count); + + // Clearing the callback restores the null fast path immediately. + try testing.expectEqual(Result.success, set(t, .unknown_sequence, null)); + try testing.expect(t.?.stream.handler.unknown_sequence == null); + vt_write(t, before_callback, before_callback.len); + try testing.expectEqual(@as(usize, 2), S.count); + + // A NULL limit disables capture even after reinstalling the callback. + try testing.expectEqual(Result.success, set( + t, + .unknown_sequence, + @ptrCast(&S.unknownSequence), + )); + try testing.expectEqual(Result.success, set(t, .unknown_max_bytes, null)); + try testing.expectEqual(@as(usize, 0), t.?.stream.handler.apc_handler.unknown_max_bytes); + vt_write(t, before_callback, before_callback.len); + try testing.expectEqual(@as(usize, 2), S.count); +} + test "set pwd_changed callback" { var t: Terminal = null; try testing.expectEqual(Result.success, new( @@ -3657,6 +4962,65 @@ test "get title set via vt_write" { try testing.expectEqualStrings("VT Title", title.ptr[0..title.len]); } +test "title report requires explicit opt in" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |data| testing.allocator.free(data); + last_data = null; + } + + fn writePty( + _: Terminal, + _: ?*anyopaque, + ptr: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) void { + if (last_data) |data| testing.allocator.free(data); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + }; + S.last_data = null; + defer S.deinit(); + + try testing.expectEqual( + Result.success, + set(t, .write_pty, @ptrCast(&S.writePty)), + ); + + const set_title = "\x1B]2;echo vulnerable\x1B\\"; + const query_title = "\x1B[21t"; + vt_write(t, set_title, set_title.len); + + // WRITE_PTY alone must not enable the security-sensitive response. + vt_write(t, query_title, query_title.len); + try testing.expect(S.last_data == null); + + const enabled = true; + try testing.expectEqual(Result.success, set(t, .title_report, &enabled)); + vt_write(t, query_title, query_title.len); + try testing.expectEqualStrings( + "\x1b]lecho vulnerable\x1b\\", + S.last_data.?, + ); + + // NULL restores the secure default. + S.deinit(); + try testing.expectEqual(Result.success, set(t, .title_report, null)); + vt_write(t, query_title, query_title.len); + try testing.expect(S.last_data == null); +} + test "resize updates pixel dimensions" { var t: Terminal = null; try testing.expectEqual(Result.success, new( diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 60f97c342..df84d3ede 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -20,6 +20,7 @@ const render = @import("render.zig"); const style_c = @import("style.zig"); const mouse_encode = @import("mouse_encode.zig"); const grid_ref = @import("grid_ref.zig"); +const io = @import("io.zig"); /// C: GhosttySurfacePosition pub const SurfacePosition = extern struct { @@ -60,6 +61,7 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: { .{ "GhosttyMousePosition", StructInfo.init(mouse_event.Position) }, .{ "GhosttyPoint", StructInfo.init(point.Point.C) }, .{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) }, + .{ "GhosttyReader", StructInfo.init(io.Reader) }, .{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) }, .{ "GhosttySelectionGestureBehaviors", StructInfo.init(selection_gesture.Behaviors) }, .{ "GhosttySelectionGestureGeometry", StructInfo.init(selection_gesture.Geometry) }, @@ -69,15 +71,19 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: { .{ "GhosttyStyle", StructInfo.init(style_c.Style) }, .{ "GhosttyStyleColor", StructInfo.init(style_c.Color) }, .{ "GhosttyTerminalDesktopNotification", StructInfo.init(terminal.DesktopNotification) }, + .{ "GhosttyTerminalModeConfig", StructInfo.init(terminal.ModeConfig) }, .{ "GhosttyTerminalProgressReport", StructInfo.init(terminal.ProgressReport) }, .{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) }, .{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) }, + .{ "GhosttyTerminalUnknownSequence", StructInfo.init(terminal.UnknownSequence.C) }, + .{ "GhosttyTerminalUnknownStringSequence", StructInfo.init(terminal.UnknownStringSequence) }, + .{ "GhosttyWriter", StructInfo.init(io.Writer) }, }); }; /// The comptime-generated JSON string of all structs. pub const json: [:0]const u8 = json: { - @setEvalBranchQuota(100000); + @setEvalBranchQuota(200_000); var counter: std.Io.Writer.Discarding = .init(&.{}); jsonWriteAll(&counter.writer) catch unreachable; @@ -218,6 +224,11 @@ test "json parses" { try std.testing.expect(root.contains("GhosttyClipboardContent")); try std.testing.expect(root.contains("GhosttyClipboardWrite")); try std.testing.expect(root.contains("GhosttyFormatterTerminalOptions")); + try std.testing.expect(root.contains("GhosttyTerminalModeConfig")); + try std.testing.expect(root.contains("GhosttyTerminalUnknownSequence")); + try std.testing.expect(root.contains("GhosttyTerminalUnknownStringSequence")); + try std.testing.expect(root.contains("GhosttyReader")); + try std.testing.expect(root.contains("GhosttyWriter")); const clipboard_content = root.get("GhosttyClipboardContent").?.object; const clipboard_content_fields = clipboard_content.get("fields").?.object; @@ -231,6 +242,26 @@ test "json parses" { try std.testing.expect(clipboard_write_fields.contains("contents")); try std.testing.expect(clipboard_write_fields.contains("contents_len")); + const unknown_sequence = root.get("GhosttyTerminalUnknownSequence").?.object; + const unknown_sequence_fields = unknown_sequence.get("fields").?.object; + try std.testing.expect(unknown_sequence_fields.contains("tag")); + try std.testing.expect(unknown_sequence_fields.contains("value")); + + const unknown_string = root.get("GhosttyTerminalUnknownStringSequence").?.object; + const unknown_string_fields = unknown_string.get("fields").?.object; + try std.testing.expect(unknown_string_fields.contains("truncated")); + try std.testing.expect(unknown_string_fields.contains("content")); + + const reader_fields = root.get("GhosttyReader").?.object + .get("fields").?.object; + try std.testing.expect(reader_fields.contains("read")); + try std.testing.expect(reader_fields.contains("userdata")); + + const writer_fields = root.get("GhosttyWriter").?.object + .get("fields").?.object; + try std.testing.expect(writer_fields.contains("write")); + try std.testing.expect(writer_fields.contains("userdata")); + try std.testing.expect(!root.contains("GhosttyTerminalOptions")); } diff --git a/src/terminal/color.zig b/src/terminal/color.zig index 1909ba2a0..c04e19e84 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -350,7 +350,7 @@ pub const DynamicRGB = struct { } pub fn reset(self: *DynamicRGB) void { - self.override = self.default; + self.override = null; } }; diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index bc183dcf5..a76ab31ba 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -523,6 +523,7 @@ test "DECRQSS largest response fits fixed buffer" { try t.setAttribute(.inverse); try t.setAttribute(.invisible); try t.setAttribute(.strikethrough); + try t.setAttribute(.overline); try t.setAttribute(.{ .direct_color_fg = .{ .r = 255, .g = 255, @@ -538,7 +539,7 @@ test "DECRQSS largest response fits fixed buffer" { const encoded = try Command.DECRQSS.sgr.encode(&t, &buf); const expected = - "\x1BP1$r0;1;2;3;4:5;5;7;8;9" ++ + "\x1BP1$r0;1;2;3;4:5;53;5;7;8;9" ++ ";38:2::255:255:255" ++ ";48:2::255:255:255m\x1B\\"; try testing.expectEqualStrings(expected, encoded); diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index f8cf61e64..ab9edbffe 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -1,5 +1,6 @@ const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; +const fastprint = @import("../fastprint.zig"); const lib = @import("lib.zig"); const Allocator = std.mem.Allocator; const color = @import("color.zig"); @@ -119,9 +120,117 @@ pub const Options = struct { /// /// Used by formatters that operate on PageLists to track the source position /// of each byte written. The caller is responsible for freeing the map. +/// +/// The mapping is stored in two parts: a per-byte x/y coordinate (8 +/// bytes per output byte, half the size of a Pin) and a tiny table of +/// page nodes covering byte ranges (there are only ever a handful of +/// pages). This also lets page formatters write coordinates directly +/// into the map without a separate coordinate-to-pin conversion pass. pub const PinMap = struct { alloc: Allocator, - map: *std.ArrayList(Pin), + map: *Map, + + /// The type of the page node referenced by pins. + pub const Node = @FieldType(Pin, "node"); + + /// A page node covering output bytes starting at `offset` + /// (inclusive) until the next entry's offset (or the end of the + /// output). + pub const NodeRun = struct { + offset: usize, + node: Node, + }; + + pub const Map = struct { + /// The x/y coordinate within its page for every output byte. + points: std.ArrayList(Coordinate) = .empty, + + /// The page node for ranges of output bytes, ordered by offset. + nodes: std.ArrayList(NodeRun) = .empty, + + pub const empty: Map = .{}; + + pub fn deinit(self: *Map, alloc: Allocator) void { + self.points.deinit(alloc); + self.nodes.deinit(alloc); + } + + pub fn clearRetainingCapacity(self: *Map) void { + self.points.clearRetainingCapacity(); + self.nodes.clearRetainingCapacity(); + } + + /// The total number of bytes mapped. + pub fn count(self: *const Map) usize { + return self.points.items.len; + } + + /// Set the page node for all bytes appended from here on, + /// until the next call. No-op if the node is unchanged. + pub fn setNode( + self: *Map, + alloc: Allocator, + node: Node, + ) Allocator.Error!void { + if (self.nodes.getLastOrNull()) |last| { + if (last.node == node) return; + } + + try self.nodes.append(alloc, .{ + .offset = self.points.items.len, + .node = node, + }); + } + + /// Append `n` bytes that map to `pin`. + pub fn append( + self: *Map, + alloc: Allocator, + pin: Pin, + n: usize, + ) Allocator.Error!void { + if (n == 0) return; + try self.setNode(alloc, pin.node); + try self.points.appendNTimes( + alloc, + .{ .x = pin.x, .y = pin.y }, + n, + ); + } + + /// Returns the pin that the byte at the given offset maps to, + /// or null if the offset is out of range. + pub fn get(self: *const Map, offset: usize) ?Pin { + if (offset >= self.points.items.len) return null; + const coord = self.points.items[offset]; + return .{ + .node = findNode(self.nodes.items, offset) orelse return null, + .x = coord.x, + .y = @intCast(coord.y), + }; + } + + /// Returns the last pin in the map, if any. + pub fn getLastOrNull(self: *const Map) ?Pin { + const len = self.points.items.len; + if (len == 0) return null; + return self.get(len - 1); + } + }; + + /// Binary search for the node covering `offset` in a slice of node + /// runs sorted by offset. Returns null only if the slice is empty + /// or the offset precedes the first run. + pub fn findNode(runs: []const NodeRun, offset: usize) ?Node { + if (runs.len == 0 or offset < runs[0].offset) return null; + var lo: usize = 0; + var hi: usize = runs.len; + while (lo + 1 < hi) { + const mid = lo + (hi - lo) / 2; + if (runs[mid].offset <= offset) lo = mid else hi = mid; + } + return runs[lo].node; + } }; /// Terminal formatter formats the active terminal screen. @@ -288,7 +397,7 @@ pub const TerminalFormatter = struct { // Map all those bytes to the same pin. Use the top left to ensure // the node pointer is always properly initialized. - m.map.appendNTimes( + m.map.append( m.alloc, self.terminal.screens.active.pages.getTopLeft(.screen), std.math.cast(usize, discarding.count) orelse return error.WriteFailed, @@ -326,7 +435,47 @@ pub const TerminalFormatter = struct { // Map all those bytes to the same pin. Use the top left to ensure // the node pointer is always properly initialized. - m.map.appendNTimes( + m.map.append( + m.alloc, + self.terminal.screens.active.pages.getTopLeft(.screen), + std.math.cast(usize, discarding.count) orelse return error.WriteFailed, + ) catch return error.WriteFailed; + } + } + + // Emit tabstop positions before the screen contents because setting + // them moves the cursor. Screen formatting will restore the requested + // cursor position afterwards. + if (self.opts.emit == .vt and self.extra.tabstops) { + // Clear all tabs (CSI 3 g) + try writer.print("\x1b[3g", .{}); + + // Set each configured tabstop by moving cursor and using HTS + for (0..self.terminal.cols) |col| { + if (self.terminal.tabstops.get(col)) { + // Move cursor to the column (1-indexed) + try writer.print("\x1b[{d}G", .{col + 1}); + // Set tab (HTS) + try writer.print("\x1bH", .{}); + } + } + + // Screen contents are formatted relative to the top-left. + try writer.writeAll("\x1b[H"); + + // If we have a pin_map, add the bytes we wrote to map. + if (self.pin_map) |*m| { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + var extra_formatter: TerminalFormatter = self; + extra_formatter.content = .none; + extra_formatter.pin_map = null; + extra_formatter.extra = .none; + extra_formatter.extra.tabstops = true; + try extra_formatter.format(&discarding.writer); + + // Map all those bytes to the same pin. Use the top left to ensure + // the node pointer is always properly initialized. + m.map.append( m.alloc, self.terminal.screens.active.pages.getTopLeft(.screen), std.math.cast(usize, discarding.count) orelse return error.WriteFailed, @@ -360,22 +509,6 @@ pub const TerminalFormatter = struct { } } - // Emit tabstop positions - if (self.extra.tabstops) { - // Clear all tabs (CSI 3 g) - try writer.print("\x1b[3g", .{}); - - // Set each configured tabstop by moving cursor and using HTS - for (0..self.terminal.cols) |col| { - if (self.terminal.tabstops.get(col)) { - // Move cursor to the column (1-indexed) - try writer.print("\x1b[{d}G", .{col + 1}); - // Set tab (HTS) - try writer.print("\x1bH", .{}); - } - } - } - // Emit keyboard modes such as ModifyOtherKeys if (self.extra.keyboard) { // Only emit if modify_other_keys_2 is true @@ -398,21 +531,14 @@ pub const TerminalFormatter = struct { extra_formatter.pin_map = null; extra_formatter.extra = .none; extra_formatter.extra.scrolling_region = self.extra.scrolling_region; - extra_formatter.extra.tabstops = self.extra.tabstops; extra_formatter.extra.keyboard = self.extra.keyboard; extra_formatter.extra.pwd = self.extra.pwd; try extra_formatter.format(&discarding.writer); - m.map.appendNTimes( + m.map.append( m.alloc, - if (m.map.items.len > 0) pin: { - const last = m.map.items[m.map.items.len - 1]; - break :pin .{ - .node = last.node, - .x = last.x, - .y = last.y, - }; - } else self.terminal.screens.active.pages.getTopLeft(.screen), + m.map.getLastOrNull() orelse + self.terminal.screens.active.pages.getTopLeft(.screen), std.math.cast(usize, discarding.count) orelse return error.WriteFailed, ) catch return error.WriteFailed; } @@ -671,21 +797,10 @@ pub const ScreenFormatter = struct { // Map all those bytes to the same pin. Use the first page node // to ensure the node pointer is always properly initialized. - m.map.appendNTimes( + m.map.append( m.alloc, - if (m.map.items.len > 0) pin: { - // There is a weird Zig miscompilation here on 0.15.2. - // If I return the m.map.items value directly then we - // get undefined memory (even though we're copying a - // Pin struct). If we duplicate here like this we do - // not. - const last = m.map.items[m.map.items.len - 1]; - break :pin .{ - .node = last.node, - .x = last.x, - .y = last.y, - }; - } else self.screen.pages.getTopLeft(.screen), + m.map.getLastOrNull() orelse + self.screen.pages.getTopLeft(.screen), std.math.cast(usize, discarding.count) orelse return error.WriteFailed, ) catch return error.WriteFailed; } @@ -737,10 +852,6 @@ pub const PageListFormatter = struct { const tl: PageList.Pin = self.top_left orelse self.list.getTopLeft(.screen); const br: PageList.Pin = self.bottom_right orelse self.list.getBottomRight(.screen).?; - // If we keep track of pins, we'll need this. - var point_map: std.ArrayList(Coordinate) = .empty; - defer if (self.pin_map) |*m| point_map.deinit(m.alloc); - var page_state: ?PageFormatter.TrailingState = null; var iter = tl.pageIterator(.right_down, br); while (iter.next()) |chunk| { @@ -763,27 +874,19 @@ pub const PageListFormatter = struct { if (chunk.node == br.node) formatter.end_x = br.x; } - // If we're tracking pins, then we setup a point map for the - // page formatter (cause it can't track pins). And then we convert - // this to pins later. + // If we're tracking pins, the page formatter writes its + // per-byte coordinates directly into our map's point list + // and we record which page node covers those bytes. if (self.pin_map) |*m| { - point_map.clearRetainingCapacity(); - formatter.point_map = .{ .alloc = m.alloc, .map = &point_map }; + m.map.setNode(m.alloc, chunk.node) catch return error.WriteFailed; + formatter.point_map = .{ + .alloc = m.alloc, + .map = &m.map.points, + .base = m.map.points.items.len, + }; } page_state = try formatter.formatWithState(writer); - - // If we're tracking pins then grab our points and write them - // to our pin map. - if (self.pin_map) |*m| { - for (point_map.items) |coord| { - m.map.append(m.alloc, .{ - .node = chunk.node, - .x = coord.x, - .y = @intCast(coord.y), - }) catch return error.WriteFailed; - } - } } } }; @@ -831,16 +934,25 @@ pub const PageFormatter = struct { /// The x/y coordinate will be the coordinates within the page. /// /// Warning: there is a significant performance hit to track this - point_map: ?struct { - alloc: Allocator, - map: *std.ArrayList(Coordinate), - }, + point_map: ?PointMap, /// The previous trailing state from the prior page. If you're iterating /// over multiple pages this helps ensure that unwrapping and other /// accounting works properly. trailing_state: ?TrailingState, + /// See point_map. + pub const PointMap = struct { + alloc: Allocator, + map: *std.ArrayList(Coordinate), + + /// The index in `map` at which this formatter's output begins. + /// Entries before this index belong to a caller (e.g. previous + /// pages of a PageListFormatter) and aren't inspected. This + /// exists so that callers can share one list across pages. + base: usize = 0, + }; + /// Trailing state. This is used to ensure that rows wrapped across /// multiple pages are unwrapped properly, as well as other accounting /// we may do in the future. @@ -877,6 +989,18 @@ pub const PageFormatter = struct { pub fn formatWithState( self: PageFormatter, writer: *std.Io.Writer, + ) std.Io.Writer.Error!TrailingState { + // Specialize the hot path on the emitted format so that the + // per-cell loop contains no per-cell format dispatch. + switch (self.opts.emit) { + inline else => |emit| return self.formatWithStateEmit(writer, emit), + } + } + + fn formatWithStateEmit( + self: PageFormatter, + writer: *std.Io.Writer, + comptime emit: Format, ) std.Io.Writer.Error!TrailingState { var blank_rows: usize = 0; var blank_cells: usize = 0; @@ -934,7 +1058,7 @@ pub const PageFormatter = struct { } // Wrap HTML output in monospace font styling - switch (self.opts.emit) { + switch (emit) { .plain => {}, .html => { @@ -994,8 +1118,23 @@ pub const PageFormatter = struct { }, } - // Our style for non-plain formats + // Our style for non-plain formats. Alongside the style itself we + // track the page-local interned style id it corresponds to (styles + // are interned per-page so id equality implies style equality). + // The id is only a fast-path hint: it is set to `invalid_style_id` + // whenever the current style didn't come from an interned id + // (e.g. bg-color-only cells which synthesize styles). + const invalid_style_id: u32 = std.math.maxInt(u32); var style: Style = .{}; + var style_id: u32 = 0; + + // Whether the codepoint map has any entries. Hoisted out of the + // per-codepoint path so that the common no-map case can use the + // fast cell run path below. + const cp_map_empty: bool = if (self.opts.codepoint_map) |m| + m.len == 0 + else + true; // Track hyperlink state for HTML output. We need to close tags // when the hyperlink changes or ends. @@ -1050,11 +1189,12 @@ pub const PageFormatter = struct { // Reset style before emitting newlines to prevent background // colors from bleeding into the next line's leading cells. if (!style.default()) { - try self.formatStyleClose(writer); + try self.formatStyleClose(emit, writer); style = .{}; + style_id = 0; } - const sequence: []const u8 = switch (self.opts.emit) { + const sequence: []const u8 = switch (emit) { // Plaintext just uses standard newlines because newlines // on their own usually move the cursor back in anywhere // you type plaintext. @@ -1077,7 +1217,7 @@ pub const PageFormatter = struct { // in a prior page, so we just map to the first row of this // page. if (self.point_map) |*map| { - const start: Coordinate = if (map.map.items.len > 0) + const start: Coordinate = if (map.map.items.len > map.base) map.map.items[map.map.items.len - 1] else .{ .x = 0, .y = 0 }; @@ -1113,8 +1253,56 @@ pub const PageFormatter = struct { if (!row.wrap_continuation or !self.opts.unwrap) blank_cells = 0; // Go through each cell and print it - for (cells_subset, row_start_x..) |*cell, x_usize| { - const x: size.CellCountInt = @intCast(x_usize); + var cell_i: usize = 0; + while (cell_i < cells_subset.len) : (cell_i += 1) { + const cell: *const Cell = &cells_subset[cell_i]; + const x: size.CellCountInt = @intCast(row_start_x + cell_i); + + // Fast path: runs of simple cells (single codepoint, no + // style/hyperlink transitions) are encoded in batches, + // avoiding all of the per-cell bookkeeping below. This is + // only valid when we have no codepoint map and when our + // current style/hyperlink state is known-stable. + if (cp_map_empty) fast: { + if (comptime formatStyled(emit)) { + if (style_id == invalid_style_id) break :fast; + } + + // Specialized on point tracking so that the common + // non-tracking case has zero per-cell overhead. + const consumed = if (self.point_map == null) + try self.writeCellRun( + emit, + false, + writer, + cells_subset[cell_i..], + x, + y, + style_id, + current_hyperlink_id, + &blank_cells, + ) + else + try self.writeCellRun( + emit, + true, + writer, + cells_subset[cell_i..], + x, + y, + style_id, + current_hyperlink_id, + &blank_cells, + ); + + // Zero cells consumed means the first cell isn't + // eligible for the fast path; handle it below. + if (consumed == 0) break :fast; + + // The continue expression adds the final one. + cell_i += consumed - 1; + continue; + } // Skip spacers. These happen naturally when wide characters // are printed again on the screen (for well-behaved terminals!) @@ -1130,8 +1318,9 @@ pub const PageFormatter = struct { // If we're emitting styled output (not plaintext) and // the cell has some kind of styling or is not empty // then this isn't blank. - if (formatStyled(self.opts.emit) and - (!cell.isEmpty() or cell.hasStyling())) break :blank; + if (comptime formatStyled(emit)) { + if (!cell.isEmpty() or cell.hasStyling()) break :blank; + } // Cells with no text are blank if (!cell.hasText()) { @@ -1153,32 +1342,12 @@ pub const PageFormatter = struct { if (blank_cells > 0) { try writer.splatByteAll(' ', blank_cells); - if (self.point_map) |*map| { - // Map each blank cell to its coordinate. Blank cells can span - // multiple rows if they carry over from wrap continuation. - var remaining_blanks = blank_cells; - var blank_x = x; - var blank_y = y; - while (remaining_blanks > 0) : (remaining_blanks -= 1) { - if (blank_x > 0) { - // We have space in this row - blank_x -= 1; - } else if (blank_y > 0) { - // Wrap to previous row - blank_y -= 1; - blank_x = self.page.size.cols - 1; - } else { - // Can't go back further, just use (0, 0) - blank_x = 0; - blank_y = 0; - } - - map.map.append( - map.alloc, - .{ .x = blank_x, .y = blank_y }, - ) catch return error.WriteFailed; - } - } + if (self.point_map) |*map| try self.appendBlankPoints( + map, + blank_cells, + x, + y, + ); blank_cells = 0; } @@ -1186,24 +1355,44 @@ pub const PageFormatter = struct { style: { // If we aren't emitting styled output then we don't // have to worry about styles. - if (!formatStyled(self.opts.emit)) break :style; + if (!comptime formatStyled(emit)) break :style; + + // Fast path: styles are interned per-page, so if this + // cell's style id matches the id of our current style + // then the style is unchanged. + const cell_style_id: u32 = switch (cell.content_tag) { + .codepoint, .codepoint_grapheme => cell.style_id, + .bg_color_palette, .bg_color_rgb => invalid_style_id, + }; + if (cell_style_id == style_id and + cell_style_id != invalid_style_id) break :style; // Get our cell style. const cell_style = self.cellStyle(cell); // If the style hasn't changed, don't bloat output. - if (cell_style.eql(style)) break :style; + // When both ids are interned (and thus different, since + // equal ids broke out above), interning guarantees the + // styles differ so we can skip the comparison entirely. + if (cell_style_id == invalid_style_id or + style_id == invalid_style_id) + { + if (cell_style.eql(style)) { + style_id = cell_style_id; + break :style; + } + } // If we had a previous style, we need to close it, // because we've confirmed we have some new style // (which is maybe default). - if (!style.default()) switch (self.opts.emit) { - .html => try self.formatStyleClose(writer), + if (!style.default()) switch (emit) { + .html => try self.formatStyleClose(emit, writer), // For VT, we only close if we're switching to a default // style because any non-default style will emit // a \x1b[0m as the start of a VT coloring sequence. - .vt => if (cell_style.default()) try self.formatStyleClose(writer), + .vt => if (cell_style.default()) try self.formatStyleClose(emit, writer), // Unreachable because of the styled() check at the // top of this block. @@ -1212,12 +1401,14 @@ pub const PageFormatter = struct { // At this point, we can copy our style over style = cell_style; + style_id = cell_style_id; // If we're just the default style now, we're done. if (cell_style.default()) break :style; // New style, emit it. try self.formatStyleOpen( + emit, writer, &style, ); @@ -1227,16 +1418,18 @@ pub const PageFormatter = struct { if (self.point_map) |*map| { var discarding: std.Io.Writer.Discarding = .init(&.{}); try self.formatStyleOpen( + emit, &discarding.writer, &style, ); - for (0..std.math.cast( - usize, - discarding.count, - ) orelse return error.WriteFailed) |_| map.map.append(map.alloc, .{ - .x = x, - .y = y, - }) catch return error.WriteFailed; + map.map.appendNTimes( + map.alloc, + .{ .x = x, .y = y }, + std.math.cast( + usize, + discarding.count, + ) orelse return error.WriteFailed, + ) catch return error.WriteFailed; } } @@ -1245,7 +1438,7 @@ pub const PageFormatter = struct { // We currently only emit hyperlinks for HTML. In the // future we can support emitting OSC 8 hyperlinks for // VT output as well. - if (self.opts.emit != .html) break :hyperlink; + if (comptime emit != .html) break :hyperlink; // Get the hyperlink ID. This ID is our internal ID, // not necessarily the OSC8 ID. @@ -1261,7 +1454,7 @@ pub const PageFormatter = struct { // If our prior hyperlink ID was non-null, we need to // close it because the ID has changed. if (current_hyperlink_id != null) { - try self.formatHyperlinkClose(writer); + try self.formatHyperlinkClose(emit, writer); current_hyperlink_id = null; } @@ -1278,6 +1471,7 @@ pub const PageFormatter = struct { break :uri link.uri.offset.ptr(self.page.memory)[0..link.uri.len]; }; try self.formatHyperlinkOpen( + emit, writer, uri, ); @@ -1287,16 +1481,18 @@ pub const PageFormatter = struct { if (self.point_map) |*map| { var discarding: std.Io.Writer.Discarding = .init(&.{}); try self.formatHyperlinkOpen( + emit, &discarding.writer, uri, ); - for (0..std.math.cast( - usize, - discarding.count, - ) orelse return error.WriteFailed) |_| map.map.append(map.alloc, .{ - .x = x, - .y = y, - }) catch return error.WriteFailed; + map.map.appendNTimes( + map.alloc, + .{ .x = x, .y = y }, + std.math.cast( + usize, + discarding.count, + ) orelse return error.WriteFailed, + ) catch return error.WriteFailed; } } @@ -1304,20 +1500,21 @@ pub const PageFormatter = struct { // We combine codepoint and graphemes because both have // shared style handling. We use comptime to dup it. inline .codepoint, .codepoint_grapheme => |tag| { - try self.writeCell(tag, writer, cell); + try self.writeCell(tag, emit, writer, cell); // If we have a point map, all codepoints map to this // cell. if (self.point_map) |*map| { var discarding: std.Io.Writer.Discarding = .init(&.{}); - try self.writeCell(tag, &discarding.writer, cell); - for (0..std.math.cast( - usize, - discarding.count, - ) orelse return error.WriteFailed) |_| map.map.append(map.alloc, .{ - .x = x, - .y = y, - }) catch return error.WriteFailed; + try self.writeCell(tag, emit, &discarding.writer, cell); + map.map.appendNTimes( + map.alloc, + .{ .x = x, .y = y }, + std.math.cast( + usize, + discarding.count, + ) orelse return error.WriteFailed, + ) catch return error.WriteFailed; } }, @@ -1335,13 +1532,13 @@ pub const PageFormatter = struct { } // If the style is non-default, we need to close our style tag. - if (!style.default()) try self.formatStyleClose(writer); + if (!style.default()) try self.formatStyleClose(emit, writer); // Close any open hyperlink for HTML output - if (current_hyperlink_id != null) try self.formatHyperlinkClose(writer); + if (current_hyperlink_id != null) try self.formatHyperlinkClose(emit, writer); // Close the monospace wrapper for HTML output - if (self.opts.emit == .html) { + if (comptime emit == .html) { const closing = ""; try writer.writeAll(closing); if (self.point_map) |*map| { @@ -1354,14 +1551,310 @@ pub const PageFormatter = struct { closing.len, ); } + // Closing the div creates a newline in the output + // so make sure to create one less newline + if (blank_rows >= 1) blank_rows -= 1; } return .{ .rows = blank_rows, .cells = blank_cells }; } + /// Fast path for writing runs of simple cells: single-codepoint cells + /// that require no style or hyperlink handling. Output bytes are + /// batched into a stack buffer to avoid per-cell writer dispatch. + /// Returns the number of cells consumed, which may be zero if the + /// first cell isn't eligible for the fast path (in which case the + /// caller must handle it via the slow path). + /// + /// Requirements (asserted by the caller, not here): + /// + /// - The codepoint map is empty. + /// - For styled formats, `run_style_id` is the valid interned + /// page-local id of the currently active style. + /// - For HTML, no hyperlink is currently open. + /// + /// `run_x`/`run_y` are the page coordinates of `cells[0]`, used for + /// point map tracking. + /// + /// Blank cell accounting matches the slow path: accumulated blanks + /// are only materialized once a non-blank cell is found, and any + /// remainder is written back to `blank_cells`. + fn writeCellRun( + self: *const PageFormatter, + comptime emit: Format, + comptime track_points: bool, + writer: *std.Io.Writer, + cells: []const Cell, + run_x: size.CellCountInt, + run_y: size.CellCountInt, + run_style_id: u32, + run_hyperlink_id: ?hyperlink.Id, + blank_cells: *usize, + ) std.Io.Writer.Error!usize { + assert(track_points == (self.point_map != null)); + + // The largest single-cell encoding must fit after a flush: the + // HTML entity for the maximum codepoint ("�") is 10 + // bytes, escapes are up to 6. + const max_encoding_len = 16; + var buf: [512]u8 = undefined; + var len: usize = 0; + var pending: usize = blank_cells.*; + + var i: usize = 0; + while (i < cells.len) : (i += 1) { + const cell = &cells[i]; + + // Spacers produce no output, matching the slow path which + // skips them before any blank/style handling. + switch (cell.wide) { + .narrow, .wide => {}, + .spacer_head, .spacer_tail => continue, + } + + // Only text cells: bg-color cells synthesize styles and take + // the slow path. + switch (cell.content_tag) { + .codepoint, .codepoint_grapheme => {}, + .bg_color_palette, .bg_color_rgb => break, + } + + if (comptime formatStyled(emit)) { + // Style transition, take the slow path. + if (cell.style_id != run_style_id) break; + } + + const cp: u21 = cell.content.codepoint.data; + + // Blank cell accounting, matching the slow path blank block. + if (comptime formatStyled(emit)) { + // Styled formats only treat unstyled empty cells as + // blank; anything else (including spaces) is written + // so that styling is preserved. + if (cp == 0 and cell.wide == .narrow and run_style_id == 0) { + pending += 1; + continue; + } + } else { + // Cells with no text are blank. + if (cp == 0) { + pending += 1; + continue; + } + + // Trailing spaces are blank. + if (cp == ' ' and self.opts.trim) { + pending += 1; + continue; + } + } + + // Hyperlink state must be stable within a run: any non-blank + // cell must belong to the currently open hyperlink (or none). + // Transitions take the slow path. This is checked after blank + // accounting because blank cells never touch hyperlink state. + if (comptime emit == .html) { + if (cell.hyperlink) { + const run_id = run_hyperlink_id orelse break; + const cell_id = self.page.lookupHyperlink(cell) orelse break; + if (cell_id != run_id) break; + } else if (run_hyperlink_id != null) break; + } + + // The page coordinate of this cell, for point tracking. + const x: size.CellCountInt = @intCast(run_x + i); + + // This cell produces output: materialize accumulated blanks. + if (pending > 0) { + if (comptime track_points) try self.appendBlankPoints( + &self.point_map.?, + pending, + x, + run_y, + ); + + while (pending > 0) { + if (len == buf.len) { + try writer.writeAll(buf[0..len]); + len = 0; + } + const n = @min(pending, buf.len - len); + @memset(buf[len..][0..n], ' '); + len += n; + pending -= n; + } + } + + // Flush if the largest possible encoding may not fit. + if (len + max_encoding_len > buf.len) { + try writer.writeAll(buf[0..len]); + len = 0; + } + + var cell_bytes: usize = 0; + + // Empty (but styled or wide) cells emit a space, matching + // writeCell. + if (cp == 0) { + buf[len] = ' '; + len += 1; + cell_bytes = 1; + } else { + cell_bytes = encodeCodepoint(emit, &buf, &len, cp); + + // Multi-codepoint graphemes emit their extra codepoints, + // matching writeCell. This is out-of-line to keep the + // hot loop for the common single-codepoint case small. + if (cell.content_tag == .codepoint_grapheme) { + @branchHint(.unlikely); + cell_bytes += try self.writeGraphemeCps( + emit, + writer, + cell, + &buf, + &len, + ); + } + } + + // All of the cell's bytes map to the cell's coordinate. + if (comptime track_points) { + const map = &self.point_map.?; + map.map.appendNTimes( + map.alloc, + .{ .x = x, .y = run_y }, + cell_bytes, + ) catch return error.WriteFailed; + } + } + + if (len > 0) try writer.writeAll(buf[0..len]); + blank_cells.* = pending; + return i; + } + + /// Encode the extra codepoints of a multi-codepoint grapheme into + /// buf, flushing to the writer as needed. Returns the number of + /// bytes written. This is deliberately not inlined so that the + /// (rare) grapheme case doesn't bloat the writeCellRun hot loop. + noinline fn writeGraphemeCps( + self: *const PageFormatter, + comptime emit: Format, + writer: *std.Io.Writer, + cell: *const Cell, + buf: *[512]u8, + len: *usize, + ) std.Io.Writer.Error!usize { + const max_encoding_len = 16; + var bytes: usize = 0; + for (self.page.lookupGrapheme(cell).?) |gcp| { + if (len.* + max_encoding_len > buf.len) { + try writer.writeAll(buf[0..len.*]); + len.* = 0; + } + bytes += encodeCodepoint(emit, buf, len, gcp); + } + return bytes; + } + + /// Encode a single codepoint into buf at len, advancing len and + /// returning the number of bytes written. The caller must guarantee + /// enough remaining buffer space for the largest possible encoding. + inline fn encodeCodepoint( + comptime emit: Format, + buf: *[512]u8, + len: *usize, + cp: u21, + ) usize { + const start = len.*; + switch (emit) { + .plain, .vt => if (cp < 0x80) { + buf[start] = @intCast(cp); + len.* += 1; + } else { + len.* += std.unicode.utf8Encode(cp, buf[start..][0..4]) catch l: { + // Matches Writer.printUnicodeCodepoint: invalid + // codepoints become the replacement character. + buf[start..][0..3].* = std.unicode.replacement_character_utf8; + break :l 3; + }; + }, + + .html => html: { + const esc: ?[]const u8 = switch (cp) { + '<' => "<", + '>' => ">", + '&' => "&", + '"' => """, + '\'' => "'", + else => null, + }; + if (esc) |s| { + @memcpy(buf[start..][0..s.len], s); + len.* += s.len; + break :html; + } + + // ASCII is emitted directly, everything else as a + // numeric entity. See writeCodepoint. + if (cp < 0x80) { + buf[start] = @intCast(cp); + len.* += 1; + break :html; + } + + buf[start..][0..2].* = "&#".*; + len.* += 2; + len.* += fastprint.printDecimal(u21, buf[len.*..], cp); + buf[len.*] = ';'; + len.* += 1; + }, + } + + return len.* - start; + } + + /// Append the point map entries for a run of `count` blank cells + /// that are materialized as spaces just before the cell at (x, y). + /// Blank cells can span multiple rows if they carry over from wrap + /// continuation, so this walks backwards from (x, y). + fn appendBlankPoints( + self: *const PageFormatter, + map: *const PointMap, + count: usize, + x: size.CellCountInt, + y: size.CellCountInt, + ) std.Io.Writer.Error!void { + map.map.ensureUnusedCapacity( + map.alloc, + count, + ) catch return error.WriteFailed; + + var remaining = count; + var blank_x = x; + var blank_y = y; + while (remaining > 0) : (remaining -= 1) { + if (blank_x > 0) { + // We have space in this row + blank_x -= 1; + } else if (blank_y > 0) { + // Wrap to previous row + blank_y -= 1; + blank_x = self.page.size.cols - 1; + } else { + // Can't go back further, just use (0, 0) + blank_x = 0; + blank_y = 0; + } + + map.map.appendAssumeCapacity(.{ .x = blank_x, .y = blank_y }); + } + } + fn writeCell( self: PageFormatter, comptime tag: Cell.ContentTag, + comptime emit: Format, writer: *std.Io.Writer, cell: *const Cell, ) !void { @@ -1373,16 +1866,17 @@ pub const PageFormatter = struct { return; } - try self.writeCodepointWithReplacement(writer, cell.content.codepoint.data); + try self.writeCodepointWithReplacement(emit, writer, cell.content.codepoint.data); if (comptime tag == .codepoint_grapheme) { for (self.page.lookupGrapheme(cell).?) |cp| { - try self.writeCodepointWithReplacement(writer, cp); + try self.writeCodepointWithReplacement(emit, writer, cp); } } } fn writeCodepointWithReplacement( self: PageFormatter, + comptime emit: Format, writer: *std.Io.Writer, codepoint: u21, ) !void { @@ -1404,12 +1898,14 @@ pub const PageFormatter = struct { // If no replacement, write it directly. const r = r_ orelse return try self.writeCodepoint( + emit, writer, codepoint, ); switch (r) { .codepoint => |v| try self.writeCodepoint( + emit, writer, v, ), @@ -1418,6 +1914,7 @@ pub const PageFormatter = struct { const view = std.unicode.Utf8View.init(s) catch unreachable; var it = view.iterator(); while (it.nextCodepoint()) |cp| try self.writeCodepoint( + emit, writer, cp, ); @@ -1427,11 +1924,13 @@ pub const PageFormatter = struct { fn writeCodepoint( self: PageFormatter, + comptime emit: Format, writer: *std.Io.Writer, codepoint: u21, ) !void { - switch (self.opts.emit) { - .plain, .vt => try writer.print("{u}", .{codepoint}), + _ = self; + switch (emit) { + .plain, .vt => try writer.printUnicodeCodepoint(codepoint), .html => { switch (codepoint) { '<' => try writer.writeAll("<"), @@ -1446,9 +1945,14 @@ pub const PageFormatter = struct { // meta tag because we emit partial HTML so this ensures // proper unicode handling. if (codepoint < 0x80) { - try writer.print("{u}", .{codepoint}); + try writer.writeByte(@intCast(codepoint)); } else { - try writer.print("&#{d};", .{codepoint}); + var buf: [16]u8 = undefined; + buf[0..2].* = "&#".*; + var len: usize = 2 + fastprint.printDecimal(u21, buf[2..], codepoint); + buf[len] = ';'; + len += 1; + try writer.writeAll(buf[0..len]); } }, } @@ -1493,16 +1997,17 @@ pub const PageFormatter = struct { /// and other HTML attribute values. fn formatStyleOpen( self: PageFormatter, + comptime emit: Format, writer: *std.Io.Writer, style: *const Style, ) std.Io.Writer.Error!void { - switch (self.opts.emit) { + switch (emit) { .plain => unreachable, .vt => { var formatter = style.formatterVt(); formatter.palette = self.opts.palette; - try writer.print("{f}", .{formatter}); + try formatter.format(writer); }, // We use `display: inline` so that the div doesn't impact @@ -1510,19 +2015,19 @@ pub const PageFormatter = struct { .html => { var formatter = style.formatterHtml(); formatter.palette = self.opts.palette; - try writer.print( - "
", - .{formatter}, - ); + try writer.writeAll("
"); }, } } fn formatStyleClose( self: PageFormatter, + comptime emit: Format, writer: *std.Io.Writer, ) std.Io.Writer.Error!void { - const str: []const u8 = switch (self.opts.emit) { + const str: []const u8 = switch (emit) { .plain => return, .vt => "\x1b[0m", .html => "
", @@ -1544,16 +2049,18 @@ pub const PageFormatter = struct { fn formatHyperlinkOpen( self: PageFormatter, + comptime emit: Format, writer: *std.Io.Writer, uri: []const u8, ) std.Io.Writer.Error!void { - switch (self.opts.emit) { + switch (emit) { .plain, .vt => unreachable, // layout since we're primarily using it as a CSS wrapper. .html => { try writer.writeAll(" "", .plain, .vt => return, }; @@ -3628,7 +4136,7 @@ test "PageList plain single line" { s.nextSlice("hello, world"); - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: PageListFormatter = .init(&t.screens.active.pages, .plain); @@ -3638,11 +4146,11 @@ test "PageList plain single line" { try testing.expectEqualStrings("hello, world", output); // Verify pin map - try testing.expectEqual(output.len, pin_map.items.len); + try testing.expectEqual(output.len, pin_map.count()); const node = t.screens.active.pages.pages.first.?; for (0..output.len) |i| try testing.expectEqual( Pin{ .node = node, .x = @intCast(i), .y = 0 }, - pin_map.items[i], + pin_map.get(i).?, ); } @@ -3681,7 +4189,7 @@ test "PageList plain spanning two pages" { s.nextSlice("page two"); // Format the entire PageList - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: PageListFormatter = .init(pages, .plain); @@ -3692,31 +4200,31 @@ test "PageList plain spanning two pages" { try testing.expectEqualStrings("page one\npage two", output); // Verify pin map - try testing.expectEqual(full_output.len, pin_map.items.len); + try testing.expectEqual(full_output.len, pin_map.count()); const first_node = pages.pages.first.?; const last_node = pages.pages.last.?; const trimmed_count = full_output.len - output.len; // First part (trimmed blank lines) maps to first node for (0..trimmed_count) |i| { - try testing.expectEqual(first_node, pin_map.items[i].node); + try testing.expectEqual(first_node, pin_map.get(i).?.node); } // "page one" (8 chars) maps to first node for (0..8) |i| { const idx = trimmed_count + i; - try testing.expectEqual(first_node, pin_map.items[idx].node); - try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); + try testing.expectEqual(first_node, pin_map.get(idx).?.node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(idx).?.x); } // \n - maps to last node as it represents the transition to new page - try testing.expectEqual(last_node, pin_map.items[trimmed_count + 8].node); + try testing.expectEqual(last_node, pin_map.get(trimmed_count + 8).?.node); // "page two" (8 chars) maps to last node for (0..8) |i| { const idx = trimmed_count + 9 + i; - try testing.expectEqual(last_node, pin_map.items[idx].node); - try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); + try testing.expectEqual(last_node, pin_map.get(idx).?.node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(idx).?.x); } } @@ -3748,7 +4256,7 @@ test "PageList soft-wrapped line spanning two pages without unwrap" { try testing.expect(pages.pages.first != pages.pages.last); // Format without unwrap - should show line breaks - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: PageListFormatter = .init(pages, .plain); @@ -3759,29 +4267,29 @@ test "PageList soft-wrapped line spanning two pages without unwrap" { try testing.expectEqualStrings("hello worl\nd test", output); // Verify pin map - try testing.expectEqual(full_output.len, pin_map.items.len); + try testing.expectEqual(full_output.len, pin_map.count()); const first_node = pages.pages.first.?; const last_node = pages.pages.last.?; const trimmed_count = full_output.len - output.len; // First part (trimmed blank lines) maps to first node for (0..trimmed_count) |i| { - try testing.expectEqual(first_node, pin_map.items[i].node); + try testing.expectEqual(first_node, pin_map.get(i).?.node); } // First line maps to first node for (0..10) |i| { const idx = trimmed_count + i; - try testing.expectEqual(first_node, pin_map.items[idx].node); + try testing.expectEqual(first_node, pin_map.get(idx).?.node); } // \n - maps to last node as it represents the transition to new page - try testing.expectEqual(last_node, pin_map.items[trimmed_count + 10].node); + try testing.expectEqual(last_node, pin_map.get(trimmed_count + 10).?.node); // "d test" (6 chars) maps to last node for (0..6) |i| { const idx = trimmed_count + 11 + i; - try testing.expectEqual(last_node, pin_map.items[idx].node); + try testing.expectEqual(last_node, pin_map.get(idx).?.node); } } @@ -3813,7 +4321,7 @@ test "PageList soft-wrapped line spanning two pages with unwrap" { try testing.expect(pages.pages.first != pages.pages.last); // Format with unwrap - should join the wrapped lines - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: PageListFormatter = .init(pages, .{ .emit = .plain, .unwrap = true }); @@ -3824,26 +4332,26 @@ test "PageList soft-wrapped line spanning two pages with unwrap" { try testing.expectEqualStrings("hello world test", output); // Verify pin map - try testing.expectEqual(full_output.len, pin_map.items.len); + try testing.expectEqual(full_output.len, pin_map.count()); const first_node = pages.pages.first.?; const last_node = pages.pages.last.?; const trimmed_count = full_output.len - output.len; // First part (trimmed blank lines) maps to first node for (0..trimmed_count) |i| { - try testing.expectEqual(first_node, pin_map.items[i].node); + try testing.expectEqual(first_node, pin_map.get(i).?.node); } // First line from first page for (0..10) |i| { const idx = trimmed_count + i; - try testing.expectEqual(first_node, pin_map.items[idx].node); + try testing.expectEqual(first_node, pin_map.get(idx).?.node); } // "d test" (6 chars) from last page for (0..6) |i| { const idx = trimmed_count + 10 + i; - try testing.expectEqual(last_node, pin_map.items[idx].node); + try testing.expectEqual(last_node, pin_map.get(idx).?.node); } } @@ -3882,7 +4390,7 @@ test "PageList VT spanning two pages" { s.nextSlice("page two"); // Format the entire PageList with VT - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: PageListFormatter = .init(pages, .vt); @@ -3893,14 +4401,15 @@ test "PageList VT spanning two pages" { try testing.expectEqualStrings("\x1b[0m\x1b[1mpage one\x1b[0m\r\n\x1b[0m\x1b[1mpage two\x1b[0m", output); // Verify pin map - try testing.expectEqual(full_output.len, pin_map.items.len); + try testing.expectEqual(full_output.len, pin_map.count()); const first_node = pages.pages.first.?; const last_node = pages.pages.last.?; // Just verify we have entries for both pages in the pin map var first_count: usize = 0; var last_count: usize = 0; - for (pin_map.items) |pin| { + for (0..pin_map.count()) |byte_i| { + const pin = pin_map.get(byte_i).?; if (pin.node == first_node) first_count += 1; if (pin.node == last_node) last_count += 1; } @@ -3930,7 +4439,7 @@ test "PageList plain with x offset on single page" { const pages = &t.screens.active.pages; const node = pages.pages.first.?; - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: PageListFormatter = .init(pages, .plain); @@ -3943,15 +4452,16 @@ test "PageList plain with x offset on single page" { try testing.expectEqualStrings("world\ntest case\nfoo", output); // Verify pin map - try testing.expectEqual(output.len, pin_map.items.len); - for (pin_map.items) |pin| { + try testing.expectEqual(output.len, pin_map.count()); + for (0..pin_map.count()) |byte_i| { + const pin = pin_map.get(byte_i).?; try testing.expectEqual(node, pin.node); } // "world" starts at x=6, y=0 for (0..5) |i| { - try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.items[i].x); - try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); + try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.get(i).?.x); + try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.get(i).?.y); } } @@ -3991,7 +4501,7 @@ test "PageList plain with x offset spanning two pages" { const first_node = pages.pages.first.?; const last_node = pages.pages.last.?; - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: PageListFormatter = .init(pages, .plain); @@ -4005,24 +4515,24 @@ test "PageList plain with x offset spanning two pages" { try testing.expectEqualStrings("world\nfoo", output); // Verify pin map - try testing.expectEqual(full_output.len, pin_map.items.len); + try testing.expectEqual(full_output.len, pin_map.count()); const trimmed_count = full_output.len - output.len; // "world" (5 chars) from first page for (0..5) |i| { const idx = trimmed_count + i; - try testing.expectEqual(first_node, pin_map.items[idx].node); - try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.items[idx].x); + try testing.expectEqual(first_node, pin_map.get(idx).?.node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.get(idx).?.x); } // \n - maps to last node as it represents the transition to new page - try testing.expectEqual(last_node, pin_map.items[trimmed_count + 5].node); + try testing.expectEqual(last_node, pin_map.get(trimmed_count + 5).?.node); // "foo" (3 chars) from last page for (0..3) |i| { const idx = trimmed_count + 6 + i; - try testing.expectEqual(last_node, pin_map.items[idx].node); - try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); + try testing.expectEqual(last_node, pin_map.get(idx).?.node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(idx).?.x); } } @@ -4048,7 +4558,7 @@ test "PageList plain with start_x only" { const pages = &t.screens.active.pages; const node = pages.pages.first.?; - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: PageListFormatter = .init(pages, .plain); @@ -4060,11 +4570,11 @@ test "PageList plain with start_x only" { try testing.expectEqualStrings("world", output); // Verify pin map - try testing.expectEqual(output.len, pin_map.items.len); + try testing.expectEqual(output.len, pin_map.count()); for (0..5) |i| { - try testing.expectEqual(node, pin_map.items[i].node); - try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.items[i].x); - try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); + try testing.expectEqual(node, pin_map.get(i).?.node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.get(i).?.x); + try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.get(i).?.y); } } @@ -4090,7 +4600,7 @@ test "PageList plain with end_x only" { const pages = &t.screens.active.pages; const node = pages.pages.first.?; - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: PageListFormatter = .init(pages, .plain); @@ -4102,23 +4612,23 @@ test "PageList plain with end_x only" { try testing.expectEqualStrings("hello world\ntes", output); // Verify pin map - try testing.expectEqual(output.len, pin_map.items.len); + try testing.expectEqual(output.len, pin_map.count()); // "hello world" (11 chars) on y=0 for (0..11) |i| { - try testing.expectEqual(node, pin_map.items[i].node); - try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x); - try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); + try testing.expectEqual(node, pin_map.get(i).?.node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(i).?.x); + try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.get(i).?.y); } // \n - try testing.expectEqual(node, pin_map.items[11].node); + try testing.expectEqual(node, pin_map.get(11).?.node); // "tes" (3 chars) on y=1 for (0..3) |i| { - try testing.expectEqual(node, pin_map.items[12 + i].node); - try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[12 + i].x); - try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[12 + i].y); + try testing.expectEqual(node, pin_map.get(12 + i).?.node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(12 + i).?.x); + try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.get(12 + i).?.y); } } @@ -4373,7 +4883,7 @@ test "TerminalFormatter plain with pin_map" { s.nextSlice("hello, world"); - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: TerminalFormatter = .init(&t, .plain); @@ -4384,11 +4894,11 @@ test "TerminalFormatter plain with pin_map" { try testing.expectEqualStrings("hello, world", output); // Verify pin map - try testing.expectEqual(output.len, pin_map.items.len); + try testing.expectEqual(output.len, pin_map.count()); const node = t.screens.active.pages.pages.first.?; for (0..output.len) |i| try testing.expectEqual( Pin{ .node = node, .x = @intCast(i), .y = 0 }, - pin_map.items[i], + pin_map.get(i).?, ); } @@ -4411,7 +4921,7 @@ test "TerminalFormatter plain multiline with pin_map" { s.nextSlice("hello\r\nworld"); - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: TerminalFormatter = .init(&t, .plain); @@ -4422,22 +4932,22 @@ test "TerminalFormatter plain multiline with pin_map" { try testing.expectEqualStrings("hello\nworld", output); // Verify pin map - try testing.expectEqual(output.len, pin_map.items.len); + try testing.expectEqual(output.len, pin_map.count()); const node = t.screens.active.pages.pages.first.?; // "hello" (5 chars) for (0..5) |i| { - try testing.expectEqual(node, pin_map.items[i].node); - try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x); - try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); + try testing.expectEqual(node, pin_map.get(i).?.node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(i).?.x); + try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.get(i).?.y); } // "\n" maps to end of first line - try testing.expectEqual(node, pin_map.items[5].node); + try testing.expectEqual(node, pin_map.get(5).?.node); // "world" (5 chars) for (0..5) |i| { const idx = 6 + i; - try testing.expectEqual(node, pin_map.items[idx].node); - try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); - try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[idx].y); + try testing.expectEqual(node, pin_map.get(idx).?.node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(idx).?.x); + try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.get(idx).?.y); } } @@ -4462,7 +4972,7 @@ test "TerminalFormatter vt with palette and pin_map" { s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); s.nextSlice("test"); - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: TerminalFormatter = .init(&t, .vt); @@ -4472,10 +4982,10 @@ test "TerminalFormatter vt with palette and pin_map" { const output = builder.writer.buffered(); // Verify pin map - palette bytes should be mapped to top left - try testing.expectEqual(output.len, pin_map.items.len); + try testing.expectEqual(output.len, pin_map.count()); const node = t.screens.active.pages.pages.first.?; for (0..output.len) |i| { - try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(node, pin_map.get(i).?.node); } } @@ -4498,7 +5008,7 @@ test "TerminalFormatter with selection and pin_map" { s.nextSlice("line1\r\nline2\r\nline3"); - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: TerminalFormatter = .init(&t, .plain); @@ -4514,13 +5024,13 @@ test "TerminalFormatter with selection and pin_map" { try testing.expectEqualStrings("line2", output); // Verify pin map - try testing.expectEqual(output.len, pin_map.items.len); + try testing.expectEqual(output.len, pin_map.count()); const node = t.screens.active.pages.pages.first.?; // "line2" (5 chars) from row 1 for (0..5) |i| { - try testing.expectEqual(node, pin_map.items[i].node); - try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x); - try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[i].y); + try testing.expectEqual(node, pin_map.get(i).?.node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(i).?.x); + try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.get(i).?.y); } } @@ -4543,7 +5053,7 @@ test "Screen plain single line" { s.nextSlice("hello, world"); - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: ScreenFormatter = .init(t.screens.active, .plain); @@ -4554,11 +5064,11 @@ test "Screen plain single line" { try testing.expectEqualStrings("hello, world", output); // Verify pin map - try testing.expectEqual(output.len, pin_map.items.len); + try testing.expectEqual(output.len, pin_map.count()); const node = t.screens.active.pages.pages.first.?; for (0..output.len) |i| try testing.expectEqual( Pin{ .node = node, .x = @intCast(i), .y = 0 }, - pin_map.items[i], + pin_map.get(i).?, ); } @@ -4581,7 +5091,7 @@ test "Screen plain multiline" { s.nextSlice("hello\r\nworld"); - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: ScreenFormatter = .init(t.screens.active, .plain); @@ -4592,22 +5102,22 @@ test "Screen plain multiline" { try testing.expectEqualStrings("hello\nworld", output); // Verify pin map - try testing.expectEqual(output.len, pin_map.items.len); + try testing.expectEqual(output.len, pin_map.count()); const node = t.screens.active.pages.pages.first.?; // "hello" (5 chars) for (0..5) |i| { - try testing.expectEqual(node, pin_map.items[i].node); - try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x); - try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); + try testing.expectEqual(node, pin_map.get(i).?.node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(i).?.x); + try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.get(i).?.y); } // "\n" maps to end of first line - try testing.expectEqual(node, pin_map.items[5].node); + try testing.expectEqual(node, pin_map.get(5).?.node); // "world" (5 chars) for (0..5) |i| { const idx = 6 + i; - try testing.expectEqual(node, pin_map.items[idx].node); - try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); - try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[idx].y); + try testing.expectEqual(node, pin_map.get(idx).?.node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(idx).?.x); + try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.get(idx).?.y); } } @@ -4630,7 +5140,7 @@ test "Screen plain with selection" { s.nextSlice("line1\r\nline2\r\nline3"); - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: ScreenFormatter = .init(t.screens.active, .plain); @@ -4646,13 +5156,13 @@ test "Screen plain with selection" { try testing.expectEqualStrings("line2", output); // Verify pin map - try testing.expectEqual(output.len, pin_map.items.len); + try testing.expectEqual(output.len, pin_map.count()); const node = t.screens.active.pages.pages.first.?; // "line2" (5 chars) from row 1 for (0..5) |i| { - try testing.expectEqual(node, pin_map.items[i].node); - try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x); - try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[i].y); + try testing.expectEqual(node, pin_map.get(i).?.node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(i).?.x); + try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.get(i).?.y); } } @@ -4676,7 +5186,7 @@ test "Screen vt with cursor position" { // Position cursor at a specific location s.nextSlice("hello\r\nworld"); - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: ScreenFormatter = .init(t.screens.active, .vt); @@ -4703,16 +5213,16 @@ test "Screen vt with cursor position" { try testing.expectEqual(t.screens.active.cursor.y, t2.screens.active.cursor.y); // Verify pin map - the extras should be mapped to the last pin - try testing.expectEqual(output.len, pin_map.items.len); + try testing.expectEqual(output.len, pin_map.count()); const node = t.screens.active.pages.pages.first.?; const content_len = "hello\r\nworld".len; // Content bytes map to their positions for (0..content_len) |i| { - try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(node, pin_map.get(i).?.node); } // Extra bytes (cursor position) map to last content pin for (content_len..output.len) |i| { - try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(node, pin_map.get(i).?.node); } } @@ -4736,7 +5246,7 @@ test "Screen vt with style" { // Set some style attributes s.nextSlice("\x1b[1;31mhello"); - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: ScreenFormatter = .init(t.screens.active, .vt); @@ -4762,10 +5272,10 @@ test "Screen vt with style" { try testing.expect(t.screens.active.cursor.style.eql(t2.screens.active.cursor.style)); // Verify pin map - try testing.expectEqual(output.len, pin_map.items.len); + try testing.expectEqual(output.len, pin_map.count()); const node = t.screens.active.pages.pages.first.?; for (0..output.len) |i| { - try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(node, pin_map.get(i).?.node); } } @@ -4789,7 +5299,7 @@ test "Screen vt with hyperlink" { // Set a hyperlink s.nextSlice("\x1b]8;;http://example.com\x1b\\hello"); - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: ScreenFormatter = .init(t.screens.active, .vt); @@ -4823,10 +5333,10 @@ test "Screen vt with hyperlink" { } // Verify pin map - try testing.expectEqual(output.len, pin_map.items.len); + try testing.expectEqual(output.len, pin_map.count()); const node = t.screens.active.pages.pages.first.?; for (0..output.len) |i| { - try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(node, pin_map.get(i).?.node); } } @@ -4850,7 +5360,7 @@ test "Screen vt with protection" { // Enable protection mode s.nextSlice("\x1b[1\"qhello"); - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: ScreenFormatter = .init(t.screens.active, .vt); @@ -4876,10 +5386,10 @@ test "Screen vt with protection" { try testing.expectEqual(t.screens.active.cursor.protected, t2.screens.active.cursor.protected); // Verify pin map - try testing.expectEqual(output.len, pin_map.items.len); + try testing.expectEqual(output.len, pin_map.count()); const node = t.screens.active.pages.pages.first.?; for (0..output.len) |i| { - try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(node, pin_map.get(i).?.node); } } @@ -4903,7 +5413,7 @@ test "Screen vt with kitty keyboard" { // Set kitty keyboard flags (disambiguate + report_events = 3) s.nextSlice("\x1b[=3;1uhello"); - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: ScreenFormatter = .init(t.screens.active, .vt); @@ -4931,10 +5441,10 @@ test "Screen vt with kitty keyboard" { try testing.expectEqual(flags1, flags2); // Verify pin map - try testing.expectEqual(output.len, pin_map.items.len); + try testing.expectEqual(output.len, pin_map.count()); const node = t.screens.active.pages.pages.first.?; for (0..output.len) |i| { - try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(node, pin_map.get(i).?.node); } } @@ -4958,7 +5468,7 @@ test "Screen vt with charsets" { // Set G0 to DEC special and shift to G1 s.nextSlice("\x1b(0\x0ehello"); - var pin_map: std.ArrayList(Pin) = .empty; + var pin_map: PinMap.Map = .empty; defer pin_map.deinit(alloc); var formatter: ScreenFormatter = .init(t.screens.active, .vt); @@ -4989,10 +5499,10 @@ test "Screen vt with charsets" { ); // Verify pin map - try testing.expectEqual(output.len, pin_map.items.len); + try testing.expectEqual(output.len, pin_map.count()); const node = t.screens.active.pages.pages.first.?; for (0..output.len) |i| { - try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(node, pin_map.get(i).?.node); } } @@ -5110,10 +5620,15 @@ test "Terminal vt with tabstops" { s.nextSlice("\x1b[5G\x1bH"); // Set tab at column 5 s.nextSlice("\x1b[15G\x1bH"); // Set tab at column 15 s.nextSlice("\x1b[30G\x1bH"); // Set tab at column 30 - s.nextSlice("hello"); + s.nextSlice("\x1b[Hhello"); + + var pin_map: PinMap.Map = .empty; + defer pin_map.deinit(alloc); var formatter: TerminalFormatter = .init(&t, .vt); formatter.extra.tabstops = true; + formatter.extra.screen.cursor = true; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); const output = builder.writer.buffered(); @@ -5138,6 +5653,22 @@ test "Terminal vt with tabstops" { try testing.expect(t2.tabstops.get(14)); // Column 15 (1-indexed) try testing.expect(t2.tabstops.get(29)); // Column 30 (1-indexed) try testing.expect(!t2.tabstops.get(8)); // Not a tab + + // Tabstop serialization must not offset the screen contents. + for ("hello", 0..) |expected, col| { + const cell = t2.screens.active.pages.getCell(.{ + .screen = .{ .x = @intCast(col), .y = 0 }, + }).?; + try testing.expectEqual(expected, cell.cell.codepoint()); + } + + // Emitting tabstops moves the cursor to each configured column. When + // cursor state is included, it must be restored afterwards. + try testing.expectEqual(t.screens.active.cursor.x, t2.screens.active.cursor.x); + try testing.expectEqual(t.screens.active.cursor.y, t2.screens.active.cursor.y); + + // Verify the reordered terminal state is still represented in the map. + try testing.expectEqual(output.len, pin_map.count()); } test "Terminal vt with keyboard modes" { @@ -5511,6 +6042,43 @@ test "Page html with unicode as numeric entities" { ); } +test "Page html trailing blank lines" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(io, alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + s.nextSlice("hello\r\nworld\r\n\r\n"); + + const pages = &t.screens.active.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = pages.pages.last.?.page(); + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + + // The closing div behaves as a newline + try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows); + try testing.expectEqualStrings( + "
hello\nworld
", + output, + ); +} + test "Page html ascii characters unchanged" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/kitty/graphics_command.zig b/src/terminal/kitty/graphics_command.zig index ce791072b..aa1bef5e9 100644 --- a/src/terminal/kitty/graphics_command.zig +++ b/src/terminal/kitty/graphics_command.zig @@ -1,25 +1,61 @@ const std = @import("std"); const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; const simd = @import("../../simd/main.zig"); const lib = @import("../lib.zig"); const log = std.log.scoped(.kitty_gfx); /// The key-value pairs for the control information for a command. The -/// keys are always single characters and the values are either single -/// characters or 32-bit unsigned integers. +/// recognized keys are always single ASCII letters and the values are either +/// single characters or 32-bit unsigned integers. Unknown non-letter keys are +/// ignored, matching how unknown letter keys are ignored when building a +/// command. /// -/// For the value of this: if the value is a single printable ASCII character -/// it is the ASCII code. Otherwise, it is parsed as a 32-bit unsigned integer. -const KV = std.AutoHashMapUnmanaged(u8, u32); +/// This is deliberately a dense value table plus a presence bitmap. There are +/// only 52 possible keys(technically KIP has fever than 52 possible .. at the moment of writing), so a lookup is one machine-word bit test followed by +/// an indexed load. The values are undefined until their presence bit is set. +/// +/// Other alternatives were considered and should probably be revisited in the +/// future. Some of them were: +/// A `[52]?u32` table is 416 bytes versus 216 bytes for this layout, and +/// its larger copies erased the smaller lookup code. +/// `std.EnumMap` added optional-return lowering, +/// `std.StaticBitSet(52)` kept the same size but generated larger code +/// for the packed mask. Passing the table by pointer removed the copies +/// but did not improve end-to-end throughput. Keep this representation +/// unless a new benchmark shows an improvement in the zig compilers codegen. +/// +/// For the value itself: if it is a single printable ASCII character it is the +/// ASCII code. Otherwise, it is parsed as a 32-bit unsigned integer. +const KV = struct { + values: [52]u32 = undefined, + present: u64 = 0, + + fn index(key: u8) ?u6 { + return switch (key) { + 'a'...'z' => @intCast(key - 'a'), + 'A'...'Z' => @intCast(26 + key - 'A'), + else => null, + }; + } + + fn get(self: *const KV, key: u8) ?u32 { + const idx = index(key) orelse return null; + if (self.present & (@as(u64, 1) << idx) == 0) return null; + return self.values[idx]; + } + + fn put(self: *KV, key: u8, value: u32) void { + const idx = index(key) orelse return; + self.values[idx] = value; + self.present |= @as(u64, 1) << idx; + } +}; /// Command parser parses the Kitty graphics protocol escape sequence. pub const Parser = struct { - /// The memory used by the parser is stored in an arena because it is - /// all freed at the end of the command. - arena: ArenaAllocator, + alloc: Allocator, /// This is the list of KV pairs that we're building up. kv: KV, @@ -60,10 +96,8 @@ pub const Parser = struct { /// Initialize the parser. The allocator given will be used for both /// temporary data and long-lived values such as the final image blob. pub fn init(alloc: Allocator, max_bytes: usize) Parser { - var arena = ArenaAllocator.init(alloc); - errdefer arena.deinit(); var result: Parser = .{ - .arena = arena, + .alloc = alloc, .data = .empty, .kv = .{}, .kv_temp_len = 0, @@ -82,9 +116,7 @@ pub const Parser = struct { } pub fn deinit(self: *Parser) void { - // We don't free the hash map because its in the arena - self.data.deinit(self.arena.child_allocator); - self.arena.deinit(); + self.data.deinit(self.alloc); } /// Parse a complete command string. @@ -144,7 +176,7 @@ pub const Parser = struct { .data => { if (self.data.items.len >= self.max_bytes) return error.OutOfMemory; - try self.data.append(self.arena.child_allocator, c); + try self.data.append(self.alloc, c); }, } } @@ -160,7 +192,7 @@ pub const Parser = struct { if (self.data.items.len + rem.len > self.max_bytes) { return error.OutOfMemory; } - try self.data.appendSlice(self.arena.child_allocator, rem); + try self.data.appendSlice(self.alloc, rem); return; } @@ -269,8 +301,6 @@ pub const Parser = struct { } fn finishValue(self: *Parser, next_state: State) !void { - const alloc = self.arena.allocator(); - // We can move states right away, we don't use it. self.state = next_state; @@ -278,7 +308,7 @@ pub const Parser = struct { if (self.kv_temp_len == 1) { const c = self.kv_temp[0]; if (c < '0' or c > '9') { - try self.kv.put(alloc, self.kv_current, @intCast(c)); + self.kv.put(self.kv_current, @intCast(c)); self.kv_temp_len = 0; return; } @@ -291,7 +321,7 @@ pub const Parser = struct { 'z', 'H', 'V' => @bitCast(try std.fmt.parseInt(i32, self.kv_temp[0..self.kv_temp_len], 10)), else => try std.fmt.parseInt(u32, self.kv_temp[0..self.kv_temp_len], 10), }; - try self.kv.put(alloc, self.kv_current, v); + self.kv.put(self.kv_current, v); // Clear our temp buffer self.kv_temp_len = 0; @@ -1221,6 +1251,24 @@ test "ignore unknown keys (long)" { try testing.expectEqual(@as(u32, 20), v.height); } +test "ignore unknown keys (non-letter)" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc, 1024 * 1024); + defer p.deinit(); + + const input = "f=24,s=10,v=20,!=1"; + for (input) |c| try p.feed(c); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit); + const v = command.control.transmit; + try testing.expectEqual(Transmission.Format.rgb, v.format); + try testing.expectEqual(@as(u32, 10), v.width); + try testing.expectEqual(@as(u32, 20), v.height); +} + test "ignore very long values" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index e0756ccc7..03a0882a6 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -172,7 +172,7 @@ fn transmit( // If the loaded image was assigned its ID automatically, not based // on a number or explicitly specified ID, then we don't respond. - if (load.image.implicit_id) return .{}; + if (load.image.metadata.implicit_id) return .{}; // After the image is added, set the ID in case it changed. // The resulting image number and placement ID never change. @@ -256,6 +256,7 @@ fn display( storage.addPlacement( io, alloc, + terminal.screens.active, img.id, result.placement_id, p, @@ -273,14 +274,21 @@ fn display( .after => { // We use terminal.index to properly handle scroll regions. const size = p.gridSize(img, terminal); - for (0..size.rows) |_| terminal.index() catch |err| { + // Once the requested movement leaves the screen, its exact + // position is undefined by the Kitty graphics protocol. Bound + // the work so an untrusted row count can't make us spin. + const rows_to_move: usize = @min( + @as(usize, size.rows), + @as(usize, terminal.rows), + ); + for (0..rows_to_move) |_| terminal.index() catch |err| { log.warn("failed to move cursor: {}", .{err}); break; }; terminal.setCursorPos( terminal.screens.active.cursor.y, - pin.x + size.cols + 1, + @as(usize, pin.x) +| @as(usize, size.cols) +| 1, ); }, }, @@ -347,8 +355,8 @@ fn loadAndAddImage( storage.next_image_id +%= 1; // If the image also has no number then its auto-ID is "implicit". - // See the doc comment on the Image.implicit_id field for more detail. - if (loading.image.number == 0) loading.image.implicit_id = true; + // See the doc comment on Image.metadata.implicit_id for more detail. + if (loading.image.number == 0) loading.image.metadata.implicit_id = true; } // If this is chunked, this is the beginning of a new chunked transmission. @@ -369,7 +377,7 @@ fn loadAndAddImage( // Validate and store our image var img = try loading.complete(alloc); errdefer img.deinit(alloc); - try storage.addImage(io, alloc, img); + try storage.addImage(io, alloc, terminal.screens.active, img); // Get our display settings const display_ = loading.display; @@ -628,6 +636,86 @@ test "kittygfx retransmit same id gets fresh image generation" { try testing.expectEqual(gen2, storage.generation); } +test "kittygfx retransmit same id removes existing placements" { + const testing = std.testing; + const io = testing.io; + const alloc = testing.allocator; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + const tracked = t.screens.active.pages.countTrackedPins(); + + // Transmit and display an image, then add anonymous and named placements. + // Multiple anonymous a=p placements for one image are explicitly valid. + { + const cmd = try command.Parser.parseString( + alloc, + "a=T,t=d,f=24,i=1,s=1,v=2,C=1;////////", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } + { + const cmd = try command.Parser.parseString(alloc, "a=p,i=1,C=1"); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } + { + const cmd = try command.Parser.parseString(alloc, "a=p,i=1,p=7,C=1"); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } + try testing.expectEqual(@as(usize, 3), storage.placements.count()); + try testing.expectEqual( + tracked + 3, + t.screens.active.pages.countTrackedPins(), + ); + + // Retransmitting replaces the image and must delete every old placement. + // Plain a=t creates no replacement placement of its own. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } + try testing.expectEqual(@as(usize, 0), storage.placements.count()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); + + // a=T creates one new placement after deleting the previous image and + // placements, so repeated redraws remain bounded at one placement. + { + const cmd = try command.Parser.parseString( + alloc, + "a=T,t=d,f=24,i=1,s=1,v=2,C=1;////////", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } + { + const cmd = try command.Parser.parseString( + alloc, + "a=T,t=d,f=24,i=1,s=1,v=2,C=1;AAAAAAAA", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } + try testing.expectEqual(@as(usize, 1), storage.placements.count()); + try testing.expectEqual( + tracked + 1, + t.screens.active.pages.countTrackedPins(), + ); +} + test "kittygfx delete then retransmit same id gets fresh generation" { const testing = std.testing; const io = testing.io; @@ -673,3 +761,22 @@ test "kittygfx delete then retransmit same id gets fresh generation" { try testing.expect(gen2 > gen1); try testing.expect(gen2 > gen_delete); } + +test "kittygfx placement bounds cursor movement for untrusted dimensions" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + const cmd = try command.Parser.parseString( + alloc, + "a=T,t=d,f=24,i=1,s=1,v=1,c=4294967295,r=4294967295;////", + ); + defer cmd.deinit(alloc); + + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + try testing.expectEqual(@as(usize, 1), t.screens.active.kitty_images.placements.count()); +} diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index ef584c09a..584195620 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -9,6 +9,7 @@ const fastmem = @import("../../fastmem.zig"); const command = @import("graphics_command.zig"); const PageList = @import("../PageList.zig"); const sys = @import("../sys.zig"); +const LimitedAllocator = @import("../../datastruct/main.zig").LimitedAllocator; const log = std.log.scoped(.kitty_gfx); @@ -98,7 +99,7 @@ pub const LoadingImage = struct { .height = t.height, .compression = t.compression, .format = t.format, - .usage = t.usage, + .metadata = .{ .transient = t.usage.transient }, }, .display = cmd.display(), @@ -139,36 +140,18 @@ pub const LoadingImage = struct { if (comptime builtin.os.tag != .windows) { if (std.mem.indexOfScalar(u8, cmd.data, 0) != null) { - // posix.realpath *asserts* that the path does not have - // internal nulls instead of erroring. - log.warn("failed to get absolute path: BadPathName", .{}); + // POSIX paths cannot contain internal nulls. + log.warn("invalid image path: BadPathName", .{}); return error.InvalidData; } } - var abs_buf: [std.fs.max_path_bytes]u8 = undefined; - const path = switch (t.medium) { - .direct => unreachable, // handled above - .file, .temporary_file => path: { - const len = std.Io.Dir.cwd().realPathFile( - io, - cmd.data, - &abs_buf, - ) catch |err| { - log.warn("failed to get absolute path: {}", .{err}); - return error.InvalidData; - }; - break :path abs_buf[0..len]; - }, - .shared_memory => cmd.data, - }; - // Depending on the medium, load the data from the path. switch (t.medium) { .direct => unreachable, // handled above - .file => try result.readFile(.file, io, alloc, t, path), - .temporary_file => try result.readFile(.temporary_file, io, alloc, t, path), - .shared_memory => try result.readSharedMemory(io, alloc, t, path), + .file => try result.readFile(.file, io, alloc, t, cmd.data), + .temporary_file => try result.readFile(.temporary_file, io, alloc, t, cmd.data), + .shared_memory => try result.readSharedMemory(io, alloc, t, cmd.data), } return result; @@ -222,30 +205,13 @@ pub const LoadingImage = struct { return error.InvalidData; }; if (stat.size <= 0) return error.InvalidData; - break :stat @intCast(stat.size); + break :stat std.math.cast(usize, stat.size) orelse + return error.InvalidData; }; - const expected_size: usize = switch (self.image.format) { - // Png we decode the full data size because later decoding will - // get the proper dimensions and assert validity. - .png => stat_size, - - // For these formats we have a size we must have. - .gray, .gray_alpha, .rgb, .rgba => size: { - const bpp = command.Transmission.formatBpp(self.image.format); - break :size self.image.width * self.image.height * bpp; - }, - }; - - // Our stat size must be at least the expected size otherwise - // the shared memory data is invalid. - if (stat_size < expected_size) { - log.warn( - "shared memory size too small expected={} actual={}", - .{ expected_size, stat_size }, - ); - return error.InvalidData; - } + // Get the memory range we'll read. Validate it to make sure + // it doesn't overflow. + const range = try self.sharedMemoryRange(t, stat_size); const map = std.posix.mmap( null, @@ -260,16 +226,63 @@ pub const LoadingImage = struct { }; defer std.posix.munmap(map); - // Our end size always uses the expected size so we cut off the - // padding for mmap alignment. - const start: usize = @intCast(t.offset); - const end: usize = if (t.size > 0) @min( - @as(usize, @intCast(t.offset)) + @as(usize, @intCast(t.size)), - expected_size, - ) else expected_size; - assert(self.data.items.len == 0); - try self.data.appendSlice(alloc, map[start..end]); + try self.data.appendSlice(alloc, map[range.start..range.end]); + } + + const SharedMemoryRange = struct { + start: usize, + end: usize, + }; + + /// Returns the byte range to copy from a shared memory object. + fn sharedMemoryRange( + self: *const LoadingImage, + t: command.Transmission, + stat_size: usize, + ) error{ + InvalidData, + DimensionsTooLarge, + }!SharedMemoryRange { + const expected_size: ?usize = switch (self.image.format) { + // PNG dimensions come from the decoded data. + .png => null, + + // Validate before multiplying because protocol dimensions are + // u32 values and may otherwise overflow in safe builds. + .gray, .gray_alpha, .rgb, .rgba => size: { + if (self.image.width > max_dimension or + self.image.height > max_dimension) + { + return error.DimensionsTooLarge; + } + + const bpp: usize = command.Transmission.formatBpp(self.image.format); + break :size @as(usize, self.image.width) * + @as(usize, self.image.height) * bpp; + }, + }; + + // Get our start offset and validate its within the range of + // the statted data. + const start = std.math.cast(usize, t.offset) orelse + return error.InvalidData; + if (start > stat_size) return error.InvalidData; + + // Validate that our length is within the stat range too. + const available = stat_size - start; + const data_size: usize = if (t.size > 0) + std.math.cast(usize, t.size) orelse return error.InvalidData + else if (self.image.compression == .none and expected_size != null) + expected_size.? + else + available; + if (data_size > max_size or data_size > available) { + return error.InvalidData; + } + + // data_size <= available guarantees this addition cannot overflow. + return .{ .start = start, .end = start + data_size }; } /// Reads the data from a temporary file and returns it. This allocates @@ -289,35 +302,56 @@ pub const LoadingImage = struct { else => @compileError("readFile only supports file and temporary_file"), } - // Verify file seems "safe". This is logic copied directly from Kitty, - // mostly. This is really rough but it will catch obvious bad actors. - if (std.mem.startsWith(u8, path, "/proc/") or - std.mem.startsWith(u8, path, "/sys/") or - (std.mem.startsWith(u8, path, "/dev/") and - !std.mem.startsWith(u8, path, "/dev/shm/"))) - { + // Open our file right away before we do validation. This avoids + // TOCTOU issues. + var file = std.Io.Dir.cwd().openFile( + io, + path, + .{}, + ) catch |err| { + log.warn("failed to open image file: {}", .{err}); return error.InvalidData; + }; + + // We'll populate a delete path if this is a temporary file. + var delete_path: ?[]const u8 = null; + defer { + file.close(io); + if (delete_path) |p| { + std.Io.Dir.cwd().deleteFile(io, p) catch |err| { + log.warn("failed to delete temporary file: {}", .{err}); + }; + } } + // Derive the path from the open handle so the file we validate is the + // exact file we read. Resolving a path before opening it would allow a + // cooperating process to swap a symlink or directory entry in between. + var abs_buf: [std.fs.max_path_bytes]u8 = undefined; + const abs_path = validatedFilePath( + io, + file, + &abs_buf, + ) catch |err| { + log.warn("failed to validate image file path: {}", .{err}); + return error.InvalidData; + }; + // Temporary file logic if (medium == .temporary_file) { assert(self.temporary_directory != null); - if (!isPathInTempDir(io, self.temporary_directory.?, path)) return error.TemporaryFileNotInTempDir; - if (std.mem.indexOf(u8, path, "tty-graphics-protocol") == null) { - return error.TemporaryFileNotNamedCorrectly; - } + if (!isPathInTempDir( + io, + self.temporary_directory.?, + abs_path, + )) return error.TemporaryFileNotInTempDir; + if (std.mem.indexOf( + u8, + abs_path, + "tty-graphics-protocol", + ) == null) return error.TemporaryFileNotNamedCorrectly; + delete_path = abs_path; } - defer if (medium == .temporary_file) { - std.Io.Dir.cwd().deleteFile(io, path) catch |err| { - log.warn("failed to delete temporary file: {}", .{err}); - }; - }; - - var file = std.Io.Dir.cwd().openFile(io, path, .{}) catch |err| { - log.warn("failed to open temporary file: {}", .{err}); - return error.InvalidData; - }; - defer file.close(io); // File must be a regular file if (file.stat(io)) |stat| { @@ -354,12 +388,30 @@ pub const LoadingImage = struct { self.data = .{ .items = managed.items, .capacity = managed.capacity }; } + /// Returns the canonical path of an open file after applying the file + /// transmission blocklist. + fn validatedFilePath(io: std.Io, file: std.Io.File, buf: []u8) ![]const u8 { + const path = buf[0..try file.realPath(io, buf)]; + + // This is logic copied directly from Kitty, mostly. This is really + // rough but it will catch obvious bad actors. + if (std.mem.startsWith(u8, path, "/proc/") or + std.mem.startsWith(u8, path, "/sys/") or + (std.mem.startsWith(u8, path, "/dev/") and + !std.mem.startsWith(u8, path, "/dev/shm/"))) + { + return error.InvalidData; + } + + return path; + } + /// Returns true if path appears to be in a temporary directory. /// Copies logic from Kitty. fn isPathInTempDir(io: std.Io, dir: []const u8, path: []const u8) bool { - if (std.mem.startsWith(u8, path, "/tmp")) return true; - if (std.mem.startsWith(u8, path, "/dev/shm")) return true; - if (std.mem.startsWith(u8, path, dir)) return true; + if (isPathInDir("/tmp", path)) return true; + if (isPathInDir("/dev/shm", path)) return true; + if (isPathInDir(dir, path)) return true; // The temporary dir is sometimes a symlink. On macOS for // example /tmp is /private/var/... @@ -369,7 +421,7 @@ pub const LoadingImage = struct { dir, &buf, ) catch return false]; - if (std.mem.startsWith(u8, path, real_dir)) return true; + if (isPathInDir(real_dir, path)) return true; return false; } @@ -433,7 +485,7 @@ pub const LoadingImage = struct { // Everything looks good, copy the image data over. var result = self.image; - result.data = try self.data.toOwnedSlice(alloc); + result.data = .{ .complete = try self.data.toOwnedSlice(alloc) }; errdefer result.deinit(alloc); self.image = .{}; return result; @@ -500,14 +552,20 @@ pub const LoadingImage = struct { const decode_png_fn = sys.decode_png orelse return error.UnsupportedFormat; + + var limited: LimitedAllocator = .init(alloc, max_size); + const decode_alloc = limited.allocator(); const result = decode_png_fn( - alloc, + decode_alloc, self.data.items, ) catch |err| switch (err) { error.InvalidData => return error.InvalidData, - error.OutOfMemory => return error.OutOfMemory, + error.OutOfMemory => if (limited.limit_exceeded) + return error.InvalidData + else + return error.OutOfMemory, }; - defer alloc.free(result.data); + defer decode_alloc.free(result.data); if (result.data.len > max_size) { log.warn("png image too large size={} max_size={}", .{ result.data.len, max_size }); @@ -527,13 +585,13 @@ pub const LoadingImage = struct { } }; -/// Image represents a single fully loaded image. +/// Image represents a single image whose metadata is fully known. /// -/// The image data is always fully decoded raw pixels: loading inflates -/// any zlib-compressed payload and decodes PNG into RGBA before an image -/// is completed, so `compression` is always `.none` and `format` is -/// never `.png` for a stored image, and `data.len` always equals -/// `width * height * bytes-per-pixel`. +/// Complete image data is always fully decoded raw pixels: loading inflates +/// any zlib-compressed payload and decodes PNG into RGBA before an image is +/// completed, so `compression` is always `.none` and `format` is never `.png` +/// for a stored image. Pending image data reserves the exact decoded byte +/// length that will be attached later. pub const Image = struct { id: u32 = 0, number: u32 = 0, @@ -541,8 +599,19 @@ pub const Image = struct { height: u32 = 0, format: command.Transmission.Format = .rgb, compression: command.Transmission.Compression = .none, - data: []const u8 = "", - usage: command.Transmission.Usage = .default, + data: Data = .{ .complete = "" }, + metadata: packed struct(u32) { + /// The image's transient usage hint, used to prioritize eviction. + transient: bool = false, + + /// Set this if the image was loaded without an ID or number. Such + /// images must not receive responses even though they currently get + /// IDs in the public range (which is bad!). + implicit_id: bool = false, + + /// Number of placements referencing this image. + placement_count: u30 = 0, + } = .{}, /// Unique, monotonically increasing stamp assigned each time an /// image is added to (or replaced in) an ImageStorage. A changed @@ -552,12 +621,6 @@ pub const Image = struct { /// transmission time. Zero means "never stored". generation: u64 = 0, - /// Set this to true if this image was loaded by a command that - /// doesn't specify an ID or number, since such commands should - /// not be responded to, even though we do currently give them - /// IDs in the public range (which is bad!). - implicit_id: bool = false, - pub const Error = error{ InvalidData, DecompressionFailed, @@ -571,14 +634,49 @@ pub const Image = struct { UnsupportedDepth, }; + pub const Data = union(enum) { + /// Owned, decoded image bytes. The empty default is not allocated. + complete: []const u8, + + /// Expected decoded byte length for a payload that has not arrived. + pending: usize, + + /// Bytes reserved against the storage limit. + pub fn len(self: Data) usize { + return switch (self) { + .complete => |data| data.len, + .pending => |expected_len| expected_len, + }; + } + + /// Returns decoded bytes when the payload is complete. + pub fn bytes(self: Data) ?[]const u8 { + return switch (self) { + .complete => |data| data, + .pending => null, + }; + } + + pub fn isPending(self: Data) bool { + return self == .pending; + } + + pub fn deinit(self: *Data, alloc: Allocator) void { + switch (self.*) { + .complete => |data| if (data.len > 0) alloc.free(data), + .pending => {}, + } + } + }; + pub fn deinit(self: *Image, alloc: Allocator) void { - if (self.data.len > 0) alloc.free(self.data); + self.data.deinit(alloc); } /// Mostly for logging pub fn withoutData(self: *const Image) Image { var copy = self.*; - copy.data = ""; + if (copy.data == .complete) copy.data = .{ .complete = "" }; return copy; } }; @@ -589,8 +687,101 @@ pub const Image = struct { pub const Rect = struct { top_left: PageList.Pin, bottom_right: PageList.Pin, + + /// Returns true if the grid cell is inside this rectangle. Pin.isBetween + /// compares page order, so its interior rows intentionally do not constrain + /// x. Check the column independently and use isBetween only for the row. + pub fn contains(self: Rect, cell: PageList.Pin) bool { + if (cell.x < self.top_left.x or cell.x > self.bottom_right.x) return false; + + var row = cell; + row.x = self.top_left.x; + return row.isBetween(self.top_left, self.bottom_right); + } }; +/// Returns true if `path` is `dir` or is contained within it, requiring a +/// path-separator boundary so similarly prefixed directories do not match. +fn isPathInDir(dir: []const u8, path: []const u8) bool { + if (dir.len == 0 or !std.mem.startsWith(u8, path, dir)) return false; + if (path.len == dir.len or std.fs.path.isSep(dir[dir.len - 1])) return true; + return std.fs.path.isSep(path[dir.len]); +} + +test "temporary file path must be inside directory" { + const testing = std.testing; + + try testing.expect(isPathInDir("/tmp", "/tmp/tty-graphics-protocol-image.data")); + try testing.expect(isPathInDir("/tmp/", "/tmp/tty-graphics-protocol-image.data")); + try testing.expect(isPathInDir("/tmp", "/tmp")); + + try testing.expect(!isPathInDir("", "/tmp/tty-graphics-protocol-image.data")); + try testing.expect(!isPathInDir("/tmp", "/tmpX/tty-graphics-protocol-image.data")); + try testing.expect(!isPathInDir("/dev/shm", "/dev/shm-evil/tty-graphics-protocol-image.data")); + try testing.expect(!isPathInDir("/custom/tmp", "/custom/tmp-suffix/tty-graphics-protocol-image.data")); +} + +test "shared memory range with offset and size" { + const testing = std.testing; + + const loading: LoadingImage = .{ + .image = .{ + .width = 1, + .height = 1, + .format = .rgb, + }, + .quiet = .no, + .temporary_directory = null, + }; + + const explicit = try loading.sharedMemoryRange(.{ + .offset = 2, + .size = 3, + }, 5); + try testing.expectEqual(@as(usize, 2), explicit.start); + try testing.expectEqual(@as(usize, 5), explicit.end); + + const implicit = try loading.sharedMemoryRange(.{ + .offset = 2, + }, 5); + try testing.expectEqual(@as(usize, 2), implicit.start); + try testing.expectEqual(@as(usize, 5), implicit.end); +} + +test "shared memory range rejects out of bounds offset" { + const loading: LoadingImage = .{ + .image = .{ + .width = 1, + .height = 1, + .format = .rgb, + }, + .quiet = .no, + .temporary_directory = null, + }; + + try std.testing.expectError( + error.InvalidData, + loading.sharedMemoryRange(.{ .offset = 4 }, 3), + ); +} + +test "shared memory range validates dimensions before multiplication" { + const loading: LoadingImage = .{ + .image = .{ + .width = std.math.maxInt(u32), + .height = std.math.maxInt(u32), + .format = .rgba, + }, + .quiet = .no, + .temporary_directory = null, + }; + + try std.testing.expectError( + error.DimensionsTooLarge, + loading.sharedMemoryRange(.{}, 1), + ); +} + // This specifically tests we ALLOW invalid RGB data because Kitty // documents that this should work. test "image load with invalid RGB data" { @@ -826,6 +1017,54 @@ test "image load: temporary file without correct path" { try tmp_dir.dir.access(testing.io, path, .{}); } +test "image load: temporary file outside directory prefix is rejected" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.createDir(io, "temp", .default_dir); + try tmp_dir.dir.createDir(io, "temp-suffix", .default_dir); + + var trusted_dir = try tmp_dir.dir.openDir(io, "temp", .{}); + defer trusted_dir.close(io); + var outside_dir = try tmp_dir.dir.openDir(io, "temp-suffix", .{}); + defer outside_dir.close(io); + + const filename = "tty-graphics-protocol-image.data"; + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try outside_dir.writeFile(io, .{ + .sub_path = filename, + .data = data, + }); + + var trusted_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const trusted_path = trusted_path_buf[0..try trusted_dir.realPath(io, &trusted_path_buf)]; + var outside_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const outside_path = outside_path_buf[0..try outside_dir.realPathFile(io, filename, &outside_path_buf)]; + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .temporary_file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, outside_path), + }; + defer cmd.deinit(alloc); + try testing.expectError( + error.TemporaryFileNotInTempDir, + LoadingImage.init(io, alloc, &cmd, .allWithTempDir(trusted_path)), + ); + + // Rejection must happen before temporary-file cleanup is armed. + try outside_dir.access(io, filename, .{}); +} + test "image load: rgb, not compressed, temporary file" { const testing = std.testing; const alloc = testing.allocator; @@ -952,6 +1191,39 @@ test "image load: rgb, not compressed, relative regular file" { try testing.expect(img.compression == .none); } +test "image load: blocklist applies to opened file after symlink swap" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + const testing = std.testing; + const io = testing.io; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.writeFile(io, .{ + .sub_path = "safe.data", + .data = "safe", + }); + try tmp_dir.dir.symLink(io, "/dev/null", "image.data", .{}); + + // Pin the blocked file, then simulate the cooperating process replacing + // the path with a safe target before validation. + const blocked_file = try tmp_dir.dir.openFile(io, "image.data", .{}); + defer blocked_file.close(io); + try tmp_dir.dir.symLinkAtomic(io, "safe.data", "image.data", .{}); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + try testing.expectError( + error.InvalidData, + LoadingImage.validatedFilePath(io, blocked_file, &path_buf), + ); + + // The pathname now resolves to the safe replacement, demonstrating that + // the rejection above came from the already-open file handle. + const safe_file = try tmp_dir.dir.openFile(io, "image.data", .{}); + defer safe_file.close(io); + _ = try LoadingImage.validatedFilePath(io, safe_file, &path_buf); +} + test "image load: png, not compressed, regular file" { if (sys.decode_png == null) return error.SkipZigTest; @@ -997,6 +1269,72 @@ test "image load: png, not compressed, regular file" { try tmp_dir.dir.access(testing.io, path, .{}); } +test "image load: png rejects oversized decoder allocation" { + const testing = std.testing; + + const oversized_decoder = struct { + fn decode( + alloc: Allocator, + _: []const u8, + ) sys.DecodeError!sys.Image { + const data = try alloc.alloc(u8, max_size + 1); + return .{ + .width = 1, + .height = 1, + .data = data, + }; + } + }.decode; + + const original_decode_png = sys.decode_png; + defer sys.decode_png = original_decode_png; + sys.decode_png = &oversized_decoder; + + // Fail any allocation which reaches the underlying allocator. The size + // limiter should reject the decoder's request before it gets that far. + var failing = testing.FailingAllocator.init(testing.allocator, .{ + .fail_index = 0, + }); + const alloc = failing.allocator(); + + var loading: LoadingImage = .{ + .image = .{ .format = .png }, + .quiet = .no, + .temporary_directory = null, + }; + defer loading.deinit(alloc); + + try testing.expectError(error.InvalidData, loading.complete(alloc)); + try testing.expect(!failing.has_induced_failure); +} + +test "image load: png rejects oversized Wuffs image before allocation" { + if (sys.decode_png == null) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + + // Turn the small test PNG into a 32768x32767 image. Its decoded RGBA + // size is just under Wuffs' 4 GiB package limit but over Kitty's 400 MiB + // limit, which previously allowed the large allocation to happen first. + var data = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data").*; + std.mem.writeInt(u32, data[16..20], 32768, .big); + std.mem.writeInt(u32, data[20..24], 32767, .big); + std.mem.writeInt(u32, data[29..33], std.hash.Crc32.hash(data[12..29]), .big); + + const cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .png, + .medium = .direct, + } }, + .data = &data, + }; + var loading = try LoadingImage.init(testing.io, alloc, &cmd, .direct); + defer loading.deinit(alloc); + + try testing.expectError(error.InvalidData, loading.complete(alloc)); +} + test "limits: direct medium always allowed" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index 2a9ad7f56..fe97b1509 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -132,6 +132,38 @@ pub const ImageStorage = struct { total_bytes: usize = 0, total_limit: usize = 320 * 1000 * 1000, // 320MB + /// Identifies one exact pending image transmission. The generation is + /// assigned by this storage when the pending image is inserted, so a + /// later replacement of the same ID cannot consume stale payload bytes. + pub const PendingImage = struct { + id: u32, + generation: u64, + + /// Attach owned decoded bytes if this exact pending transmission is + /// still resident. On true, storage owns `data`; on false, the caller + /// retains ownership. Completion preserves the image generation (and + /// therefore eviction age) while marking storage content as mutated. + pub fn complete( + self: PendingImage, + storage: *ImageStorage, + io: std.Io, + data: []const u8, + ) bool { + const img = storage.images.getPtr(self.id) orelse return false; + if (img.generation != self.generation) return false; + + const expected_len = switch (img.data) { + .complete => return false, + .pending => |len| len, + }; + if (data.len != expected_len) return false; + + img.data = .{ .complete = data }; + storage.markMutated(io); + return true; + } + }; + pub fn deinit( self: *ImageStorage, alloc: Allocator, @@ -139,12 +171,12 @@ pub const ImageStorage = struct { ) void { if (self.loading) |loading| loading.destroy(alloc); + self.clearPlacements(s); + self.placements.deinit(alloc); + var it = self.images.iterator(); while (it.next()) |kv| kv.value_ptr.deinit(alloc); self.images.deinit(alloc); - - self.clearPlacements(s); - self.placements.deinit(alloc); } /// Kitty image protocol is enabled if we have a non-zero limit. @@ -175,7 +207,7 @@ pub const ImageStorage = struct { alloc: Allocator, s: *terminal.Screen, limit: usize, - ) !void { + ) void { // Special case disabling by quickly deleting all if (limit == 0) { const image_limits = self.image_limits; @@ -188,7 +220,7 @@ pub const ImageStorage = struct { if (limit < self.total_bytes) { const req_bytes = self.total_bytes - limit; log.info("evicting images to lower limit, evicting={}", .{req_bytes}); - if (!try self.evictImage(io, alloc, req_bytes)) { + if (!self.evictImage(io, alloc, s, req_bytes)) { log.warn("failed to evict enough images for required bytes", .{}); } } @@ -196,40 +228,63 @@ pub const ImageStorage = struct { self.total_limit = limit; } - /// Add an already-loaded image to the storage. This will automatically - /// free any existing image with the same ID. - pub fn addImage(self: *ImageStorage, io: std.Io, alloc: Allocator, img: Image) Allocator.Error!void { + /// Add an image to the storage. This will automatically free any existing + /// image with the same ID. Prefer addPendingImage for pending data so the + /// caller receives a completion token. + pub fn addImage( + self: *ImageStorage, + io: std.Io, + alloc: Allocator, + s: *terminal.Screen, + img: Image, + ) Allocator.Error!void { + const new_len = img.data.len(); + // If the image itself is over the limit, then error immediately - if (img.data.len > self.total_limit) return error.OutOfMemory; + if (new_len > self.total_limit) return error.OutOfMemory; + + // Credit an existing image's reservation before calculating the + // replacement size. In particular, a completed live transmission + // 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() + else + 0; + assert(old_len <= self.total_bytes); + + // Reserve map capacity before evicting anything so allocation failure + // cannot leave storage partially evicted. Existing keys need no room. + if (!self.images.contains(img.id)) { + try self.images.ensureUnusedCapacity(alloc, 1); + } // If this would put us over the limit, then evict. - const total_bytes = self.total_bytes + img.data.len; + const total_bytes = self.total_bytes - old_len + new_len; if (total_bytes > self.total_limit) { const req_bytes = total_bytes - self.total_limit; log.info("evicting images to make space for {} bytes", .{req_bytes}); - if (!try self.evictImage(io, alloc, req_bytes)) { + if (!self.evictImageExcept(io, alloc, s, req_bytes, img.id)) { log.warn("failed to evict enough images for required bytes", .{}); return error.OutOfMemory; } } - // Do the gop op first so if it fails we don't get a partial state - const gop = try self.images.getOrPut(alloc, img.id); + const gop = self.images.getOrPutAssumeCapacity(img.id); - log.debug("addImage image={}", .{img: { - var copy = img; - copy.data = ""; - break :img copy; - }}); + log.debug("addImage image={}", .{img.withoutData()}); - // Write our new image + // Retransmitting a specific image ID replaces the old image and all + // of its placements, as required by the Kitty graphics protocol. if (gop.found_existing) { - self.total_bytes -= gop.value_ptr.data.len; + self.removePlacementsByImageId(s, img.id); + self.total_bytes -= gop.value_ptr.data.len(); gop.value_ptr.deinit(alloc); } gop.value_ptr.* = img; - self.total_bytes += img.data.len; + gop.value_ptr.metadata.placement_count = 0; + self.total_bytes += new_len; // Stamp the stored image with a fresh generation. This gives // every add/replace a unique stamp even when the same image ID @@ -239,12 +294,30 @@ pub const ImageStorage = struct { gop.value_ptr.generation = self.generation; } + /// Add an image whose decoded payload bytes have not arrived yet. + /// Returns the token required to complete this exact transmission. + pub fn addPendingImage( + self: *ImageStorage, + io: std.Io, + alloc: Allocator, + s: *terminal.Screen, + img: Image, + ) Allocator.Error!PendingImage { + assert(img.data == .pending); + try self.addImage(io, alloc, s, img); + const stored = self.images.get(img.id).?; + return .{ .id = stored.id, .generation = stored.generation }; + } + /// Add a placement for a given image. The caller must verify in advance - /// the image exists to prevent memory corruption. + /// the image exists to prevent memory corruption. On success, storage + /// owns `p`; on error, the caller retains ownership. The screen must own + /// any tracked pin in `p` and in the placement it replaces. pub fn addPlacement( self: *ImageStorage, io: std.Io, alloc: Allocator, + s: *terminal.Screen, image_id: u32, placement_id: u32, p: Placement, @@ -256,6 +329,14 @@ pub const ImageStorage = struct { p, }); + // Tracked pins are marked garbage when their underlying history is + // pruned. Kitty removes placements once they scroll out of retained + // history, so reclaim those placements before growing the map for a + // new one. If allocation below fails, the sweep is still a content + // mutation and must be visible to consumers. + const removed_garbage = self.removeGarbagePlacements(s); + errdefer if (removed_garbage) self.markMutated(io); + // The important piece here is that the placement ID needs to // be marked internal if it is zero. This allows multiple placements // to be added for the same image. If it is non-zero, then it is @@ -276,17 +357,73 @@ pub const ImageStorage = struct { }; const gop = try self.placements.getOrPut(alloc, key); + if (gop.found_existing) { + gop.value_ptr.deinit(s); + } else { + const img = self.images.getPtr(image_id).?; + img.metadata.placement_count = std.math.add( + @TypeOf(img.metadata.placement_count), + img.metadata.placement_count, + 1, + ) catch { + self.placements.removeByPtr(gop.key_ptr); + return error.OutOfMemory; + }; + } gop.value_ptr.* = p; self.markMutated(io); } + /// Remove pin-backed placements whose tracked content has been pruned. + /// Virtual placements have no tracked screen location and are retained. + fn removeGarbagePlacements( + self: *ImageStorage, + s: *terminal.Screen, + ) bool { + var removed = false; + var it = self.placements.iterator(); + while (it.next()) |entry| { + const pin = switch (entry.value_ptr.location) { + .pin => |pin| pin, + .virtual => continue, + }; + if (!pin.garbage) continue; + + entry.value_ptr.deinit(s); + self.removePlacementByPtr(entry.key_ptr); + removed = true; + } + + return removed; + } + fn clearPlacements(self: *ImageStorage, s: *terminal.Screen) void { var it = self.placements.iterator(); while (it.next()) |entry| entry.value_ptr.deinit(s); self.placements.clearRetainingCapacity(); } + fn removePlacementsByImageId( + self: *ImageStorage, + s: *terminal.Screen, + image_id: u32, + ) void { + var it = self.placements.iterator(); + while (it.next()) |entry| { + if (entry.key_ptr.image_id != image_id) continue; + entry.value_ptr.deinit(s); + self.removePlacementByPtr(entry.key_ptr); + } + } + + fn removePlacementByPtr(self: *ImageStorage, key: *PlacementKey) void { + const img = self.images.getPtr(key.image_id).?; + assert(img.metadata.placement_count > 0); + img.metadata.placement_count -= 1; + self.placements.removeByPtr(key); + } + /// Get an image by its ID. If the image doesn't exist, null is returned. pub fn imageById(self: *const ImageStorage, image_id: u32) ?Image { return self.images.get(image_id); @@ -342,7 +479,7 @@ pub const ImageStorage = struct { // Deinit the placement and remove it const image_id = entry.key_ptr.image_id; entry.value_ptr.deinit(t.screens.active); - self.placements.removeByPtr(entry.key_ptr); + self.removePlacementByPtr(entry.key_ptr); if (delete_images) self.deleteIfUnused(alloc, image_id); } @@ -440,7 +577,7 @@ pub const ImageStorage = struct { const rect = entry.value_ptr.rect(img, t) orelse continue; if (rect.top_left.x <= x and rect.bottom_right.x >= x) { entry.value_ptr.deinit(t.screens.active); - self.placements.removeByPtr(entry.key_ptr); + self.removePlacementByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, img.id); } } @@ -469,7 +606,7 @@ pub const ImageStorage = struct { target_pin_copy.x = rect.top_left.x; if (target_pin_copy.isBetween(rect.top_left, rect.bottom_right)) { entry.value_ptr.deinit(t.screens.active); - self.placements.removeByPtr(entry.key_ptr); + self.removePlacementByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, img.id); } } @@ -489,7 +626,7 @@ pub const ImageStorage = struct { if (entry.value_ptr.z == v.z) { const image_id = entry.key_ptr.image_id; entry.value_ptr.deinit(t.screens.active); - self.placements.removeByPtr(entry.key_ptr); + self.removePlacementByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, image_id); } } @@ -507,10 +644,10 @@ pub const ImageStorage = struct { var it = self.placements.iterator(); while (it.next()) |entry| { - if (entry.key_ptr.image_id >= v.first or entry.key_ptr.image_id <= v.last) { + if (entry.key_ptr.image_id >= v.first and entry.key_ptr.image_id <= v.last) { const image_id = entry.key_ptr.image_id; entry.value_ptr.deinit(t.screens.active); - self.placements.removeByPtr(entry.key_ptr); + self.removePlacementByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, image_id); } } @@ -536,7 +673,7 @@ pub const ImageStorage = struct { while (it.next()) |entry| { if (entry.key_ptr.image_id == image_id) { entry.value_ptr.deinit(s); - self.placements.removeByPtr(entry.key_ptr); + self.removePlacementByPtr(entry.key_ptr); } } } else { @@ -545,7 +682,7 @@ pub const ImageStorage = struct { .placement_id = .{ .tag = .external, .id = placement_id }, })) |entry| { entry.value_ptr.deinit(s); - self.placements.removeByPtr(entry.key_ptr); + self.removePlacementByPtr(entry.key_ptr); } } @@ -556,19 +693,12 @@ pub const ImageStorage = struct { /// Delete an image if it is unused. fn deleteIfUnused(self: *ImageStorage, alloc: Allocator, image_id: u32) void { - var it = self.placements.iterator(); - while (it.next()) |kv| { - if (kv.key_ptr.image_id == image_id) { - return; - } - } + const entry = self.images.getEntry(image_id) orelse return; + if (entry.value_ptr.metadata.placement_count > 0) return; - // If we get here, we can delete the image. - if (self.images.getEntry(image_id)) |entry| { - self.total_bytes -= entry.value_ptr.data.len; - entry.value_ptr.deinit(alloc); - self.images.removeByPtr(entry.key_ptr); - } + self.total_bytes -= entry.value_ptr.data.len(); + entry.value_ptr.deinit(alloc); + self.images.removeByPtr(entry.key_ptr); } /// Deletes all placements intersecting a screen point. @@ -588,10 +718,10 @@ pub const ImageStorage = struct { while (it.next()) |entry| { const img = self.imageById(entry.key_ptr.image_id) orelse continue; const rect = entry.value_ptr.rect(img, t) orelse continue; - if (target_pin.isBetween(rect.top_left, rect.bottom_right)) { + if (rect.contains(target_pin)) { if (filter) |f| if (!f(filter_ctx, entry.value_ptr.*)) continue; entry.value_ptr.deinit(t.screens.active); - self.placements.removeByPtr(entry.key_ptr); + self.removePlacementByPtr(entry.key_ptr); if (delete_unused) self.deleteIfUnused(alloc, img.id); } } @@ -603,123 +733,97 @@ pub const ImageStorage = struct { /// /// This will evict as many images as necessary to make space for /// req bytes. - fn evictImage(self: *ImageStorage, io: std.Io, alloc: Allocator, req: usize) !bool { + fn evictImage( + self: *ImageStorage, + io: std.Io, + alloc: Allocator, + s: *terminal.Screen, + req: usize, + ) bool { + return self.evictImageExcept(io, alloc, s, req, null); + } + + /// Evict images while preserving `exclude_id`, used when replacing an + /// existing image whose old reservation has already been credited. + fn evictImageExcept( + self: *ImageStorage, + io: std.Io, + alloc: Allocator, + s: *terminal.Screen, + req: usize, + exclude_id: ?u32, + ) bool { assert(req <= self.total_limit); - // Ironically we allocate to evict. We should probably redesign the - // data structures to avoid this but for now allocating a little - // bit is fine compared to the megabytes we're looking to save. const Candidate = struct { id: u32, generation: u64, - // Map images into four distinct blocks: + + // Lower values are evicted first: // 0: transient, unused // 1: not transient, unused // 2: transient, used // 3: not transient, used - block: u2, + priority: u2, + + fn init(img: *const Image) @This() { + return .{ + .id = img.id, + .generation = img.generation, + .priority = (if (img.metadata.transient) @as(u2, 0) else @as(u2, 1)) + + (if (img.metadata.placement_count > 0) @as(u2, 2) else @as(u2, 0)), + }; + } + + fn lessThan(lhs: @This(), rhs: @This()) bool { + if (lhs.priority != rhs.priority) + return lhs.priority < rhs.priority; + if (lhs.generation != rhs.generation) + return lhs.generation < rhs.generation; + return lhs.id < rhs.id; + } }; - const candidates = try alloc.alloc(Candidate, self.images.count()); - defer alloc.free(candidates); - - var it = self.images.iterator(); - var i: usize = 0; - while (it.next()) |kv| : (i += 1) { - const img = kv.value_ptr; - - // This is a huge waste. See comment above about redesigning - // our data structures to avoid this. Eviction should be very - // rare though and we never have that many images/placements - // so hopefully this will last a long time. - const used = used: { - var p_it = self.placements.iterator(); - while (p_it.next()) |p_kv| { - if (p_kv.key_ptr.image_id == img.id) { - break :used true; - } - } - - break :used false; - }; - - const transient = img.usage.transient; - - candidates[i] = .{ - .id = img.id, - .generation = img.generation, - // Map images into four distinct blocks: - // 0: transient, unused - // 1: not transient, unused - // 2: transient, used - // 3: not transient, used - .block = (if (transient) @as(u2, 0) else @as(u2, 1)) + (if (used) @as(u2, 2) else @as(u2, 0)), - }; - } - - // Sort - std.mem.sortUnstable( - Candidate, - candidates, - {}, - struct { - fn lessThan( - ctx: void, - lhs: Candidate, - rhs: Candidate, - ) bool { - _ = ctx; - - // If images mapped into different blocks, prioritize lower - // numbered blocks. - if (lhs.block < rhs.block) return true; - if (lhs.block > rhs.block) return false; - - // If images mapped to the same block, compare generations. - return if (lhs.generation == rhs.generation) - // If the generation is the same, use the ID to - // prioritize evicting "earlier" images. - lhs.id < rhs.id - else - // If the generation is different, prioritize evicting - // images from earlied generations. - lhs.generation < rhs.generation; - } - }.lessThan, - ); - // Evicting anything is a content mutation. This matters for the // setLimit path in particular, which doesn't otherwise mark it. - var any_evicted = false; - defer if (any_evicted) self.markMutated(io); + const images_before = self.images.count(); + defer if (self.images.count() != images_before) self.markMutated(io); - // They're in order of best to evict. var evicted: usize = 0; - for (candidates) |c| { - // Delete all the placements for this image and the image. + while (evicted < req) { + const c = candidate: { + var best: ?Candidate = null; + var it = self.images.iterator(); + while (it.next()) |kv| { + const img = kv.value_ptr; + if (exclude_id != null and img.id == exclude_id.?) continue; + + const current = Candidate.init(img); + if (best == null or current.lessThan(best.?)) best = current; + } + break :candidate best orelse return false; + }; + var p_it = self.placements.iterator(); while (p_it.next()) |entry| { if (entry.key_ptr.image_id == c.id) { - self.placements.removeByPtr(entry.key_ptr); - any_evicted = true; + entry.value_ptr.deinit(s); + self.removePlacementByPtr(entry.key_ptr); } } - if (self.images.getEntry(c.id)) |entry| { - log.info("evicting image id={} bytes={}", .{ c.id, entry.value_ptr.data.len }); + const entry = self.images.getEntry(c.id).?; + const image_len = entry.value_ptr.data.len(); + log.info("evicting image id={} bytes={}", .{ c.id, image_len }); - evicted += entry.value_ptr.data.len; - self.total_bytes -= entry.value_ptr.data.len; + evicted += image_len; + self.total_bytes -= image_len; - entry.value_ptr.deinit(alloc); - self.images.removeByPtr(entry.key_ptr); - any_evicted = true; - - if (evicted > req) return true; - } + entry.value_ptr.deinit(alloc); + self.images.removeByPtr(entry.key_ptr); } - return false; + return true; } /// Every placement is uniquely identified by the image ID and the @@ -772,6 +876,24 @@ pub const ImageStorage = struct { } } + /// Multiply two protocol-controlled values without allowing them to + /// wrap. Placement geometry is exposed as u32, so values larger than + /// that are represented by the largest possible value. + fn saturatingMul(lhs: u32, rhs: u32) u32 { + return std.math.mul(u32, lhs, rhs) catch std.math.maxInt(u32); + } + + /// Scale a dimension by an aspect ratio and round to the nearest + /// integer. The u64 intermediate can hold the product of two u32s as + /// well as the rounding adjustment. + fn scaleDimension(value: u32, numerator: u32, denominator: u32) u32 { + if (denominator == 0) return 0; + + const rounded = (@as(u64, value) * @as(u64, numerator) + + @as(u64, denominator) / 2) / @as(u64, denominator); + return std.math.cast(u32, rounded) orelse std.math.maxInt(u32); + } + /// Returns the size of this placement's image in pixels, /// taking into account the source rectangle, specified /// rows/columns, and aspect ratio. @@ -803,15 +925,12 @@ pub const ImageStorage = struct { const cell_width: u32 = t.width_px / t.cols; const cell_height: u32 = t.height_px / t.rows; - const width_f64: f64 = @floatFromInt(width); - const height_f64: f64 = @floatFromInt(height); - // If we have a specified cols AND rows then we calculate // the width and height from them directly, we don't need // to adjust for aspect ratio. if (self.columns > 0 and self.rows > 0) { - const calc_width = cell_width * self.columns; - const calc_height = cell_height * self.rows; + const calc_width = saturatingMul(cell_width, self.columns); + const calc_height = saturatingMul(cell_height, self.rows); return .{ .width = calc_width, @@ -825,11 +944,8 @@ pub const ImageStorage = struct { // If only the columns were specified, we determine // the height of the image based on the aspect ratio. if (self.columns > 0) { - const aspect = height_f64 / width_f64; - const calc_width: u32 = cell_width * self.columns; - const calc_height: u32 = @intFromFloat(@round( - @as(f64, @floatFromInt(calc_width)) * aspect, - )); + const calc_width = saturatingMul(cell_width, self.columns); + const calc_height = scaleDimension(calc_width, height, width); return .{ .width = calc_width, @@ -840,11 +956,8 @@ pub const ImageStorage = struct { // Otherwise, only the rows were specified, so we // determine the width based on the aspect ratio. { - const aspect = width_f64 / height_f64; - const calc_height: u32 = cell_height * self.rows; - const calc_width: u32 = @intFromFloat(@round( - @as(f64, @floatFromInt(calc_height)) * aspect, - )); + const calc_height = saturatingMul(cell_height, self.rows); + const calc_width = scaleDimension(calc_height, width, height); return .{ .width = calc_width, @@ -874,12 +987,12 @@ pub const ImageStorage = struct { return .{ .cols = std.math.divCeil( u32, - calc_size.width + self.x_offset, + calc_size.width +| self.x_offset, t.width_px / t.cols, ) catch 0, .rows = std.math.divCeil( u32, - calc_size.height + self.y_offset, + calc_size.height +| self.y_offset, t.height_px / t.rows, ) catch 0, }; @@ -888,8 +1001,8 @@ pub const ImageStorage = struct { } /// Returns a selection of the entire rectangle this placement - /// occupies within the screen. This can return null if the placement - /// doesn't have an associated rect (i.e. a virtual placement). + /// occupies within the screen. This can return null for a virtual + /// placement or when unavailable pixel geometry makes it empty. pub fn rect( self: Placement, image: Image, @@ -900,18 +1013,23 @@ pub const ImageStorage = struct { .pin => |p| p, .virtual => return null, }; + if (pin.garbage) return null; + + // A zero pixel-sized placement can produce a zero grid size when + // pixel geometry is unavailable. It occupies no rectangle. + if (grid_size.cols == 0 or grid_size.rows == 0) return null; var br = switch (pin.downOverflow(grid_size.rows - 1)) { .offset => |v| v, .overflow => |v| v.end, }; - br.x = @min( + br.x = @intCast(@min( // We need to sub one here because the x value is // one width already. So if the image is width "1" // then we add zero to X because X itself is width 1. - pin.x + (grid_size.cols - 1), - t.cols - 1, - ); + @as(u32, pin.x) +| (grid_size.cols - 1), + @as(u32, t.cols) - 1, + )); return .{ .top_left = pin.*, @@ -942,10 +1060,10 @@ test "storage: add placement with zero placement id" { var s: ImageStorage = .{}; defer s.deinit(alloc, t.screens.active); - try s.addImage(io, alloc, .{ .id = 1, .width = 50, .height = 50 }); - try s.addImage(io, alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(io, alloc, 1, 0, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); - try s.addPlacement(io, alloc, 1, 0, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1, .width = 50, .height = 50 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2, .width = 25, .height = 25 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 0, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 1, 0, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); try testing.expectEqual(@as(usize, 2), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); @@ -961,6 +1079,103 @@ test "storage: add placement with zero placement id" { }) != null); } +test "storage: replacing placement releases tracked pin" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 3, .rows = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + + const tracked = t.screens.active.pages.countTrackedPins(); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }, + }); + try testing.expectEqual( + tracked + 1, + t.screens.active.pages.countTrackedPins(), + ); + + const replacement_pin = try trackPin(&t, .{ .x = 2, .y = 2 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = replacement_pin }, + }); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expectEqual( + tracked + 1, + t.screens.active.pages.countTrackedPins(), + ); + try testing.expectEqual( + replacement_pin, + s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }).?.location.pin, + ); +} + +test "storage: adding placement reclaims garbage placements" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 3, .rows = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + + const tracked = t.screens.active.pages.countTrackedPins(); + const old_pin = try trackPin(&t, .{ .x = 0, .y = 0 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 0, .{ + .location = .{ .pin = old_pin }, + }); + old_pin.garbage = true; + + const new_pin = try trackPin(&t, .{ .x = 1, .y = 1 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 0, .{ + .location = .{ .pin = new_pin }, + }); + + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expectEqual( + tracked + 1, + t.screens.active.pages.countTrackedPins(), + ); + try testing.expectEqual( + new_pin, + s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .internal, .id = 1 }, + }).?.location.pin, + ); +} + +test "storage: placement count limit permits replacement" { + const testing = std.testing; + const io = testing.io; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .virtual = {} } }); + + const img = s.images.getPtr(1).?; + img.metadata.placement_count = std.math.maxInt(@TypeOf(img.metadata.placement_count)); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .virtual = {} } }); + try testing.expectError( + error.OutOfMemory, + s.addPlacement(io, alloc, t.screens.active, 1, 2, .{ .location = .{ .virtual = {} } }), + ); + try testing.expectEqual(@as(usize, 1), s.placements.count()); +} + test "storage: delete all placements and images" { const testing = std.testing; const alloc = testing.allocator; @@ -971,11 +1186,11 @@ test "storage: delete all placements and images" { var s: ImageStorage = .{}; defer s.deinit(alloc, t.screens.active); - try s.addImage(io, alloc, .{ .id = 1 }); - try s.addImage(io, alloc, .{ .id = 2 }); - try s.addImage(io, alloc, .{ .id = 3 }); - try s.addPlacement(io, alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); - try s.addPlacement(io, alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 3 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); s.dirty = false; s.delete(io, alloc, &t, .{ .all = true }); @@ -996,11 +1211,11 @@ test "storage: delete all placements and images preserves limit" { var s: ImageStorage = .{}; defer s.deinit(alloc, t.screens.active); s.total_limit = 5000; - try s.addImage(io, alloc, .{ .id = 1 }); - try s.addImage(io, alloc, .{ .id = 2 }); - try s.addImage(io, alloc, .{ .id = 3 }); - try s.addPlacement(io, alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); - try s.addPlacement(io, alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 3 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); s.dirty = false; s.delete(io, alloc, &t, .{ .all = true }); @@ -1021,11 +1236,11 @@ test "storage: delete all placements" { var s: ImageStorage = .{}; defer s.deinit(alloc, t.screens.active); - try s.addImage(io, alloc, .{ .id = 1 }); - try s.addImage(io, alloc, .{ .id = 2 }); - try s.addImage(io, alloc, .{ .id = 3 }); - try s.addPlacement(io, alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); - try s.addPlacement(io, alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 3 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); s.dirty = false; s.delete(io, alloc, &t, .{ .all = false }); @@ -1045,11 +1260,11 @@ test "storage: delete all placements by image id" { var s: ImageStorage = .{}; defer s.deinit(alloc, t.screens.active); - try s.addImage(io, alloc, .{ .id = 1 }); - try s.addImage(io, alloc, .{ .id = 2 }); - try s.addImage(io, alloc, .{ .id = 3 }); - try s.addPlacement(io, alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); - try s.addPlacement(io, alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 3 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); s.dirty = false; s.delete(io, alloc, &t, .{ .id = .{ .image_id = 2 } }); @@ -1069,11 +1284,11 @@ test "storage: delete all placements by image id and unused images" { var s: ImageStorage = .{}; defer s.deinit(alloc, t.screens.active); - try s.addImage(io, alloc, .{ .id = 1 }); - try s.addImage(io, alloc, .{ .id = 2 }); - try s.addImage(io, alloc, .{ .id = 3 }); - try s.addPlacement(io, alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); - try s.addPlacement(io, alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 3 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); s.dirty = false; s.delete(io, alloc, &t, .{ .id = .{ .delete = true, .image_id = 2 } }); @@ -1093,12 +1308,12 @@ test "storage: delete placement by specific id" { var s: ImageStorage = .{}; defer s.deinit(alloc, t.screens.active); - try s.addImage(io, alloc, .{ .id = 1 }); - try s.addImage(io, alloc, .{ .id = 2 }); - try s.addImage(io, alloc, .{ .id = 3 }); - try s.addPlacement(io, alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); - try s.addPlacement(io, alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); - try s.addPlacement(io, alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 3 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); s.dirty = false; s.delete(io, alloc, &t, .{ .id = .{ @@ -1124,10 +1339,10 @@ test "storage: delete intersecting cursor" { var s: ImageStorage = .{}; defer s.deinit(alloc, t.screens.active); - try s.addImage(io, alloc, .{ .id = 1, .width = 50, .height = 50 }); - try s.addImage(io, alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(io, alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); - try s.addPlacement(io, alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1, .width = 50, .height = 50 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2, .width = 25, .height = 25 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); t.screens.active.cursorAbsolute(12, 12); @@ -1145,6 +1360,61 @@ test "storage: delete intersecting cursor" { }) != null); } +test "storage: delete intersecting cursor checks interior row column" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 100, .cols = 100 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1, .width = 10, .height = 10 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 20, .y = 0 }) } }); + + // This is inside the right placement and on an interior row shared by + // both placements, but it is outside the left placement's columns. + t.screens.active.cursorAbsolute(21, 5); + s.delete(io, alloc, &t, .{ .intersect_cursor = false }); + + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }) != null); +} + +test "storage: delete intersecting cell checks interior row column" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 100, .cols = 100 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1, .width = 10, .height = 10 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 20, .y = 0 }) } }); + + // Protocol coordinates are one-based, so this targets grid cell (21, 5). + s.delete(io, alloc, &t, .{ .intersect_cell = .{ + .x = 22, + .y = 6, + } }); + + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }) != null); +} + test "storage: delete intersecting cursor plus unused" { const testing = std.testing; const alloc = testing.allocator; @@ -1157,10 +1427,10 @@ test "storage: delete intersecting cursor plus unused" { var s: ImageStorage = .{}; defer s.deinit(alloc, t.screens.active); - try s.addImage(io, alloc, .{ .id = 1, .width = 50, .height = 50 }); - try s.addImage(io, alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(io, alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); - try s.addPlacement(io, alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1, .width = 50, .height = 50 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2, .width = 25, .height = 25 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); t.screens.active.cursorAbsolute(12, 12); @@ -1190,10 +1460,10 @@ test "storage: delete intersecting cursor hits multiple" { var s: ImageStorage = .{}; defer s.deinit(alloc, t.screens.active); - try s.addImage(io, alloc, .{ .id = 1, .width = 50, .height = 50 }); - try s.addImage(io, alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(io, alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); - try s.addPlacement(io, alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1, .width = 50, .height = 50 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2, .width = 25, .height = 25 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); t.screens.active.cursorAbsolute(26, 26); @@ -1217,10 +1487,10 @@ test "storage: delete by column" { var s: ImageStorage = .{}; defer s.deinit(alloc, t.screens.active); - try s.addImage(io, alloc, .{ .id = 1, .width = 50, .height = 50 }); - try s.addImage(io, alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(io, alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); - try s.addPlacement(io, alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1, .width = 50, .height = 50 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2, .width = 25, .height = 25 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); s.dirty = false; s.delete(io, alloc, &t, .{ .column = .{ @@ -1250,10 +1520,10 @@ test "storage: delete by column 1x1" { var s: ImageStorage = .{}; defer s.deinit(alloc, t.screens.active); - try s.addImage(io, alloc, .{ .id = 1, .width = 1, .height = 1 }); - try s.addPlacement(io, alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); - try s.addPlacement(io, alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 0 }) } }); - try s.addPlacement(io, alloc, 1, 3, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 2, .y = 0 }) } }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1, .width = 1, .height = 1 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 0 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 1, 3, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 2, .y = 0 }) } }); s.delete(io, alloc, &t, .{ .column = .{ .delete = false, @@ -1285,10 +1555,10 @@ test "storage: delete by row" { var s: ImageStorage = .{}; defer s.deinit(alloc, t.screens.active); - try s.addImage(io, alloc, .{ .id = 1, .width = 50, .height = 50 }); - try s.addImage(io, alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(io, alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); - try s.addPlacement(io, alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1, .width = 50, .height = 50 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2, .width = 25, .height = 25 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); s.dirty = false; s.delete(io, alloc, &t, .{ .row = .{ @@ -1318,10 +1588,10 @@ test "storage: delete by row 1x1" { var s: ImageStorage = .{}; defer s.deinit(alloc, t.screens.active); - try s.addImage(io, alloc, .{ .id = 1, .width = 1, .height = 1 }); - try s.addPlacement(io, alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .y = 0 }) } }); - try s.addPlacement(io, alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .y = 1 }) } }); - try s.addPlacement(io, alloc, 1, 3, .{ .location = .{ .pin = try trackPin(&t, .{ .y = 2 }) } }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1, .width = 1, .height = 1 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .y = 0 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .y = 1 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 1, 3, .{ .location = .{ .pin = try trackPin(&t, .{ .y = 2 }) } }); s.delete(io, alloc, &t, .{ .row = .{ .delete = false, @@ -1351,11 +1621,11 @@ test "storage: delete images by range 1" { var s: ImageStorage = .{}; defer s.deinit(alloc, t.screens.active); - try s.addImage(io, alloc, .{ .id = 1 }); - try s.addImage(io, alloc, .{ .id = 2 }); - try s.addImage(io, alloc, .{ .id = 3 }); - try s.addPlacement(io, alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); - try s.addPlacement(io, alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 3 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); try testing.expectEqual(@as(usize, 3), s.images.count()); try testing.expectEqual(@as(usize, 2), s.placements.count()); @@ -1377,11 +1647,11 @@ test "storage: delete images by range 2" { var s: ImageStorage = .{}; defer s.deinit(alloc, t.screens.active); - try s.addImage(io, alloc, .{ .id = 1 }); - try s.addImage(io, alloc, .{ .id = 2 }); - try s.addImage(io, alloc, .{ .id = 3 }); - try s.addPlacement(io, alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); - try s.addPlacement(io, alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 3 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); try testing.expectEqual(@as(usize, 3), s.images.count()); try testing.expectEqual(@as(usize, 2), s.placements.count()); @@ -1403,20 +1673,29 @@ test "storage: delete images by range 3" { var s: ImageStorage = .{}; defer s.deinit(alloc, t.screens.active); - try s.addImage(io, alloc, .{ .id = 1 }); - try s.addImage(io, alloc, .{ .id = 2 }); - try s.addImage(io, alloc, .{ .id = 3 }); - try s.addPlacement(io, alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); - try s.addPlacement(io, alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 3 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 3, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); try testing.expectEqual(@as(usize, 3), s.images.count()); - try testing.expectEqual(@as(usize, 2), s.placements.count()); + try testing.expectEqual(@as(usize, 3), s.placements.count()); s.dirty = false; - s.delete(io, alloc, &t, .{ .range = .{ .delete = false, .first = 1, .last = 1 } }); + s.delete(io, alloc, &t, .{ .range = .{ .delete = false, .first = 2, .last = 2 } }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 3), s.images.count()); - try testing.expectEqual(@as(usize, 0), s.placements.count()); - try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }) != null); + try testing.expect(s.placements.get(.{ + .image_id = 3, + .placement_id = .{ .tag = .external, .id = 1 }, + }) != null); + try testing.expectEqual(tracked + 2, t.screens.active.pages.countTrackedPins()); } test "storage: delete images by range 4" { @@ -1429,20 +1708,29 @@ test "storage: delete images by range 4" { var s: ImageStorage = .{}; defer s.deinit(alloc, t.screens.active); - try s.addImage(io, alloc, .{ .id = 1 }); - try s.addImage(io, alloc, .{ .id = 2 }); - try s.addImage(io, alloc, .{ .id = 3 }); - try s.addPlacement(io, alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); - try s.addPlacement(io, alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 3 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 3, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); try testing.expectEqual(@as(usize, 3), s.images.count()); - try testing.expectEqual(@as(usize, 2), s.placements.count()); + try testing.expectEqual(@as(usize, 3), s.placements.count()); s.dirty = false; - s.delete(io, alloc, &t, .{ .range = .{ .delete = true, .first = 1, .last = 1 } }); + s.delete(io, alloc, &t, .{ .range = .{ .delete = true, .first = 2, .last = 2 } }); try testing.expect(s.dirty); - try testing.expectEqual(@as(usize, 1), s.images.count()); - try testing.expectEqual(@as(usize, 0), s.placements.count()); - try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); + try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }) != null); + try testing.expect(s.placements.get(.{ + .image_id = 3, + .placement_id = .{ .tag = .external, .id = 1 }, + }) != null); + try testing.expectEqual(tracked + 2, t.screens.active.pages.countTrackedPins()); } test "storage: aspect ratio calculation when only columns or rows specified" { @@ -1492,6 +1780,113 @@ test "storage: aspect ratio calculation when only columns or rows specified" { } } +test "storage: placement geometry handles untrusted dimensions" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + const max = std.math.maxInt(u32); + + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 2, .rows = 2 }); + defer t.deinit(alloc); + t.width_px = max; + t.height_px = max; + + // Cell dimensions multiplied by protocol-controlled row and column + // counts saturate instead of panicking or wrapping. + { + const placement: ImageStorage.Placement = .{ + .location = .{ .virtual = {} }, + .columns = 3, + .rows = 3, + }; + const actual = placement.pixelSize(.{ .width = 1, .height = 1 }, &t); + try testing.expectEqual(max, actual.width); + try testing.expectEqual(max, actual.height); + } + + // Aspect-ratio scaling also saturates when the derived dimension does + // not fit in the public u32 geometry type. + { + const placement: ImageStorage.Placement = .{ + .location = .{ .virtual = {} }, + .columns = 3, + .source_height = max, + }; + const actual = placement.pixelSize(.{ .width = 1, .height = 1 }, &t); + try testing.expectEqual(max, actual.width); + try testing.expectEqual(max, actual.height); + } + { + const placement: ImageStorage.Placement = .{ + .location = .{ .virtual = {} }, + .rows = 3, + .source_width = max, + }; + const actual = placement.pixelSize(.{ .width = 1, .height = 1 }, &t); + try testing.expectEqual(max, actual.width); + try testing.expectEqual(max, actual.height); + } + + // Pixel offsets are protocol-controlled too. Include them without + // allowing the grid-size numerator to wrap. + t.width_px = 2; + t.height_px = 2; + { + const placement: ImageStorage.Placement = .{ + .location = .{ .virtual = {} }, + .x_offset = max, + .y_offset = max, + }; + const actual = placement.gridSize(.{ .width = 1, .height = 1 }, &t); + try testing.expectEqual(max, actual.cols); + try testing.expectEqual(max, actual.rows); + } + + const pin = try trackPin(&t, .{ .x = 0, .y = 0 }); + defer t.screens.active.pages.untrackPin(pin); + + // Explicit maximum dimensions must clamp the rectangle to the terminal + // without overflowing its horizontal extent. + { + const placement: ImageStorage.Placement = .{ + .location = .{ .pin = pin }, + .columns = max, + .rows = 1, + }; + const rect = placement.rect(.{ .width = 1, .height = 1 }, &t).?; + try testing.expectEqual(@as(size.CellCountInt, 1), rect.bottom_right.x); + } + + // A garbage pin represents content that has been pruned from retained + // history. Its fallback location must not make the placement visible. + pin.garbage = true; + { + const placement: ImageStorage.Placement = .{ + .location = .{ .pin = pin }, + .columns = 1, + .rows = 1, + }; + try testing.expect(placement.rect(.{ .width = 1, .height = 1 }, &t) == null); + } + pin.garbage = false; + + // Terminals can temporarily have no pixel geometry. A placement whose + // computed grid is empty has no rectangle, so rect must not subtract one + // from a zero row count. + t.width_px = 0; + t.height_px = 0; + { + const placement: ImageStorage.Placement = .{ + .location = .{ .pin = pin }, + .columns = 1, + }; + const actual = placement.gridSize(.{ .width = 1, .height = 1 }, &t); + try testing.expectEqual(@as(u32, 0), actual.cols); + try testing.expectEqual(@as(u32, 0), actual.rows); + try testing.expect(placement.rect(.{ .width = 1, .height = 1 }, &t) == null); + } +} + test "storage: generation stamps on image add and replace" { const testing = std.testing; const io = testing.io; @@ -1505,7 +1900,7 @@ test "storage: generation stamps on image add and replace" { // Fresh storage has generation zero (never mutated). try testing.expectEqual(@as(u64, 0), s.generation); - try s.addImage(io, alloc, .{ .id = 1, .width = 1, .height = 1 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1, .width = 1, .height = 1 }); const gen1 = s.generation; try testing.expect(gen1 > 0); @@ -1513,7 +1908,7 @@ test "storage: generation stamps on image add and replace" { try testing.expectEqual(gen1, img1.generation); // A second image gets a strictly greater stamp. - try s.addImage(io, alloc, .{ .id = 2, .width = 1, .height = 1 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2, .width = 1, .height = 1 }); const gen2 = s.generation; try testing.expect(gen2 > gen1); try testing.expectEqual(gen2, s.imageById(2).?.generation); @@ -1521,7 +1916,7 @@ test "storage: generation stamps on image add and replace" { // Retransmitting the same image ID (identical dimensions) gets a // fresh stamp: this is what makes same-sized retransmissions // detectable by renderers. - try s.addImage(io, alloc, .{ .id = 1, .width = 1, .height = 1 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1, .width = 1, .height = 1 }); const gen3 = s.generation; try testing.expect(gen3 > gen2); try testing.expectEqual(gen3, s.imageById(1).?.generation); @@ -1539,10 +1934,10 @@ test "storage: generation bumps on placement and delete" { var s: ImageStorage = .{}; defer s.deinit(alloc, t.screens.active); - try s.addImage(io, alloc, .{ .id = 1 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); const gen_add = s.generation; - try s.addPlacement(io, alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); const gen_place = s.generation; try testing.expect(gen_place > gen_add); @@ -1566,12 +1961,17 @@ test "storage: generation bumps when setLimit evicts or disables" { defer s.deinit(alloc, t.screens.active); const data = try alloc.dupe(u8, "1234"); - try s.addImage(io, alloc, .{ .id = 1, .width = 1, .height = 1, .data = data }); + try s.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 1, + .height = 1, + .data = .{ .complete = data }, + }); const gen_add = s.generation; // Lowering the limit evicts the image and must mark a mutation. s.dirty = false; - try s.setLimit(io, alloc, t.screens.active, 1); + s.setLimit(io, alloc, t.screens.active, 1); try testing.expect(s.dirty); try testing.expect(s.generation > gen_add); try testing.expectEqual(@as(usize, 0), s.images.count()); @@ -1579,7 +1979,7 @@ test "storage: generation bumps when setLimit evicts or disables" { // Disabling (limit=0) resets the storage and must mark a mutation. s.dirty = false; - try s.setLimit(io, alloc, t.screens.active, 0); + s.setLimit(io, alloc, t.screens.active, 0); try testing.expect(s.dirty); try testing.expect(s.generation > gen_evict); } @@ -1596,12 +1996,12 @@ test "storage: imageByNumber returns most recently transmitted" { // Two images sharing a number: the newest transmission wins, // regardless of insertion order or clock resolution. - try s.addImage(io, alloc, .{ .id = 1, .number = 7 }); - try s.addImage(io, alloc, .{ .id = 2, .number = 7 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1, .number = 7 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2, .number = 7 }); try testing.expectEqual(@as(u32, 2), s.imageByNumber(7).?.id); // Retransmit the first: it becomes the newest. - try s.addImage(io, alloc, .{ .id = 1, .number = 7 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1, .number = 7 }); try testing.expectEqual(@as(u32, 1), s.imageByNumber(7).?.id); } @@ -1630,8 +2030,8 @@ test "storage: no-op delete does not mark a mutation" { try testing.expectEqual(@as(u64, 0), s.generation); // Same for a delete that matches nothing. - try s.addImage(io, alloc, .{ .id = 1 }); - try s.addPlacement(io, alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); const gen = s.generation; s.dirty = false; s.delete(io, alloc, &t, .{ .id = .{ .image_id = 42 } }); @@ -1644,7 +2044,7 @@ test "storage: no-op delete does not mark a mutation" { try testing.expect(s.generation > gen); } -test "storage: evict unused transient image" { +test "storage: evicts images in priority order" { const testing = std.testing; const io = testing.io; const alloc = testing.allocator; @@ -1654,36 +2054,279 @@ test "storage: evict unused transient image" { var s: ImageStorage = .{ .total_limit = 192 }; defer s.deinit(alloc, t.screens.active); - try s.addImage(io, alloc, .{ + try s.addImage(io, alloc, t.screens.active, .{ .id = 1, - .data = try alloc.dupe(u8, "*" ** 64), - .usage = .{ .transient = false }, + .data = .{ .complete = try alloc.dupe(u8, "*" ** 64) }, + .metadata = .{ .transient = false }, }); - try s.addImage(io, alloc, .{ + try s.addImage(io, alloc, t.screens.active, .{ .id = 2, - .data = try alloc.dupe(u8, "*" ** 64), - .usage = .{ .transient = true }, + .data = .{ .complete = try alloc.dupe(u8, "*" ** 64) }, + .metadata = .{ .transient = true }, }); - try s.addImage(io, alloc, .{ + try s.addImage(io, alloc, t.screens.active, .{ .id = 3, - .data = try alloc.dupe(u8, "*" ** 64), - .usage = .{ .transient = true }, + .data = .{ .complete = try alloc.dupe(u8, "*" ** 64) }, + .metadata = .{ .transient = true }, }); try s.addPlacement( io, alloc, + t.screens.active, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }, ); const gen = s.generation; - const result = try s.evictImage(io, alloc, 32); + const result = s.evictImage(io, alloc, t.screens.active, 96); try testing.expect(s.dirty); try testing.expect(s.generation > gen); try testing.expectEqual(true, result); - try testing.expectEqual(2, s.images.count()); - try testing.expect(s.images.contains(1)); + try testing.expectEqual(1, s.images.count()); + try testing.expect(!s.images.contains(1)); try testing.expect(s.images.contains(2)); try testing.expect(!s.images.contains(3)); } + +test "storage: eviction releases placement pins" { + const testing = std.testing; + const io = testing.io; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{ .total_limit = 128 }; + defer s.deinit(alloc, t.screens.active); + + const tracked = t.screens.active.pages.countTrackedPins(); + try s.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .data = .{ .complete = try alloc.dupe(u8, "*" ** 64) }, + }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }, + }); + try s.addImage(io, alloc, t.screens.active, .{ + .id = 2, + .data = .{ .complete = try alloc.dupe(u8, "*" ** 64) }, + }); + try s.addPlacement(io, alloc, t.screens.active, 2, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }, + }); + try testing.expectEqual(tracked + 2, t.screens.active.pages.countTrackedPins()); + + // Adding a third image evicts the oldest equally-ranked image and its + // placement. The newer image's placement and tracked pin remain intact. + try s.addImage(io, alloc, t.screens.active, .{ + .id = 3, + .data = .{ .complete = try alloc.dupe(u8, "*" ** 64) }, + }); + try testing.expect(!s.images.contains(1)); + try testing.expect(s.images.contains(2)); + try testing.expect(s.images.contains(3)); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expect(s.placements.contains(.{ + .image_id = 2, + .placement_id = .{ .tag = .external, .id = 1 }, + })); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); +} + +test "storage: pending image completes once and preserves age" { + const testing = std.testing; + const io = testing.io; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{ .total_limit = 16 }; + defer s.deinit(alloc, t.screens.active); + + const pending = try s.addPendingImage(io, alloc, t.screens.active, .{ + .id = 1, + .number = 7, + .width = 2, + .height = 2, + .format = .rgba, + .data = .{ .pending = 16 }, + }); + try testing.expectEqual(@as(usize, 16), s.total_bytes); + try testing.expectEqual(@as(u32, 1), s.imageByNumber(7).?.id); + try testing.expect(s.imageById(1).?.data.isPending()); + + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }, + }); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + + const wrong = try alloc.dupe(u8, "short"); + const wrong_completed = pending.complete(&s, io, wrong); + try testing.expect(!wrong_completed); + defer alloc.free(wrong); + + const storage_generation = s.generation; + s.dirty = false; + const pixels = try alloc.dupe(u8, "*" ** 16); + try testing.expect(pending.complete(&s, io, pixels)); + try testing.expect(s.dirty); + try testing.expect(s.generation > storage_generation); + try testing.expectEqual(pending.generation, s.imageById(1).?.generation); + try testing.expectEqual(@as(usize, 16), s.total_bytes); + try testing.expectEqualSlices(u8, pixels, s.imageById(1).?.data.bytes().?); + + const duplicate = try alloc.dupe(u8, "!" ** 16); + const duplicate_completed = pending.complete(&s, io, duplicate); + try testing.expect(!duplicate_completed); + defer alloc.free(duplicate); +} + +test "storage: stale pending completion loses to delete replacement and eviction" { + const testing = std.testing; + const io = testing.io; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{ .total_limit = 8 }; + defer s.deinit(alloc, t.screens.active); + + const deleted = try s.addPendingImage(io, alloc, t.screens.active, .{ + .id = 1, + .data = .{ .pending = 4 }, + }); + s.delete(io, alloc, &t, .{ .id = .{ .delete = true, .image_id = 1 } }); + const deleted_data = try alloc.dupe(u8, "gone"); + const deleted_completed = deleted.complete(&s, io, deleted_data); + try testing.expect(!deleted_completed); + defer alloc.free(deleted_data); + + const replaced = try s.addPendingImage(io, alloc, t.screens.active, .{ + .id = 2, + .data = .{ .pending = 4 }, + }); + try s.addImage(io, alloc, t.screens.active, .{ + .id = 2, + .data = .{ .complete = try alloc.dupe(u8, "live") }, + }); + const replaced_data = try alloc.dupe(u8, "late"); + const replaced_completed = replaced.complete(&s, io, replaced_data); + try testing.expect(!replaced_completed); + defer alloc.free(replaced_data); + try testing.expectEqualStrings("live", s.imageById(2).?.data.bytes().?); + + const evicted = try s.addPendingImage(io, alloc, t.screens.active, .{ + .id = 3, + .data = .{ .pending = 4 }, + }); + try s.addImage(io, alloc, t.screens.active, .{ + .id = 4, + .data = .{ .complete = try alloc.dupe(u8, "12345678") }, + }); + try testing.expect(s.imageById(3) == null); + const evicted_data = try alloc.dupe(u8, "late"); + const evicted_completed = evicted.complete(&s, io, evicted_data); + try testing.expect(!evicted_completed); + defer alloc.free(evicted_data); +} + +test "storage: replacement reuses pending reservation and removes placements" { + const testing = std.testing; + const io = testing.io; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{ .total_limit = 12 }; + defer s.deinit(alloc, t.screens.active); + const tracked = t.screens.active.pages.countTrackedPins(); + + const pending = try s.addPendingImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 2, + .height = 1, + .format = .rgba, + .data = .{ .pending = 8 }, + }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }, + }); + try s.addImage(io, alloc, t.screens.active, .{ + .id = 2, + .data = .{ .complete = try alloc.dupe(u8, "keep") }, + }); + + try s.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 2, + .height = 1, + .format = .rgba, + .data = .{ .complete = try alloc.dupe(u8, "12345678") }, + }); + try testing.expect(s.images.contains(1)); + try testing.expect(s.images.contains(2)); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); + try testing.expectEqual( + @as(u30, 0), + s.imageById(1).?.metadata.placement_count, + ); + try testing.expectEqual(@as(usize, 12), s.total_bytes); + + const stale = try alloc.dupe(u8, "snapshot"); + const stale_completed = pending.complete(&s, io, stale); + try testing.expect(!stale_completed); + defer alloc.free(stale); + + // Growing the replacement requires eviction, but the replacement ID is + // excluded. The other image supplies the needed bytes. + try s.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .data = .{ .complete = try alloc.dupe(u8, "1234567890") }, + }); + try testing.expect(s.images.contains(1)); + try testing.expect(!s.images.contains(2)); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(@as(usize, 10), s.total_bytes); + + s.delete(io, alloc, &t, .{ .id = .{ .delete = true, .image_id = 1 } }); + try testing.expectEqual(@as(usize, 0), s.images.count()); + try testing.expectEqual(@as(usize, 0), s.placements.count()); +} + +test "storage: pending images share exact eviction ordering" { + const testing = std.testing; + const io = testing.io; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{ .total_limit = 192 }; + defer s.deinit(alloc, t.screens.active); + + _ = try s.addPendingImage(io, alloc, t.screens.active, .{ + .id = 1, + .data = .{ .pending = 64 }, + .metadata = .{ .transient = false }, + }); + _ = try s.addPendingImage(io, alloc, t.screens.active, .{ + .id = 2, + .data = .{ .pending = 64 }, + .metadata = .{ .transient = true }, + }); + try s.addImage(io, alloc, t.screens.active, .{ + .id = 3, + .data = .{ .complete = try alloc.dupe(u8, "*" ** 64) }, + .metadata = .{ .transient = true }, + }); + try s.addPlacement(io, alloc, t.screens.active, 2, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }, + }); + + // ID 3 is transient and unused, so one exact-size eviction is enough. + try testing.expect(s.evictImage(io, alloc, t.screens.active, 64)); + try testing.expect(s.images.contains(1)); + try testing.expect(s.images.contains(2)); + try testing.expect(!s.images.contains(3)); + try testing.expectEqual(@as(usize, 128), s.total_bytes); +} diff --git a/src/terminal/kitty/graphics_unicode.zig b/src/terminal/kitty/graphics_unicode.zig index dd1a6ce44..485502593 100644 --- a/src/terminal/kitty/graphics_unicode.zig +++ b/src/terminal/kitty/graphics_unicode.zig @@ -1195,8 +1195,8 @@ test "unicode render placement: dog 4x2" { defer s.deinit(alloc, t.screens.active); const image: Image = .{ .id = 1, .width = 500, .height = 306 }; - try s.addImage(io, alloc, image); - try s.addPlacement(io, alloc, 1, 0, .{ + try s.addImage(io, alloc, t.screens.active, image); + try s.addPlacement(io, alloc, t.screens.active, 1, 0, .{ .location = .{ .virtual = {} }, .columns = 4, .rows = 2, @@ -1263,8 +1263,8 @@ test "unicode render placement: dog 2x2 with blank cells" { defer s.deinit(alloc, t.screens.active); const image: Image = .{ .id = 1, .width = 500, .height = 306 }; - try s.addImage(io, alloc, image); - try s.addPlacement(io, alloc, 1, 0, .{ + try s.addImage(io, alloc, t.screens.active, image); + try s.addPlacement(io, alloc, t.screens.active, 1, 0, .{ .location = .{ .virtual = {} }, .columns = 2, .rows = 2, @@ -1330,8 +1330,8 @@ test "unicode render placement: dog 1x1" { defer s.deinit(alloc, t.screens.active); const image: Image = .{ .id = 1, .width = 500, .height = 306 }; - try s.addImage(io, alloc, image); - try s.addPlacement(io, alloc, 1, 0, .{ + try s.addImage(io, alloc, t.screens.active, image); + try s.addPlacement(io, alloc, t.screens.active, 1, 0, .{ .location = .{ .virtual = {} }, .columns = 1, .rows = 1, diff --git a/src/terminal/lib.zig b/src/terminal/lib.zig index 921d831b7..040d1c4cd 100644 --- a/src/terminal/lib.zig +++ b/src/terminal/lib.zig @@ -14,6 +14,7 @@ pub const calling_conv: std.builtin.CallingConvention = .c; /// Forwarded decls from lib that are used. pub const alloc = lib.allocator; +pub const TinyIo = lib.TinyIo; pub const Buffer = lib.Buffer; pub const Enum = lib.Enum; pub const TaggedUnion = lib.TaggedUnion; diff --git a/src/terminal/main.zig b/src/terminal/main.zig index cee4bcb94..fb846aeff 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -59,6 +59,7 @@ pub const Terminal = @import("Terminal.zig"); pub const TerminalStream = stream_terminal.Stream; pub const Stream = stream.Stream; pub const StreamAction = stream.Action; +pub const UnknownSequence = stream_terminal.Handler.UnknownSequence; pub const Cursor = Screen.Cursor; pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = ansi.CursorStyle; diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index 7403f3ffa..c545b3304 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -34,22 +34,18 @@ pub const ModeState = struct { /// Set a mode to a value. pub fn set(self: *ModeState, mode: Mode, value: bool) void { - switch (mode) { - inline else => |mode_comptime| { - const entry = comptime entryForMode(mode_comptime); - @field(self.values, entry.name) = value; - }, - } + setPacked(&self.values, mode, value); + } + + /// Set the reset default and current value for a mode. + pub fn setDefault(self: *ModeState, mode: Mode, value: bool) void { + setPacked(&self.values, mode, value); + setPacked(&self.default, mode, value); } /// Get the value of a mode. pub fn get(self: *const ModeState, mode: Mode) bool { - switch (mode) { - inline else => |mode_comptime| { - const entry = comptime entryForMode(mode_comptime); - return @field(self.values, entry.name); - }, - } + return getPacked(&self.values, mode); } /// Save the state of the given mode. This can then be restored @@ -79,6 +75,19 @@ pub const ModeState = struct { /// Return a DECRPM report for the given mode tag. If the tag does /// not correspond to a known mode, the report state is .not_recognized. pub fn getReport(self: *const ModeState, tag: ModeTag) Report { + // DECECM (Erase Color Mode, DEC private mode 117) controls whether erasing + // and scrolling use the default background or the active background color. + // Ghostty's behavior is fixed equivalent to DECECM reset, and DECRQM has a + // "permanently reset" response for recognized modes that cannot be changed. + // Report that instead of "not recognized" so applications can query and adapt + // to Ghostty's erase-color behavior. + // + // See VT520/VT525 Programmer Information, "Erase Color" and DECRQM/DECRPM: + // https://web.mit.edu/dosathena/doc/www/ek-vt520-rm.pdf + + if (!tag.ansi and tag.value == 117) { + return .{ .tag = tag, .state = .permanently_reset }; + } const mode = modeFromInt(tag.value, tag.ansi) orelse return .{ .tag = tag, .state = .not_recognized, @@ -97,6 +106,24 @@ pub const ModeState = struct { } }; +fn setPacked(values: *ModePacked, mode: Mode, value: bool) void { + switch (mode) { + inline else => |mode_comptime| { + const entry = comptime entryForMode(mode_comptime); + @field(values, entry.name) = value; + }, + } +} + +fn getPacked(values: *const ModePacked, mode: Mode) bool { + switch (mode) { + inline else => |mode_comptime| { + const entry = comptime entryForMode(mode_comptime); + return @field(values, entry.name); + }, + } +} + /// A packed struct of all the settable modes. This shouldn't /// be used directly but rather through the ModeState struct. pub const ModePacked = packed_struct: { @@ -226,8 +253,24 @@ const ModeEntry = struct { /// set or queried. The mode enum still has it, allowing Ghostty developers /// to develop a mode without exposing it to real users. disabled: bool = false, + + /// Whether an embedder may safely configure this bit as reset policy. + /// Modes that perform a transition or mirror additional terminal state + /// must use their semantic configuration API instead. + default_configurable: bool = true, }; +/// Return whether a mode can safely be configured as a reset default by an +/// embedder. This excludes modes whose set/reset operations have side effects +/// beyond changing the mode bit. +pub fn defaultConfigurable(mode: Mode) bool { + switch (mode) { + inline else => |mode_comptime| { + return comptime entryForMode(mode_comptime).default_configurable; + }, + } +} + /// The full list of available entries. For documentation see how /// they're used within Ghostty or google their values. It is not /// valuable to redocument them all here. @@ -243,46 +286,46 @@ const entries: []const ModeEntry = &.{ // DEC .{ .name = "cursor_keys", .value = 1 }, // DECCKM - .{ .name = "132_column", .value = 3 }, + .{ .name = "132_column", .value = 3, .default_configurable = false }, .{ .name = "slow_scroll", .value = 4 }, .{ .name = "reverse_colors", .value = 5 }, - .{ .name = "origin", .value = 6 }, + .{ .name = "origin", .value = 6, .default_configurable = false }, .{ .name = "wraparound", .value = 7, .default = true }, .{ .name = "autorepeat", .value = 8 }, - .{ .name = "mouse_event_x10", .value = 9 }, - .{ .name = "cursor_blinking", .value = 12 }, + .{ .name = "mouse_event_x10", .value = 9, .default_configurable = false }, + .{ .name = "cursor_blinking", .value = 12, .default_configurable = false }, .{ .name = "cursor_visible", .value = 25, .default = true }, .{ .name = "enable_mode_3", .value = 40 }, .{ .name = "reverse_wrap", .value = 45 }, - .{ .name = "alt_screen_legacy", .value = 47 }, + .{ .name = "alt_screen_legacy", .value = 47, .default_configurable = false }, .{ .name = "keypad_keys", .value = 66 }, // DEC Backarrow Key Mode (DECBKM) // See https://vt100.net/dec/ek-vt3xx-tp-002.pdf page 170 // If `false` (the default), `backspace` emits 0x7f // If `true`, `backspace` emits 0x08 .{ .name = "backarrow_key_mode", .value = 67 }, - .{ .name = "enable_left_and_right_margin", .value = 69 }, - .{ .name = "mouse_event_normal", .value = 1000 }, - .{ .name = "mouse_event_button", .value = 1002 }, - .{ .name = "mouse_event_any", .value = 1003 }, + .{ .name = "enable_left_and_right_margin", .value = 69, .default_configurable = false }, + .{ .name = "mouse_event_normal", .value = 1000, .default_configurable = false }, + .{ .name = "mouse_event_button", .value = 1002, .default_configurable = false }, + .{ .name = "mouse_event_any", .value = 1003, .default_configurable = false }, .{ .name = "focus_event", .value = 1004 }, - .{ .name = "mouse_format_utf8", .value = 1005 }, - .{ .name = "mouse_format_sgr", .value = 1006 }, + .{ .name = "mouse_format_utf8", .value = 1005, .default_configurable = false }, + .{ .name = "mouse_format_sgr", .value = 1006, .default_configurable = false }, .{ .name = "mouse_alternate_scroll", .value = 1007, .default = true }, - .{ .name = "mouse_format_urxvt", .value = 1015 }, - .{ .name = "mouse_format_sgr_pixels", .value = 1016 }, + .{ .name = "mouse_format_urxvt", .value = 1015, .default_configurable = false }, + .{ .name = "mouse_format_sgr_pixels", .value = 1016, .default_configurable = false }, .{ .name = "ignore_keypad_with_numlock", .value = 1035, .default = true }, .{ .name = "alt_esc_prefix", .value = 1036, .default = true }, .{ .name = "alt_sends_escape", .value = 1039 }, .{ .name = "reverse_wrap_extended", .value = 1045 }, - .{ .name = "alt_screen", .value = 1047 }, - .{ .name = "save_cursor", .value = 1048 }, - .{ .name = "alt_screen_save_cursor_clear_enter", .value = 1049 }, + .{ .name = "alt_screen", .value = 1047, .default_configurable = false }, + .{ .name = "save_cursor", .value = 1048, .default_configurable = false }, + .{ .name = "alt_screen_save_cursor_clear_enter", .value = 1049, .default_configurable = false }, .{ .name = "bracketed_paste", .value = 2004 }, - .{ .name = "synchronized_output", .value = 2026 }, + .{ .name = "synchronized_output", .value = 2026, .default_configurable = false }, .{ .name = "grapheme_cluster", .value = 2027 }, .{ .name = "report_color_scheme", .value = 2031 }, - .{ .name = "report_visibility", .value = 2033 }, + .{ .name = "report_visibility", .value = 2033, .default_configurable = false }, .{ .name = "in_band_size_reports", .value = 2048 }, }; @@ -314,6 +357,32 @@ test ModeState { try testing.expect(state.get(.cursor_keys)); } +test "ModeState set default updates current and reset value" { + var state: ModeState = .{}; + + state.setDefault(.grapheme_cluster, true); + try testing.expect(state.get(.grapheme_cluster)); + try testing.expect(state.default.grapheme_cluster); + + state.set(.grapheme_cluster, false); + try testing.expect(!state.get(.grapheme_cluster)); + try testing.expect(state.default.grapheme_cluster); + + state.setDefault(.grapheme_cluster, true); + try testing.expect(state.get(.grapheme_cluster)); + + state.set(.grapheme_cluster, false); + state.reset(); + try testing.expect(state.get(.grapheme_cluster)); +} + +test "default configurable modes" { + try testing.expect(defaultConfigurable(.grapheme_cluster)); + try testing.expect(defaultConfigurable(.wraparound)); + try testing.expect(!defaultConfigurable(.alt_screen)); + try testing.expect(!defaultConfigurable(.cursor_blinking)); +} + test "getReport known DEC mode" { var state: ModeState = .{}; const report = state.getReport(.{ .value = 1 }); @@ -334,6 +403,13 @@ test "getReport known ANSI mode" { try testing.expectEqual(true, report.tag.ansi); } +test "getReport DECECM permanently reset" { + const state: ModeState = .{}; + const report = state.getReport(.{ .value = 117, .ansi = false }); + try testing.expectEqual(Report.State.permanently_reset, report.state); + try testing.expectEqual(false, report.tag.ansi); +} + test "getReport unknown mode" { const state: ModeState = .{}; const report = state.getReport(.{ .value = 9999 }); diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index e3d323915..618777241 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -300,11 +300,20 @@ pub const Parser = struct { /// Maximum size of a "normal" OSC. pub const MAX_BUF = 2048; + /// Maximum size of an OSC that requires dynamically allocated storage. + /// OSC input is untrusted, so these captures must have a finite bound. + pub const MAX_ALLOCATING_BUF = 8 * 1024 * 1024; + /// Optional allocator used to accept data longer than MAX_BUF. /// This only applies to some commands (e.g. OSC 52) that can /// reasonably exceed MAX_BUF. alloc: ?Allocator, + /// Maximum number of bytes retained by an allocating capture. + /// This is configurable primarily so callers and tests can choose a + /// smaller policy than the default. + max_allocating_bytes: usize, + /// Current state of the parser. state: State, @@ -376,6 +385,7 @@ pub const Parser = struct { pub fn init(alloc: ?Allocator) Parser { var result: Parser = .{ .alloc = alloc, + .max_allocating_bytes = MAX_ALLOCATING_BUF, .state = .start, .capture = null, .command = .invalid, @@ -461,6 +471,7 @@ pub const Parser = struct { const Capture = struct { writer: *std.Io.Writer, backing: Backing, + max_bytes: usize, const Backing = union(enum) { fixed: std.Io.Writer, @@ -476,22 +487,50 @@ pub const Parser = struct { new.* = .{ .backing = .{ .fixed = .fixed(buf) }, .writer = &new.*.?.backing.fixed, + .max_bytes = buf.len, }; } pub inline fn allocating( new: *?Capture, alloc: Allocator, + max_bytes: usize, ) error{OutOfMemory}!void { new.* = .{ .backing = .{ .allocating = try std.Io.Writer.Allocating.initCapacity( alloc, - 2048, + @min(MAX_BUF, max_bytes), ) }, .writer = &new.*.?.backing.allocating.writer, + .max_bytes = max_bytes, }; } + /// Append one byte without permitting the backing allocation to grow + /// beyond max_bytes. Allocating.Writer normally grows super-linearly, + /// so grow it explicitly to keep the allocation itself bounded too. + pub inline fn writeByte(self: *Capture, byte: u8) error{WriteFailed}!void { + if (self.writer.buffered().len >= self.max_bytes) return error.WriteFailed; + + switch (self.backing) { + .fixed => {}, + .allocating => |*w| { + if (w.writer.end >= w.writer.buffer.len) { + const new_capacity = @min( + self.max_bytes, + @max(w.writer.buffer.len *| 2, 1), + ); + w.writer.buffer = w.allocator.realloc( + w.writer.buffer, + new_capacity, + ) catch return error.WriteFailed; + } + }, + } + + try self.writer.writeByte(byte); + } + pub fn deinit(self: *Capture) void { switch (self.backing) { .fixed => {}, @@ -535,6 +574,7 @@ pub const Parser = struct { Capture.allocating( &self.capture, alloc, + self.max_allocating_bytes, ) catch { // The allocator failed for some reason, fall back to a fixed buffer // and hope that it's big enough. @@ -554,7 +594,7 @@ pub const Parser = struct { // If a writer has been initialized, we just accumulate the rest of the // OSC sequence in the writer's buffer and skip the state machine. if (self.capture) |*cap| { - cap.writer.writeByte(c) catch |err| switch (err) { + cap.writeByte(c) catch |err| switch (err) { // We have overflowed our buffer or had some other error, set the // state to invalid so that we discard any further input. error.WriteFailed => self.state = .invalid, @@ -845,3 +885,42 @@ test { _ = parsers; _ = encoding; } + +test "Parser allocating captures have a hard limit" { + const testing = std.testing; + const prefixes = [_][]const u8{ "52;", "66;", "72;", "5522;" }; + const limit = Parser.MAX_BUF + 1; + + for (prefixes) |prefix| { + var p: Parser = .init(testing.allocator); + defer p.deinit(); + p.max_allocating_bytes = limit; + + for (prefix) |ch| p.next(ch); + for (0..limit) |_| p.next('a'); + + const cap = &p.capture.?; + try testing.expectEqual(@as(usize, limit), cap.trailing().len); + try testing.expectEqual(@as(usize, limit), cap.writer.buffer.len); + + p.next('a'); + try testing.expectEqual(Parser.State.invalid, p.state); + try testing.expectEqual(@as(usize, limit), cap.trailing().len); + try testing.expectEqual(@as(usize, limit), cap.writer.buffer.len); + } +} + +test "Parser allocating capture limit includes parser-added bytes" { + const testing = std.testing; + var p: Parser = .init(testing.allocator); + defer p.deinit(); + p.max_allocating_bytes = 4; + + for ("52;abcd") |ch| p.next(ch); + try testing.expect(p.end(null) == null); + try testing.expectEqual(Parser.State.invalid, p.state); + + const cap = &p.capture.?; + try testing.expectEqual(@as(usize, 4), cap.trailing().len); + try testing.expectEqual(@as(usize, 4), cap.writer.buffer.len); +} diff --git a/src/terminal/osc/parsers/clipboard_operation.zig b/src/terminal/osc/parsers/clipboard_operation.zig index 8d149f97c..ec8836fb0 100644 --- a/src/terminal/osc/parsers/clipboard_operation.zig +++ b/src/terminal/osc/parsers/clipboard_operation.zig @@ -12,7 +12,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command { parser.state = .invalid; return null; }; - cap.writer.writeByte(0) catch { + cap.writeByte(0) catch { parser.state = .invalid; return null; }; diff --git a/src/terminal/osc/parsers/kitty_text_sizing.zig b/src/terminal/osc/parsers/kitty_text_sizing.zig index 94f271005..a60ed5d75 100644 --- a/src/terminal/osc/parsers/kitty_text_sizing.zig +++ b/src/terminal/osc/parsers/kitty_text_sizing.zig @@ -77,7 +77,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command { }; // Write a NUL byte to ensure that `text` is NUL-terminated - cap.writer.writeByte(0) catch { + cap.writeByte(0) catch { parser.state = .invalid; return null; }; diff --git a/src/terminal/page.zig b/src/terminal/page.zig index dae019c1f..c2814ae98 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -89,6 +89,7 @@ const AllocWindows = struct { /// for alignment. const grapheme_chunk_len = 4; const grapheme_chunk = grapheme_chunk_len * @sizeOf(u21); +pub const grapheme_max_len = 64; const GraphemeAlloc = BitmapAllocator(grapheme_chunk); const grapheme_count_default = GraphemeAlloc.bitmap_bit_size; pub const grapheme_bytes_default = grapheme_count_default * grapheme_chunk; @@ -1502,7 +1503,8 @@ pub const Page = struct { } /// Set the graphemes for the given cell. This asserts that the cell - /// has no graphemes set, and only contains a single codepoint. + /// has no graphemes set, and only contains a single codepoint. Input + /// beyond grapheme_max_len is ignored. pub inline fn setGraphemes( self: *Page, row: *Row, @@ -1516,14 +1518,15 @@ pub const Page = struct { const cell_offset = getOffset(Cell, self.memory, cell); var map = self.grapheme_map.map(self.memory); + const stored_cps = cps[0..@min(cps.len, grapheme_max_len)]; - const slice = self.grapheme_alloc.alloc(u21, self.memory, cps.len) catch |e| { + const slice = self.grapheme_alloc.alloc(u21, self.memory, stored_cps.len) catch |e| { comptime assert(@TypeOf(e) == error{OutOfMemory}); // The grapheme alloc capacity needs to be increased. return error.GraphemeAllocOutOfMemory; }; errdefer self.grapheme_alloc.free(self.memory, slice); - @memcpy(slice, cps); + @memcpy(slice, stored_cps); map.putNoClobber(cell_offset, .{ .offset = getOffset(u21, self.memory, @ptrCast(slice.ptr)), @@ -1541,7 +1544,8 @@ pub const Page = struct { return; } - /// Append a codepoint to the given cell as a grapheme. + /// Append a codepoint to the given cell as a grapheme. Once the cell has + /// grapheme_max_len suffix codepoints, additional codepoints are ignored. pub fn appendGrapheme(self: *Page, row: *Row, cell: *Cell, cp: u21) Allocator.Error!void { defer self.assertIntegrity(); @@ -1575,6 +1579,10 @@ pub const Page = struct { const slice = map.getPtr(cell_offset).?; + // Terminal input is untrusted. In addition to bounding memory, this + // prevents repeated chunk growth and copying from becoming quadratic. + if (slice.len >= grapheme_max_len) return; + // If our slice len doesn't divide evenly by the grapheme chunk // length then we can utilize the additional chunk space. if (slice.len % grapheme_chunk_len != 0) { @@ -2239,6 +2247,12 @@ pub const Cell = packed struct(u64) { /// struct T, used for masked compares of raw backing-integer values /// (e.g. `Row.Backing`, `Cell.Backing`). This is an implementation /// detail of `Mask`, which is the public API built on top of this. +/// +/// A field may be a dot-separated path (e.g. "content.codepoint.data") +/// to cover only a nested field of a packed struct or packed union +/// member. This allows a mask to be more precise than a whole +/// top-level field, e.g. covering the codepoint bits of a cell without +/// its padding. fn fieldMask( comptime T: type, comptime fields: []const []const u8, @@ -2246,16 +2260,33 @@ fn fieldMask( // Backing int of the packed struct const Int = @typeInfo(T).@"struct".backing_integer.?; - var mask: Int = 0; - inline for (fields) |field| { + comptime var mask: Int = 0; + inline for (fields) |path| { + // Walk the path to find the total bit offset and the type of + // the (possibly nested) field. + comptime var offset = 0; + comptime var Field = T; + comptime var it = std.mem.splitScalar(u8, path, '.'); + inline while (comptime it.next()) |name| { + offset += switch (@typeInfo(Field)) { + .@"struct" => @bitOffsetOf(Field, name), + + // Packed union members all share bit offset zero. + .@"union" => |u| offset: { + comptime assert(u.layout == .@"packed"); + break :offset 0; + }, + + else => @compileError("invalid field path: " ++ path), + }; + Field = @FieldType(Field, name); + } + // The type that fits all the bits we need to set. - const Ones = std.meta.Int( - .unsigned, - @bitSizeOf(@FieldType(T, field)), - ); + const Ones = std.meta.Int(.unsigned, @bitSizeOf(Field)); // Mask out the ones - mask |= @as(Int, std.math.maxInt(Ones)) << @bitOffsetOf(T, field); + mask |= @as(Int, std.math.maxInt(Ones)) << offset; } return mask; @@ -2378,6 +2409,21 @@ pub fn Mask( return pattern(v) == expected; } + /// Returns true if any value in the group of group_len values + /// starting at index i has masked fields equal to the expected + /// pattern (see `pattern`). This is the "any" counterpart to + /// `eql`: use it to detect the presence of a specific value + /// within a group, e.g. a run scan that must stop when it + /// encounters a sentinel codepoint anywhere in the group. + pub inline fn eqlAny( + values: []const T, + i: usize, + expected: Backing, + ) bool { + const masked = load(values, i) & @as(Group, @splat(mask)); + return @reduce(.Or, masked == @as(Group, @splat(expected))); + } + /// Like `eql` but returns the number of leading values whose /// masked fields equal the expected pattern, i.e. group_len if /// the entire group matches. This is useful for early-exit run @@ -2497,6 +2543,59 @@ test "Mask" { try testing.expectEqual(M.strip(styled), M.strip(styled_other)); try testing.expect(M.strip(styled) != M.strip(styled2)); } + + // eqlAny: presence of a matching value anywhere in the group + { + const expected = M.pattern(styled); + var cells: [4]Cell = .{ plain, plain, plain, plain }; + try testing.expect(!M.eqlAny(&cells, 0, expected)); + + cells[2] = styled; + try testing.expect(M.eqlAny(&cells, 0, expected)); + + // Masked compare: same masked fields with a different + // codepoint still matches. + cells[2] = styled2; + try testing.expect(M.eqlAny(&cells, 0, expected)); + } +} + +test "Mask nested field path" { + // Mask only the codepoint data bits of the content field, not + // the padding next to it or any other field. + const M = Mask(Cell, &.{"content.codepoint.data"}, 4); + + const a: Cell = .init('A'); + var b: Cell = .init('A'); + b.style_id = 5; + b.wide = .wide; + const c: Cell = .init('C'); + + // Same codepoint matches regardless of other fields. + const expected = M.pattern(a); + try testing.expect(M.eqlScalar(b, expected)); + try testing.expect(!M.eqlScalar(c, expected)); + + // The mask must cover exactly the codepoint data bits. + const cp_offset = @bitOffsetOf(Cell, "content"); + try testing.expectEqual( + @as(u64, std.math.maxInt(u21)) << cp_offset, + comptime fieldMask(Cell, &.{"content.codepoint.data"}), + ); + + // Group variants + { + var cells: [4]Cell = .{ a, b, a, b }; + try testing.expect(M.eql(&cells, 0, expected)); + try testing.expect(M.eqlAny(&cells, 0, expected)); + + cells[1] = c; + try testing.expect(!M.eql(&cells, 0, expected)); + try testing.expect(M.eqlAny(&cells, 0, expected)); + + const none: [4]Cell = .{ c, c, c, c }; + try testing.expect(!M.eqlAny(&none, 0, expected)); + } } // Uncomment this when you want to do some math. @@ -2740,6 +2839,49 @@ test "Page appendGrapheme larger than chunk" { } } +test "Page appendGrapheme caps codepoints per cell" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .init('A'); + + for (0..grapheme_max_len + 16) |i| { + try page.appendGrapheme(rac.row, rac.cell, @intCast(0x0300 + i)); + } + + const cps = page.lookupGrapheme(rac.cell).?; + try testing.expectEqual(@as(usize, grapheme_max_len), cps.len); + for (0..grapheme_max_len) |i| { + try testing.expectEqual(@as(u21, @intCast(0x0300 + i)), cps[i]); + } +} + +test "Page setGraphemes caps codepoints per cell" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + var input: [grapheme_max_len + 16]u21 = undefined; + for (&input, 0..) |*cp, i| cp.* = @intCast(0x0300 + i); + + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .init('A'); + try page.setGraphemes(rac.row, rac.cell, &input); + + try testing.expectEqual( + @as(usize, grapheme_max_len), + page.lookupGrapheme(rac.cell).?.len, + ); +} + test "Page clearGrapheme not all cells" { var page = try Page.init(.{ .cols = 10, diff --git a/src/terminal/snapshot/checkpoint.zig b/src/terminal/snapshot/checkpoint.zig index 79b04242f..23ea755f8 100644 --- a/src/terminal/snapshot/checkpoint.zig +++ b/src/terminal/snapshot/checkpoint.zig @@ -1,42 +1,20 @@ -//! READY and FINISH snapshot checkpoint records. +//! READY and FINISH snapshot marker records. //! -//! Each checkpoint payload is one BLAKE3-256 digest of every snapshot byte -//! before that checkpoint's record header. This binds record order and detects -//! omitted or duplicated records in ways that independent record CRCs cannot. -//! -//! READY covers the envelope, TERMINAL, and all SCREEN/PAGE sequences. FINISH -//! covers that same prefix plus the complete READY record and all HISTORY/PAGE -//! sequences. Neither digest includes its own record. FINISH terminates one -//! snapshot; bytes after it belong to the containing transport. -//! -//! The digest does not replace record framing. Its 32-byte payload is still -//! protected by the record's CRC32C. +//! READY separates the renderable terminal state from the history sequences. +//! FINISH terminates one snapshot; bytes after it belong to the containing +//! transport. Both markers have empty payloads, independently framed and +//! protected by the same CRC32C as every other record. //! //! ## Binary Format //! -//! READY and FINISH use the same fixed payload: -//! -//! ```text -//! 0 +----------------------------+ -//! | BLAKE3-256 prefix digest | -//! | 32 bytes | -//! 32 +----------------------------+ -//! ``` +//! READY and FINISH contain only their record headers. Their declared payload +//! length must be zero. const std = @import("std"); const test_fixture = @import("fixture.zig"); const record = @import("record.zig"); -const Blake3 = std.crypto.hash.Blake3; - -/// The prefix digest exchanged by checkpoint codecs and the snapshot driver. -pub const Digest = record.PrefixDigest; - -comptime { - std.debug.assert(@sizeOf(Digest) == 32); -} - -/// Selects one of the two checkpoint positions in a snapshot. +/// Selects one of the two marker positions in a snapshot. pub const Kind = enum { ready, finish, @@ -49,66 +27,40 @@ pub const Kind = enum { } }; -pub const EncodeError = std.Io.Writer.Error || - record.Writer.FinishError; +pub const EncodeError = record.Writer.FinishError; -/// Append one checkpoint covering every byte already emitted by `stream`. -/// -/// Finalizing does not consume the running hasher. READY is therefore included -/// as the stream continues toward FINISH. -pub fn encode( - kind: Kind, - stream: *record.Writer, -) EncodeError!void { - const digest = stream.prefixDigest(); - - const payload = stream.begin(kind.tag()); +/// Append one empty marker record. +pub fn encode(kind: Kind, stream: *record.Writer) EncodeError!void { + _ = stream.begin(kind.tag()); errdefer stream.cancel(); - try payload.writeAll(&digest); try stream.finish(); } pub const DecodeError = record.Reader.InitError || record.Reader.FinishError || error{ - /// The next record is valid but is not the expected checkpoint. + /// The next record is valid but is not the expected marker. UnexpectedRecordTag, - - /// The checkpoint does not describe the preceding snapshot bytes. - InvalidDigest, }; -/// Decode and validate one checkpoint against the running prefix digest. -/// -/// READY is consumed through the hashing reader so FINISH covers it. FINISH is -/// consumed from the underlying source so neither checkpoint includes itself. +/// Decode one marker and require its payload to be empty. pub fn decode( kind: Kind, - stream: *record.StreamReader, + source: *std.Io.Reader, ) DecodeError!void { - const expected = stream.prefixDigest(); - const source = if (kind == .finish) - stream.source() - else - stream.reader(); - var record_reader: record.Reader = undefined; try record_reader.init(source); if (record_reader.header.tag != kind.tag()) { return error.UnexpectedRecordTag; } - - var actual: Digest = undefined; - try record_reader.payloadReader().readSliceAll(&actual); try record_reader.finish(); - if (!std.mem.eql(u8, &expected, &actual)) return error.InvalidDigest; } const test_ready_fixture = test_fixture.parse( @embedFile("testdata/checkpoint-ready-v1.hex"), ); -test "READY golden encoding and BLAKE3-256 registry" { +test "READY golden encoding" { const prefix = "abc"; var snapshot: std.Io.Writer.Allocating = .init(std.testing.allocator); @@ -129,40 +81,13 @@ test "READY golden encoding and BLAKE3-256 registry" { snapshot.written(), ); - // The checked-in payload independently locks the BLAKE3-256 registry. - var actual: Digest = undefined; - Blake3.hash(prefix, &actual, .{}); - const payload_offset = prefix.len + record.Header.len; - try std.testing.expectEqualSlices( - u8, - test_ready_fixture[payload_offset..][0..actual.len], - &actual, - ); - - // Finalizing a streaming hasher neither consumes nor resets it. - var hasher = Blake3.init(.{}); - hasher.update("a"); - var prefix_digest: Digest = undefined; - Blake3.hash("a", &prefix_digest, .{}); - hasher.final(&actual); - try std.testing.expectEqual(prefix_digest, actual); - - hasher.update("bc"); - hasher.final(&actual); - try std.testing.expectEqualSlices( - u8, - test_ready_fixture[payload_offset..][0..actual.len], - &actual, - ); - // Decode the checked-in record rather than the generated candidate. var source: std.Io.Reader = .fixed(&test_ready_fixture); - var source_stream: record.StreamReader = .init(&source); - try source_stream.reader().discardAll(prefix.len); - try decode(.ready, &source_stream); + try source.discardAll(prefix.len); + try decode(.ready, &source); } -test "READY and FINISH checkpoint coverage" { +test "READY and FINISH are empty marker records" { const testing = std.testing; var snapshot: std.Io.Writer.Allocating = .init(testing.allocator); @@ -174,34 +99,29 @@ test "READY and FINISH checkpoint coverage" { defer stream.deinit(); try stream.writer().writeAll("prefix"); - // READY covers only the bytes that precede its own record. const ready_offset = snapshot.written().len; - const ready_digest = stream.prefixDigest(); try encode(.ready, &stream); - - // History follows READY and is included by FINISH. try stream.writer().writeAll("history"); const finish_offset = snapshot.written().len; - const finish_digest = stream.prefixDigest(); try encode(.finish, &stream); - // The running stream reaches both checkpoints without rehashing its prefix. - var source: std.Io.Reader = .fixed(snapshot.written()); - var source_stream: record.StreamReader = .init(&source); - try source_stream.reader().discardAll(ready_offset); - try testing.expectEqual(ready_digest, source_stream.prefixDigest()); - try decode(.ready, &source_stream); - try source_stream.reader().discardAll("history".len); - try testing.expectEqual(finish_digest, source_stream.prefixDigest()); - try decode(.finish, &source_stream); - try testing.expectEqual( - snapshot.written().len - finish_offset, - record.Header.len + @sizeOf(Digest), + record.Header.len, + finish_offset - ready_offset - "history".len, ); + try testing.expectEqual( + record.Header.len, + snapshot.written().len - finish_offset, + ); + + var source: std.Io.Reader = .fixed(snapshot.written()); + try source.discardAll(ready_offset); + try decode(.ready, &source); + try source.discardAll("history".len); + try decode(.finish, &source); } -test "checkpoint rejects wrong tags and digests" { +test "checkpoint rejects wrong tags and nonempty payloads" { const testing = std.testing; var snapshot: std.Io.Writer.Allocating = .init(testing.allocator); @@ -211,20 +131,14 @@ test "checkpoint rejects wrong tags and digests" { &snapshot.writer, ); defer stream.deinit(); - try stream.writer().writeAll("prefix"); - const checkpoint_offset = snapshot.written().len; try encode(.ready, &stream); - var wrong_tag_source: std.Io.Reader = .fixed( - snapshot.written()[checkpoint_offset..], - ); - var wrong_tag: record.StreamReader = .init(&wrong_tag_source); + var wrong_tag_source: std.Io.Reader = .fixed(snapshot.written()); try testing.expectError( error.UnexpectedRecordTag, - decode(.finish, &wrong_tag), + decode(.finish, &wrong_tag_source), ); - // Build a correctly framed checkpoint containing an unrelated digest. var invalid: std.Io.Writer.Allocating = .init(testing.allocator); defer invalid.deinit(); var invalid_stream: record.Writer = .init( @@ -232,22 +146,15 @@ test "checkpoint rejects wrong tags and digests" { &invalid.writer, ); defer invalid_stream.deinit(); - try invalid_stream.writer().writeAll("prefix"); - var invalid_digest = invalid_stream.prefixDigest(); - invalid_digest[0] ^= 1; const invalid_payload = invalid_stream.begin(.ready); errdefer invalid_stream.cancel(); - try invalid_payload.writeAll(&invalid_digest); + try invalid_payload.writeByte(0); try invalid_stream.finish(); - var invalid_digest_source: std.Io.Reader = .fixed(invalid.written()); - var invalid_digest_stream: record.StreamReader = .init( - &invalid_digest_source, - ); - try invalid_digest_stream.reader().discardAll("prefix".len); + var invalid_source: std.Io.Reader = .fixed(invalid.written()); try testing.expectError( - error.InvalidDigest, - decode(.ready, &invalid_digest_stream), + error.PayloadNotExhausted, + decode(.ready, &invalid_source), ); } @@ -261,15 +168,10 @@ test "FINISH leaves continuation bytes unread" { &finished.writer, ); defer finished_stream.deinit(); - try finished_stream.writer().writeAll("prefix"); try encode(.finish, &finished_stream); try finished.writer.writeByte(0); - var trailing_source: std.Io.Reader = .fixed(finished.written()); - var trailing_stream: record.StreamReader = .init( - &trailing_source, - ); - try trailing_stream.reader().discardAll("prefix".len); - try decode(.finish, &trailing_stream); - try testing.expectEqual(@as(u8, 0), try trailing_source.takeByte()); + var source: std.Io.Reader = .fixed(finished.written()); + try decode(.finish, &source); + try testing.expectEqual(@as(u8, 0), try source.takeByte()); } diff --git a/src/terminal/snapshot/continuation.zig b/src/terminal/snapshot/continuation.zig new file mode 100644 index 000000000..92b52da04 --- /dev/null +++ b/src/terminal/snapshot/continuation.zig @@ -0,0 +1,320 @@ +//! Continuation record type. +//! +//! Continuation uses the standard record header. All trailing bytes +//! are payload bytes for the continuation state. +//! +//! A continuation's payload must be the minimal bytes to go from +//! a ground state to a non-ground state in a VT emulator. Any extra +//! bytes are treated as a validation error. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const record = @import("record.zig"); +const stream_continuation = @import("../stream_continuation.zig"); +const test_fixture = @import("fixture.zig"); + +/// A borrowed continuation value supplied to encode, or an allocator-owned +/// value stored in a decoded complete snapshot. +pub const Value = union(enum) { + /// Neither the VT parser nor UTF-8 decoder has unfinished state. + ground, + + /// Canonical replay-safe PTY bytes. Empty is equivalent to `ground`. + bytes: []const u8, +}; + +pub const ValidateError = stream_continuation.ValidateError || error{ + /// The common record's u32 payload length cannot represent these bytes. + Overflow, +}; + +/// Validate a continuation before any snapshot bytes are emitted. +pub fn validate(value: Value) ValidateError!void { + switch (value) { + .ground => {}, + .bytes => |bytes| { + if (std.math.cast(u32, bytes.len) == null) { + return error.Overflow; + } + try stream_continuation.validate(bytes); + }, + } +} + +pub const EncodeError = ValidateError || record.Writer.FinishError; + +/// Encode one complete CONTINUATION record. +pub fn encode( + value: Value, + stream: *record.Writer, +) EncodeError!void { + try validate(value); + + const payload = stream.begin(.continuation); + errdefer stream.cancel(); + switch (value) { + .ground => {}, + .bytes => |bytes| try payload.writeAll(bytes), + } + try stream.finish(); +} + +pub const DecodeError = Allocator.Error || + stream_continuation.ValidateError || + record.Reader.InitError || + record.Reader.FinishError || + std.Io.Reader.Error || + error{ + /// The next record is valid but is not CONTINUATION. + UnexpectedRecordTag, + + /// The declared payload exceeds caller policy. + ContinuationLimitExceeded, + }; + +/// Decode and validate one CONTINUATION record. +/// +/// A zero-length payload returns `ground` without allocating. A nonempty +/// payload returns allocator-owned bytes; although `Value.bytes` is a const +/// slice, the caller must eventually free it with the same allocator. +pub fn decode( + alloc: Allocator, + source: *std.Io.Reader, + max_bytes: usize, +) DecodeError!Value { + var record_reader: record.Reader = undefined; + try record_reader.init(source); + if (record_reader.header.tag != .continuation) { + return error.UnexpectedRecordTag; + } + + // Validate the header payload vs our max size since the header + // enforces it. + const len: usize = record_reader.header.payload_len; + if (len > max_bytes) return error.ContinuationLimitExceeded; + + if (len == 0) { + // Empty is the ground representation, but finish is still required: + // its CRC covers the tag and zero length even though there is no body. + try record_reader.finish(); + return .ground; + } + + // Consume the bytes + const bytes = try alloc.alloc(u8, len); + errdefer alloc.free(bytes); + try record_reader.payloadReader().readSliceAll(bytes); + try record_reader.finish(); + + // Framing is valid, so it is now safe to classify the bytes as replay + // state. Validation requires a minimal, unfinished, side-effect-free tail. + try stream_continuation.validate(bytes); + + // Ownership transfers to the returned Value only after all validation. + return .{ .bytes = bytes }; +} + +const test_ground_fixture = test_fixture.parse(@embedFile("testdata/continuation-ground-v1.hex")); +const test_utf8_fixture = test_fixture.parse(@embedFile("testdata/continuation-utf8-v1.hex")); +const test_esc_fixture = test_fixture.parse(@embedFile("testdata/continuation-esc-v1.hex")); +const test_csi_fixture = test_fixture.parse(@embedFile("testdata/continuation-csi-v1.hex")); +const test_osc_fixture = test_fixture.parse(@embedFile("testdata/continuation-osc-v1.hex")); +const test_dcs_fixture = test_fixture.parse(@embedFile("testdata/continuation-dcs-v1.hex")); +const test_apc_fixture = test_fixture.parse(@embedFile("testdata/continuation-apc-v1.hex")); + +test "continuation golden records" { + const Golden = struct { + path: []const u8, + candidate: []const u8, + value: Value, + expected: []const u8, + }; + const values = [_]Golden{ + .{ + .path = "src/terminal/snapshot/testdata/continuation-ground-v1.hex", + .candidate = "snapshot_fixture-continuation-ground-v1.hex", + .value = .ground, + .expected = &test_ground_fixture, + }, + .{ + .path = "src/terminal/snapshot/testdata/continuation-utf8-v1.hex", + .candidate = "snapshot_fixture-continuation-utf8-v1.hex", + .value = .{ .bytes = "\xF0\x9F\x98" }, + .expected = &test_utf8_fixture, + }, + .{ + .path = "src/terminal/snapshot/testdata/continuation-esc-v1.hex", + .candidate = "snapshot_fixture-continuation-esc-v1.hex", + .value = .{ .bytes = "\x1b" }, + .expected = &test_esc_fixture, + }, + .{ + .path = "src/terminal/snapshot/testdata/continuation-csi-v1.hex", + .candidate = "snapshot_fixture-continuation-csi-v1.hex", + .value = .{ .bytes = "\x1b[31" }, + .expected = &test_csi_fixture, + }, + .{ + .path = "src/terminal/snapshot/testdata/continuation-osc-v1.hex", + .candidate = "snapshot_fixture-continuation-osc-v1.hex", + .value = .{ .bytes = "\x1b]2;title" }, + .expected = &test_osc_fixture, + }, + .{ + .path = "src/terminal/snapshot/testdata/continuation-dcs-v1.hex", + .candidate = "snapshot_fixture-continuation-dcs-v1.hex", + .value = .{ .bytes = "\x1bPqdata" }, + .expected = &test_dcs_fixture, + }, + .{ + .path = "src/terminal/snapshot/testdata/continuation-apc-v1.hex", + .candidate = "snapshot_fixture-continuation-apc-v1.hex", + .value = .{ .bytes = "\x1b_Gdata" }, + .expected = &test_apc_fixture, + }, + }; + + for (values) |golden| { + var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer encoded.deinit(); + var stream: record.Writer = .init( + std.testing.allocator, + &encoded.writer, + ); + defer stream.deinit(); + try encode(golden.value, &stream); + try test_fixture.expectEqual( + .bytes, + golden.path, + golden.candidate, + golden.expected, + encoded.written(), + ); + } +} + +test "continuation validates supported unfinished states" { + const values = [_][]const u8{ + "\x1b", + "\x1b[31", + "\x1b]2;title", + "\x1bPqdata", + "\x1b_Gdata", + "\xF0\x9F\x98", + }; + for (values) |bytes| try validate(.{ .bytes = bytes }); +} + +test "empty continuation bytes encode and decode as ground" { + const testing = std.testing; + try validate(.{ .bytes = "" }); + + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + var stream: record.Writer = .init(testing.allocator, &encoded.writer); + defer stream.deinit(); + try encode(.{ .bytes = "" }, &stream); + try testing.expectEqualStrings(&test_ground_fixture, encoded.written()); + + var source: std.Io.Reader = .fixed(encoded.written()); + const decoded = try decode(testing.allocator, &source, 0); + try testing.expect(decoded == .ground); +} + +test "continuation rejects invalid semantic shapes" { + const testing = std.testing; + try testing.expectError( + error.NoPendingState, + validate(.{ .bytes = "\x1b[31m" }), + ); + try testing.expectError( + error.ReplayWouldCommit, + validate(.{ .bytes = "\x1b[31\x07" }), + ); + try testing.expectError( + error.NonCanonicalContinuation, + validate(.{ .bytes = "prefix\x1b[31" }), + ); + try testing.expectError( + error.NonCanonicalContinuation, + validate(.{ .bytes = "\x1b[31\x1b[4" }), + ); +} + +test "continuation record round trip and cap" { + const testing = std.testing; + const values = [_]Value{ + .ground, + .{ .bytes = "\x1b[31" }, + .{ .bytes = "\xF0\x9F" }, + }; + + for (values) |value| { + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + var stream: record.Writer = .init(testing.allocator, &encoded.writer); + defer stream.deinit(); + try encode(value, &stream); + + var source: std.Io.Reader = .fixed(encoded.written()); + const decoded = try decode(testing.allocator, &source, 1024); + defer switch (decoded) { + .ground => {}, + .bytes => |bytes| testing.allocator.free(bytes), + }; + switch (value) { + .ground => try testing.expect(decoded == .ground), + .bytes => |expected| try testing.expectEqualStrings( + expected, + decoded.bytes, + ), + } + } + + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + var stream: record.Writer = .init(testing.allocator, &encoded.writer); + defer stream.deinit(); + try encode(.{ .bytes = "\x1b[31" }, &stream); + + var capped_source: std.Io.Reader = .fixed(encoded.written()); + var failing = testing.FailingAllocator.init(testing.allocator, .{ + .fail_index = 0, + }); + try testing.expectError( + error.ContinuationLimitExceeded, + decode(failing.allocator(), &capped_source, 3), + ); + try testing.expectEqual( + @as(usize, record.Header.len), + capped_source.seek, + ); +} + +test "continuation record rejects truncation, checksum, and tag" { + const testing = std.testing; + + for (0..test_csi_fixture.len) |len| { + var source: std.Io.Reader = .fixed(test_csi_fixture[0..len]); + try testing.expectError( + error.EndOfStream, + decode(testing.allocator, &source, 1024), + ); + } + + var invalid_checksum = test_csi_fixture; + invalid_checksum[6] ^= 1; + var checksum_source: std.Io.Reader = .fixed(&invalid_checksum); + try testing.expectError( + error.InvalidChecksum, + decode(testing.allocator, &checksum_source, 1024), + ); + + var wrong_tag = test_ground_fixture; + std.mem.writeInt(u16, wrong_tag[0..2], @intFromEnum(record.Tag.ready), .little); + var tag_source: std.Io.Reader = .fixed(&wrong_tag); + try testing.expectError( + error.UnexpectedRecordTag, + decode(testing.allocator, &tag_source, 1024), + ); +} diff --git a/src/terminal/snapshot/grid.zig b/src/terminal/snapshot/grid.zig index 18e7955ba..fe5d05528 100644 --- a/src/terminal/snapshot/grid.zig +++ b/src/terminal/snapshot/grid.zig @@ -1,25 +1,37 @@ -//! Grid (rows and cells) encoding. +//! Grid (rows, cells, and grapheme suffixes) encoding. //! //! A grid contains the rows and cells of a terminal page. Its dimensions are //! supplied by the containing record rather than repeated here. The encoder -//! writes exactly `rows` row records, and every row contains exactly `columns` -//! cell records. +//! writes exactly `rows` row records followed by one grapheme suffix section. //! //! All records are tightly packed with no padding between them. All integers //! are unsigned and little-endian. //! +//! The layout is designed so the common case decodes with bulk copies: cells +//! are fixed-size words, trailing default cells are elided per row, and the +//! variable-length grapheme suffixes live outside the row data. +//! +//! ```text +//! +---------------------------+ +//! | Row 0 | +//! +---------------------------+ +//! | ... | +//! +---------------------------+ +//! | Row (rows - 1) | +//! +---------------------------+ +//! | Grapheme suffix section | +//! +---------------------------+ +//! ``` +//! //! ## Row //! //! Each row has the following format: //! -//! | Offset | Size | Field | -//! | -----: | -------: | :-------------------------- | -//! | 0 | 1 | Row flags | -//! | 1 | variable | Exactly `columns` cells | -//! -//! Cells are encoded consecutively. Since cells may contain a variable number -//! of grapheme codepoints, the next row begins immediately after the final -//! cell and its grapheme codepoints. +//! | Offset | Size | Field | +//! | -----: | ----------------: | :------------------------- | +//! | 0 | 1 | Row flags | +//! | 1 | 2 | Encoded cell count (`u16`) | +//! | 3 | `width` * `count` | Encoded cells | //! //! The row flag byte has the following format: //! @@ -28,7 +40,8 @@ //! | 0 | Wrap | //! | 1 | Wrap continuation | //! | 2-3 | Semantic prompt | -//! | 4-7 | Reserved, zero | +//! | 4-5 | Encoded cell width | +//! | 6-7 | Reserved, zero | //! //! Semantic prompt values are: //! @@ -40,37 +53,128 @@ //! //! Value 3 is not emitted in snapshot version 1. Decoders treat it as none. //! +//! The encoded cell count must not exceed the grid's column count; it is a +//! structural field and decoders reject larger values. Cells at and beyond +//! the count are the default cell: all sixty-four bits zero, meaning an +//! empty narrow codepoint cell with the default style and no hyperlink. +//! Canonical encoders emit exactly through the row's last non-default cell, +//! so a fully default row has a zero count and no cell words. +//! +//! ## Encoded cell width +//! +//! The two width bits select how many bytes encode each of the row's +//! cells: `1 << width` is the size, so zero through three select one, two, +//! four, or eight bytes. Narrower widths are truncated transports of the +//! same cell word: a cell qualifies for a width when all of its higher +//! word bits are zero. +//! +//! | Width | Bytes | Encoded value and admitted cells | +//! | ----: | ----: | :--------------------------------------------------- | +//! | 0 | 1 | Codepoint at or below U+00FF; all other bits zero | +//! | 1 | 2 | Codepoint at or below U+FFFF; all other bits zero | +//! | 2 | 4 | Word bits 0-31: any content kind and codepoint, style | +//! | | | IDs 1-63, narrow, no flags, no hyperlink | +//! | 3 | 8 | The complete word | +//! +//! Widths zero and one store the codepoint value itself, which is word +//! bits 2-25 shifted down; the reconstructed word is the codepoint shifted +//! left by two. Width two stores the word's low half unshifted. This makes +//! every width a zero-extension on decode and a truncation on encode. +//! +//! Canonical encoders choose each row's smallest admissible width, which +//! follows directly from the bitwise OR of the row's cell words. Decoders +//! use the declared width for framing and accept rows encoded wider than +//! necessary. The width of a row with a zero cell count is canonically +//! zero and carries no meaning. +//! //! Native row cache flags are not encoded. In particular, the Kitty virtual //! placeholder hint is derived while decoding cells containing U+10EEEE. //! //! ## Cell //! -//! Each cell has the following format: +//! Each cell is one 64-bit little-endian word, transported at the row's +//! encoded cell width as described above: //! -//! | Offset | Size | Field | -//! | -----: | --------: | :---------------------------- | -//! | 0 | 1 | Content kind | -//! | 1 | 1 | Width kind | -//! | 2 | 1 | Protected and semantic flags | -//! | 3 | 1 | Reserved, zero | -//! | 4 | 2 | Style ID | -//! | 6 | 2 | Hyperlink ID | -//! | 8 | 4 | Codepoint or packed color | -//! | 12 | 4 | Grapheme suffix count | -//! | 16 | 4 * count | Grapheme suffix codepoints | +//! ```text +//! bit 0 +-------------------------------+ +//! | Content kind | +//! | 2 bits | +//! bit 2 +-------------------------------+ +//! | Content | +//! | 24 bits | +//! bit 26 +-------------------------------+ +//! | Style ID | +//! | 16 bits | +//! bit 42 +-------------------------------+ +//! | Width kind | +//! | 2 bits | +//! bit 44 +-------------------------------+ +//! | Protected | +//! bit 45 +-------------------------------+ +//! | Hyperlink flag | +//! bit 46 +-------------------------------+ +//! | Semantic content | +//! | 2 bits | +//! bit 48 +-------------------------------+ +//! | Hyperlink ID | +//! | 16 bits | +//! bit 64 +-------------------------------+ +//! ``` //! //! Content kinds are: //! -//! | Value | Meaning | -//! | ----: | :----------------- | -//! | 0 | Codepoint | -//! | 1 | Palette background | -//! | 2 | RGB background | +//! | Value | Meaning | +//! | ----: | :------------------------------- | +//! | 0 | Codepoint | +//! | 1 | Codepoint with grapheme suffix | +//! | 2 | Palette background | +//! | 3 | RGB background | //! -//! For codepoint content, the value is a Unicode scalar encoded as a `u32`. -//! For a palette background, the low byte is the palette index and the other -//! three bytes are zero. For an RGB background, the low three bytes are red, -//! green, and blue, and the high byte is zero. +//! The content kind selects the layout of the 24-bit content field. All +//! content bit positions below are relative to the start of the content +//! field, so content bit 0 is cell word bit 2. +//! +//! For codepoint content (kinds 0 and 1), the content field is a Unicode +//! scalar value. Values above U+10FFFF and surrogates decode as U+FFFD. +//! Kind 1 additionally declares that the grapheme suffix section contains +//! one entry for this cell; the cell is otherwise identical to kind 0. +//! +//! ```text +//! bit 0 +-------------------------------+ +//! | Unicode scalar value | +//! | 24 bits | +//! bit 24 +-------------------------------+ +//! ``` +//! +//! For a palette background (kind 2), the low byte is the palette index. +//! The remaining content bits are reserved, canonically zero, and ignored +//! by decoders. +//! +//! ```text +//! bit 0 +-------------------------------+ +//! | Palette index | +//! | 8 bits | +//! bit 8 +-------------------------------+ +//! | Reserved, zero | +//! | 16 bits | +//! bit 24 +-------------------------------+ +//! ``` +//! +//! For an RGB background (kind 3), the content field is one byte per +//! channel: +//! +//! ```text +//! bit 0 +-------------------------------+ +//! | Red | +//! | 8 bits | +//! bit 8 +-------------------------------+ +//! | Green | +//! | 8 bits | +//! bit 16 +-------------------------------+ +//! | Blue | +//! | 8 bits | +//! bit 24 +-------------------------------+ +//! ``` //! //! Width kinds are: //! @@ -81,14 +185,6 @@ //! | 2 | Spacer tail | //! | 3 | Spacer head | //! -//! The cell flag byte has the following format: -//! -//! | Bits | Field | -//! | ---: | :---------------- | -//! | 0 | Protected | -//! | 1-2 | Semantic content | -//! | 3-7 | Reserved, zero | -//! //! Semantic content values are: //! //! | Value | Meaning | @@ -101,46 +197,264 @@ //! //! Style and hyperlink ID zero mean no style and no hyperlink. Other IDs //! refer to entries in the containing record's separate style and hyperlink -//! tables. +//! tables. The hyperlink flag is set exactly when the hyperlink ID is +//! nonzero; decoders derive cell linkage from the remapped ID and ignore the +//! flag itself. //! -//! Grapheme suffixes are valid only for codepoint content. Each suffix is a -//! Unicode scalar encoded as a `u32`. A nonzero suffix count requires a -//! nonzero base codepoint. +//! ## Grapheme suffix section +//! +//! The section begins with an entry count followed by that many entries: +//! +//! | Offset | Size | Field | +//! | -----: | ------: | :------------------- | +//! | 0 | 4 | Entry count (`u32`) | +//! | 4 | varies | Entries | +//! +//! Each entry: +//! +//! | Offset | Size | Field | +//! | -----: | ----------: | :-------------------------- | +//! | 0 | 2 | Row (`u16`) | +//! | 2 | 2 | Column (`u16`) | +//! | 4 | 2 | Codepoint count (`u16`) | +//! | 6 | 4 * `count` | Codepoints (`u32` each) | +//! +//! Each codepoint is a Unicode scalar; invalid scalars are individually +//! ignored by decoders. Canonical encoders emit exactly one entry, with at +//! least one codepoint, for every kind 1 cell, in ascending row-then-column +//! order. Decoders consume every declared entry and ignore entries whose +//! target is out of range, is not a codepoint cell, has a zero codepoint, or +//! already received an entry. A kind 1 cell that receives no suffix +//! codepoints decodes as a plain codepoint cell. const std = @import("std"); +const assert = std.debug.assert; +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; const test_fixture = @import("fixture.zig"); const io = @import("io.zig"); -const kitty = @import("../kitty.zig"); const terminal_hyperlink = @import("../hyperlink.zig"); const terminal_page = @import("../page.zig"); const terminal_style = @import("../style.zig"); +/// The Kitty virtual-placement placeholder remains wire-relevant even when +/// runtime Kitty graphics support is compiled out. +const kitty_virtual_placeholder: u21 = 0x10EEEE; + const TerminalCell = terminal_page.Cell; const TerminalHyperlinkId = terminal_hyperlink.Id; const TerminalPage = terminal_page.Page; const TerminalRow = terminal_page.Row; const TerminalStyleId = terminal_style.Id; -/// Maps encoded style table IDs to IDs assigned by the destination page. +/// The header before every row's encoded cells. /// -/// Build this by inserting each decoded style into the page, then recording -/// the encoded ID and the ID returned by the page's style set. Style ID zero is -/// implicit and does not need an entry. -pub const StyleRemap = std.AutoHashMap(TerminalStyleId, TerminalStyleId); +/// The semantic prompt is a raw integer for the same reason as the wire +/// cell fields: decoders must accept its reserved value without +/// instantiating an invalid native enum. The width enum is exhaustive over +/// its two bits, so every header byte bit-casts to a valid value. +pub const Row = packed struct(u8) { + wrap: bool = false, + wrap_continuation: bool = false, + semantic_prompt: u2 = 0, + cell_width: Cell.EncodedWidth = .one, + _padding: u2 = 0, +}; -/// Maps encoded hyperlink table IDs to IDs assigned by the destination page. +/// The wire layout of one encoded cell. This is its own registry: the bit +/// positions and field meanings are part of the snapshot format and are +/// documented above independently of the native cell. /// -/// Build this by inserting each decoded hyperlink into the page, then -/// recording the encoded ID and the ID returned by the page's hyperlink set. -/// Hyperlink ID zero is implicit and does not need an entry. -pub const HyperlinkRemap = std.AutoHashMap(TerminalHyperlinkId, TerminalHyperlinkId); +/// Enum-like fields are raw integers because decoders must accept reserved +/// values (for example semantic content 3) without instantiating an invalid +/// native enum. +pub const Cell = packed struct(u64) { + kind: u2 = @intFromEnum(Kind.codepoint), + content: u24 = 0, + style_id: u16 = 0, + width: u2 = 0, + protected: bool = false, + hyperlink: bool = false, + semantic_content: u2 = 0, + hyperlink_id: u16 = 0, + + /// Determines how `content` is interpreted. + pub const Kind = enum(u2) { + codepoint = 0, + codepoint_grapheme = 1, + bg_color_palette = 2, + bg_color_rgb = 3, + }; + + /// The encoded cell width declared by a row header: how many bytes + /// transport each of the row's cell words. See the format + /// documentation above for the value each width transports and the + /// cells it admits. + /// + /// `truncate` and `extend` are the transport transform itself, so the + /// admission masks derive from them rather than being maintained by + /// hand: a cell word is admitted exactly when it round-trips. + pub const EncodedWidth = enum(u2) { + one = 0, + two = 1, + four = 2, + eight = 3, + + /// The number of bytes transporting one cell word. + pub fn size(self: EncodedWidth) usize { + return @as(usize, 1) << @intFromEnum(self); + } + + /// The integer type transporting one cell word. + pub fn Int(comptime self: EncodedWidth) type { + return switch (self) { + .one => u8, + .two => u16, + .four => u32, + .eight => u64, + }; + } + + /// Truncate one cell word to its transported value. Only words + /// admitted by this width round-trip; `select` proves that for + /// every cell in a row before an encoder may use it. + pub fn truncate(comptime self: EncodedWidth, word: u64) self.Int() { + return @truncate(switch (self) { + // The bare codepoint, shifted down from the content field. + .one, .two => @as(Cell, @bitCast(word)).content, + + // The word itself. + .four, .eight => word, + }); + } + + /// Widen one transported value back to its cell word. + pub fn extend(comptime self: EncodedWidth, value: self.Int()) u64 { + return switch (self) { + .one, .two => @bitCast(Cell{ .content = value }), + .four, .eight => value, + }; + } + + /// The word bits a cell may use and still round-trip through this + /// width. + pub fn mask(comptime self: EncodedWidth) u64 { + return comptime self.extend(std.math.maxInt(self.Int())); + } + + /// The smallest width admitting the word, typically the bitwise + /// OR of every cell word in a row. + pub fn select(word: u64) EncodedWidth { + inline for ([_]EncodedWidth{ .one, .two, .four }) |width| { + if (word & ~width.mask() == 0) return width; + } + return .eight; + } + }; +}; + +/// Whether the native cell's in-memory layout matches the wire cell layout +/// bit for bit, with the wire hyperlink ID occupying the native padding. +/// +/// The wire format does not require this: it is an optimization. When it +/// holds, whole rows encode and decode as bulk copies. If the native layout +/// ever diverges, the portable field-by-field codec below remains correct +/// and this constant simply becomes false. +const native_matches_wire = native: { + if (@bitSizeOf(TerminalCell) != 64) break :native false; + + // The bulk copies above are only sound if we can prove the two layouts + // agree, and we want native cell changes to demote us to the portable + // codec automatically rather than corrupt snapshots. + // + // Instead of pinning every native field offset and enum value w/ assertions + // that must be maintained by hand, each probe below pairs a native + // cell with the wire cell that must share its exact bit pattern; both + // sides bit cast to a word and any difference means some field moved + // or some enum member was renumbered. + // + // Three probes, one per content payload, cover the whole cell. + const probes = [_]struct { + native: TerminalCell, + wire: Cell, + }{ + .{ + .native = .{ + .content_tag = .codepoint_grapheme, + .content = .{ .codepoint = .{ .data = 0x10FFFF } }, + .style_id = 0xBEEF, + .wide = .spacer_tail, + .protected = true, + ._padding = 0x1D2C, + }, + .wire = .{ + .kind = 1, + .content = 0x10FFFF, + .style_id = 0xBEEF, + .width = 2, + .protected = true, + // The native padding position, canonical wire or not. + .hyperlink_id = 0x1D2C, + }, + }, + .{ + .native = .{ + .content_tag = .bg_color_palette, + .content = .{ .color_palette = .{ .data = 0xAB } }, + .wide = .spacer_head, + .hyperlink = true, + .semantic_content = .input, + }, + .wire = .{ + .kind = 2, + .content = 0xAB, + .width = 3, + .hyperlink = true, + .semantic_content = 1, + }, + }, + .{ + .native = .{ + .content_tag = .bg_color_rgb, + .content = .{ .color_rgb = .{ + .r = 0x12, + .g = 0x34, + .b = 0x56, + } }, + .wide = .wide, + .semantic_content = .prompt, + }, + .wire = .{ + .kind = 3, + .content = 0x563412, + .width = 1, + .semantic_content = 2, + }, + }, + }; + for (probes) |probe| { + const native_bits: u64 = @bitCast(probe.native); + const wire_bits: u64 = @bitCast(probe.wire); + if (native_bits != wire_bits) break :native false; + } + + break :native true; +}; + +/// Whether rows of cells can be copied between native and wire storage +/// without per-cell transformation. +const bulk_codec = native_matches_wire and + builtin.cpu.arch.endian() == .little; pub const EncodeError = std.Io.Writer.Error || error{ /// Wide and spacer cells do not form a valid row. InvalidWideCell, + + /// One cell's grapheme suffix exceeds the entry's u16 codepoint count. + TooManyGraphemes, }; -/// Encode every row and cell directly from a page. +/// Encode every row, cell, and grapheme suffix directly from a page. /// /// If an error is returned, partial data may have been written. If you /// want transactional writing, the caller is responsible for using something @@ -151,20 +465,27 @@ pub fn encode( ) EncodeError!void { defer page.assertIntegrity(); for (0..page.size.rows) |y| { - // Row header const row = page.getRow(y); - const row_header: RowHeader = .{ - .wrap = row.wrap, - .wrap_continuation = row.wrap_continuation, - .semantic_prompt = row.semantic_prompt, - }; - try writer.writeByte(@bitCast(row_header)); - - // Cells const cells = page.getCells(row); - for (cells, 0..) |*cell, x| { - // Validate the wide state of this cell, we don't want - // to encode corrupt data. + + // Trailing default cells decode implicitly. Wide/spacer pairs and + // hyperlinked or styled cells are always nonzero, so eliding the + // zero suffix never drops encoded state. + const count: usize = count: { + var i: usize = cells.len; + while (i > 0) : (i -= 1) { + if (!cells[i - 1].isZero()) break :count i; + } + break :count 0; + }; + + // Validate the wide state of every encoded cell so we don't encode + // corrupt data, and accumulate the OR of the row's cell words to + // select its encoded cell width. Trailing default cells are narrow, + // so checking the encoded prefix against the full row width covers + // every pair. + var word_or: u64 = 0; + for (cells[0..count], 0..) |*cell, x| { switch (cell.wide) { .narrow => {}, .wide => if (x + 1 == cells.len or @@ -181,418 +502,826 @@ pub fn encode( return error.InvalidWideCell; }, } + word_or |= classifyWord(cell); + } - const graphemes: []const u21 = if (cell.hasGrapheme()) - page.lookupGrapheme(cell) orelse unreachable - else - &.{}; + // Canonical rows use the smallest admissible width. + const cell_width: Cell.EncodedWidth = .select(word_or); - // The page has two codepoint tags depending on whether suffixes - // exist, but the wire represents both with one content kind. - const kind: CellHeader.Kind = switch (cell.content_tag) { - .codepoint, .codepoint_grapheme => .codepoint, - .bg_color_palette => .bg_color_palette, - .bg_color_rgb => .bg_color_rgb, - }; - const value: CellHeader.Value = switch (kind) { - .codepoint => .{ - .codepoint = cell.content.codepoint.data, - }, - .bg_color_palette => .{ .bg_color_palette = .{ - .index = cell.content.color_palette.data, - } }, - .bg_color_rgb => .{ .bg_color_rgb = .{ - .r = cell.content.color_rgb.r, - .g = cell.content.color_rgb.g, - .b = cell.content.color_rgb.b, - } }, + // Row header: flags then the encoded cell count. + { + const row_header: Row = .{ + .wrap = row.wrap, + .wrap_continuation = row.wrap_continuation, + .semantic_prompt = @intFromEnum(row.semantic_prompt), + .cell_width = cell_width, }; + var header_bytes: [3]u8 = undefined; + header_bytes[0] = @bitCast(row_header); + std.mem.writeInt(u16, header_bytes[1..3], @intCast(count), .little); + try writer.writeAll(&header_bytes); + } - const style_id = cell.style_id; - const hyperlink_id: TerminalHyperlinkId = if (cell.hyperlink) - page.lookupHyperlink(cell) orelse unreachable - else - 0; - - const header: CellHeader = .{ - .content_kind = kind, - .width = cell.wide, - .protected = cell.protected, - .semantic_content = cell.semantic_content, - .style_id = style_id, - .hyperlink_id = hyperlink_id, - .value = value, - .grapheme_count = @intCast(graphemes.len), - }; - try header.encode(writer); - for (graphemes) |suffix| try io.writeInt( + switch (cell_width) { + inline .one, .two, .four => |width| try encodeNarrowCells( + width, + cells[0..count], writer, - u32, - suffix, - ); + ), + + .eight => { + // Hyperlink IDs live in a native side table, but we embed + // them in ours, so if we have any hyperlinks we need + // to fallback to the loop below. + if (comptime bulk_codec) { + const witness: Cell = @bitCast(word_or); + if (!witness.hyperlink and witness.hyperlink_id == 0) { + try writer.writeAll(std.mem.sliceAsBytes(cells[0..count])); + continue; + } + } + + for (cells[0..count]) |*cell| { + const link_id: TerminalHyperlinkId = if (cell.hyperlink) + page.lookupHyperlink(cell) orelse unreachable + else + 0; + try io.writeInt( + writer, + u64, + cellBits(cell.*, link_id), + ); + } + }, + } + } + + try encodeGraphemes(page, writer); +} + +/// The word used to select a row's encoded cell width. This is the cell's +/// wire word with the hyperlink flag reflecting the native cell, so linked +/// cells and nonzero native padding disqualify every narrow width. +inline fn classifyWord(cell: *const TerminalCell) u64 { + if (comptime native_matches_wire) return @bitCast(cell.*); + + var wire: Cell = @bitCast(cellBits(cell.*, 0)); + wire.hyperlink = cell.hyperlink; + return @bitCast(wire); +} + +/// Write one row's cells truncated to the given encoded width. +/// +/// Every admitted cell round-trips exactly because the row's width +/// selection proved every cell word survives `truncate` then `extend`. +fn encodeNarrowCells( + comptime width: Cell.EncodedWidth, + cells: []const TerminalCell, + writer: *std.Io.Writer, +) std.Io.Writer.Error!void { + const size = comptime width.size(); + var chunk: [1024]u8 = undefined; + var i: usize = 0; + while (i < cells.len) { + const n = @min(cells.len - i, chunk.len / size); + + if (comptime native_matches_wire) { + // A pure truncating loop over integers that the compiler can + // vectorize. + const words: [*]const u64 = @ptrCast(cells.ptr); + for (0..n) |j| { + std.mem.writeInt( + width.Int(), + chunk[j * size ..][0..size], + width.truncate(words[i + j]), + .little, + ); + } + } else { + for (cells[i..][0..n], 0..) |*cell, j| { + std.mem.writeInt( + width.Int(), + chunk[j * size ..][0..size], + width.truncate(classifyWord(cell)), + .little, + ); + } + } + + try writer.writeAll(chunk[0 .. n * size]); + i += n; + } +} + +/// Encode the grapheme suffix section for every kind 1 cell in the grid. +fn encodeGraphemes( + page: *const TerminalPage, + writer: *std.Io.Writer, +) EncodeError!void { + // Count and validate entries before the section header so the count is + // always exact. Rows without the native grapheme hint contain no + // grapheme cells in any intact page. + var entries: u32 = 0; + for (0..page.size.rows) |y| { + const row = page.getRow(y); + if (!row.grapheme) continue; + for (page.getCells(row)) |*cell| { + if (!cell.hasGrapheme()) continue; + const cps = page.lookupGrapheme(cell) orelse unreachable; + if (cps.len > std.math.maxInt(u16)) return error.TooManyGraphemes; + entries += 1; + } + } + + try io.writeInt(writer, u32, entries); + if (entries == 0) return; + + for (0..page.size.rows) |y| { + const row = page.getRow(y); + if (!row.grapheme) continue; + for (page.getCells(row), 0..) |*cell, x| { + if (!cell.hasGrapheme()) continue; + const cps = page.lookupGrapheme(cell) orelse unreachable; + try io.writeInt(writer, u16, @intCast(y)); + try io.writeInt(writer, u16, @intCast(x)); + try io.writeInt(writer, u16, @intCast(cps.len)); + for (cps) |cp| try io.writeInt(writer, u32, cp); } } } -/// Decode every row and cell directly into an initialized, empty page. +pub const DecodeError = std.Io.Reader.Error || error{ + /// A row declares more encoded cells than the grid has columns. + InvalidRowCellCount, +}; + +/// Maps encoded style table IDs to page-assigned style IDs. +pub const StyleRemap = Remap(TerminalStyleId); + +/// Maps encoded hyperlink table IDs to page-assigned hyperlink IDs. +pub const HyperlinkRemap = Remap(TerminalHyperlinkId); + +/// Decode every row, cell, and grapheme suffix directly into an +/// initialized, empty page. /// -/// The grid does not encode dimensions, so `page` must already have the exact -/// row and column count expected by the containing record. This function reads -/// exactly `page.size.rows` rows with `page.size.cols` cells each. Capacity -/// hints are advisory. Graphemes and cell hyperlink references that do not fit -/// are discarded without affecting the rest of the grid. +/// The grid does not encode dimensions, so `page` must already have the +/// exact row and column count expected by the containing record. Capacity +/// hints are advisory. Graphemes and cell hyperlink references that do not +/// fit are discarded without affecting the rest of the grid. /// /// Style and hyperlink table entries must be inserted into `page` before -/// calling this function. As each table entry is inserted, the caller records -/// its encoded ID and page-assigned ID in `style_remap` or `hyperlink_remap`. -/// ID zero always means the default style or no hyperlink. A nonzero ID missing -/// from its remap is also treated as zero so unknown table references do not -/// prevent the rest of the grid from decoding. +/// calling this function, with their encoded and page-assigned IDs recorded +/// in `style_remap` and `hyperlink_remap`. ID zero always means the default +/// style or no hyperlink. A nonzero ID missing from its remap is also +/// treated as zero so unknown table references do not prevent the rest of +/// the grid from decoding. /// -/// Invalid semantic data is normalized into a degraded form while preserving -/// the declared byte boundaries. Unknown semantic values use their neutral -/// variants, invalid Unicode becomes U+FFFD, invalid optional data is ignored, -/// and malformed wide-cell relationships become narrow cells. +/// Invalid semantic data is normalized into a degraded form while +/// preserving the declared byte boundaries. Unknown semantic values use +/// their neutral variants, invalid Unicode becomes U+FFFD, invalid optional +/// data is ignored, and malformed wide-cell relationships become narrow +/// cells. The encoded cell count is the only structural field. pub fn decode( page: *TerminalPage, reader: *std.Io.Reader, style_remap: *const StyleRemap, hyperlink_remap: *const HyperlinkRemap, -) std.Io.Reader.Error!void { +) DecodeError!void { for (0..page.size.rows) |y| { - const row_raw = try reader.takeByte(); - const row_header: RowHeader = @bitCast(row_raw); - const semantic_prompt_raw: u2 = @truncate(row_raw >> @bitOffsetOf(RowHeader, "semantic_prompt")); + // Read the row header and cell count. + const row_header: Row, const count: u16 = header: { + // The staged payload path has every header buffered. + var row_header_bytes: [3]u8 = undefined; + if (reader.bufferedLen() >= 3) { + row_header_bytes = reader.buffered()[0..3].*; + reader.toss(3); + } else { + try reader.readSliceAll(&row_header_bytes); + } + + // Every bit pattern is a valid header: booleans and the exhaustive + // width enum decode directly, and the raw semantic value gets a + // default below. Reserved bits do not change the known fields. + const row_header: Row = @bitCast(row_header_bytes[0]); + const count = std.mem.readInt(u16, row_header_bytes[1..3], .little); + break :header .{ row_header, count }; + }; - // Reserved bits do not change the known fields. The semantic enum is - // the only non-exhaustive field, so give its unknown value a default. const row = page.getRow(y); row.wrap = row_header.wrap; row.wrap_continuation = row_header.wrap_continuation; row.semantic_prompt = std.enums.fromInt( TerminalRow.SemanticPrompt, - semantic_prompt_raw, + row_header.semantic_prompt, ) orelse .none; const cells = page.getCells(row); - for (cells, 0..) |*cell, x| { - const decoded_header = try CellHeader.decode(reader); + if (count > cells.len) return error.InvalidRowCellCount; + if (count == 0) continue; - cell.* = .init(0); - var accept_graphemes = false; - const grapheme_count: u32 = switch (decoded_header) { - // The fixed header was fully consumed, but its content kind - // cannot be interpreted. Keep the cell empty and consume only - // the suffix bytes needed to reach the next cell. - .invalid => |count| count, - - .valid => |header| valid: { - // IDs belong to the encoded page. Translate them to IDs - // assigned by the destination page before storing them on - // cells. - const encoded_style_id = header.style_id; - const style_id = if (encoded_style_id == 0) - 0 - else - style_remap.get(encoded_style_id) orelse 0; - - const encoded_hyperlink_id = header.hyperlink_id; - const hyperlink_id = if (encoded_hyperlink_id == 0) - 0 - else - hyperlink_remap.get(encoded_hyperlink_id) orelse 0; - - switch (header.content_kind) { - .codepoint => { - var cp = std.math.cast( - u21, - header.value.codepoint, - ) orelse 0xFFFD; - if (cp > 0x10FFFF or - (cp >= 0xD800 and cp <= 0xDFFF)) - { - cp = 0xFFFD; - } - cell.content = .{ .codepoint = .{ .data = cp } }; - accept_graphemes = cp != 0; - - // Kitty image and placement state is not part of this - // snapshot version, but the placeholder is still a - // valid Unicode scalar. Preserve it and derive the - // native row hint so later row operations remain - // correct. - if (cp == kitty.graphics.unicode.placeholder) { - row.kitty_virtual_placeholder = true; - } - }, - .bg_color_palette => { - const palette = header.value.bg_color_palette; - cell.content_tag = .bg_color_palette; - cell.content = .{ - .color_palette = .{ .data = palette.index }, - }; - }, - .bg_color_rgb => { - const rgb = header.value.bg_color_rgb; - cell.content_tag = .bg_color_rgb; - cell.content = .{ .color_rgb = .{ - .r = rgb.r, - .g = rgb.g, - .b = rgb.b, - } }; - }, + // The encoded cell width is framing: it determines exactly how many + // bytes this row occupies. + switch (row_header.cell_width) { + inline .one, .two => |width| try decodeNarrowCells( + width, + reader, + cells[0..count], + ), + .four => try decodeWordCells( + .four, + page, + row, + cells, + count, + reader, + style_remap, + hyperlink_remap, + ), + .eight => { + if (comptime bulk_codec) { + // Read the encoded words directly into page storage, + // then normalize them in place. The raw words are only + // ever touched as integers until normalization makes + // them valid cells. + const words: [*]u64 = @ptrCast(cells.ptr); + try reader.readSliceAll( + std.mem.sliceAsBytes(cells[0..count]), + ); + for (0..count) |x| { + applyCell( + page, + row, + cells, + x, + words[x], + style_remap, + hyperlink_remap, + ); } - - cell.wide = header.width; - cell.protected = header.protected; - cell.semantic_content = header.semantic_content; - - if (style_id != 0) { - // The table owns one reference and each decoded cell owns - // one additional reference. - page.styles.use(page.memory, style_id); - cell.style_id = style_id; - row.styled = true; - } - - if (hyperlink_id != 0) { - // setHyperlink records the cell mapping but intentionally - // does not increment the set's reference count. If its map - // is full, undo our reference and leave this cell unlinked. - page.hyperlink_set.use(page.memory, hyperlink_id); - page.setHyperlink(row, cell, hyperlink_id) catch { - page.hyperlink_set.release(page.memory, hyperlink_id); - }; - } - - break :valid header.grapheme_count; - }, - }; - - // Always consume every declared suffix. Invalid scalars, suffixes - // on non-text cells, and suffixes that exceed the native capacity - // are optional detail and can be dropped independently. - for (0..grapheme_count) |_| { - const suffix_raw = try io.readInt(reader, u32); - if (!accept_graphemes) continue; - - const suffix = std.math.cast(u21, suffix_raw) orelse continue; - if (suffix > 0x10FFFF or - (suffix >= 0xD800 and suffix <= 0xDFFF)) - { - continue; + } else { + try decodeWordCells( + .eight, + page, + row, + cells, + count, + reader, + style_remap, + hyperlink_remap, + ); } - page.appendGrapheme(row, cell, suffix) catch { - accept_graphemes = false; - }; + + // The implicit cell after a short row is narrow, which + // resolves a trailing wide marker exactly like an explicit + // narrow neighbor. Only full-width rows can encode wide + // markers. + if (count < cells.len and cells[count - 1].wide == .wide) { + cells[count - 1].wide = .narrow; + } + }, + } + } + + try decodeGraphemes(page, reader); +} + +/// Decode one row of width-one or width-two cells. +/// +/// These widths admit only bare codepoints, so cells store directly with at +/// most Unicode scalar validation: no styles, hyperlinks, wide pairs, or +/// row hints are reachable and no normalization state is needed. +fn decodeNarrowCells( + comptime width: Cell.EncodedWidth, + reader: *std.Io.Reader, + cells: []TerminalCell, +) DecodeError!void { + const size = comptime width.size(); + + // The staged payload path has the complete row buffered, making this + // one bounds check followed by a vectorizable widening loop. + const total = cells.len * size; + if (reader.bufferedLen() >= total) { + widenCells(width, reader.buffered()[0..total], cells); + reader.toss(total); + return; + } + + // Streaming sources fall back to bounded chunks. + var chunk: [1024]u8 = undefined; + var i: usize = 0; + while (i < cells.len) { + const n = @min(cells.len - i, chunk.len / size); + try reader.readSliceAll(chunk[0 .. n * size]); + widenCells(width, chunk[0 .. n * size], cells[i..][0..n]); + i += n; + } +} + +/// Store codepoint-valued encoded cells of the given width. +fn widenCells( + comptime width: Cell.EncodedWidth, + bytes: []const u8, + cells: []TerminalCell, +) void { + const size = comptime width.size(); + + // When the native cell matches the wire word, this is a pure widening + // loop over integers that the compiler can vectorize. + if (comptime native_matches_wire) { + const words: [*]u64 = @ptrCast(cells.ptr); + for (0..cells.len) |i| { + words[i] = width.extend(widenValue(width, bytes[i * size ..])); + } + return; + } + + for (cells, 0..) |*cell, i| { + storeCell(cell, width.extend(widenValue(width, bytes[i * size ..]))); + } +} + +/// Read and validate one narrow transported value. +inline fn widenValue( + comptime width: Cell.EncodedWidth, + bytes: []const u8, +) width.Int() { + const size = comptime width.size(); + const value = std.mem.readInt(width.Int(), bytes[0..size], .little); + + // Width one cannot encode an invalid scalar. Width two admits + // surrogates, which degrade exactly like their full-width form. + if (comptime width != .one) { + if (!validScalar(value)) return 0xFFFD; + } + return value; +} + +/// Decode one row of width-four or fallback full-width cells through the +/// complete per-cell normalization path. +fn decodeWordCells( + comptime width: Cell.EncodedWidth, + page: *TerminalPage, + row: *TerminalRow, + cells: []TerminalCell, + count: usize, + reader: *std.Io.Reader, + style_remap: *const StyleRemap, + hyperlink_remap: *const HyperlinkRemap, +) DecodeError!void { + const size = comptime width.size(); + + // The staged payload path has the complete row buffered. + const total = count * size; + if (reader.bufferedLen() >= total) { + const bytes = reader.buffered()[0..total]; + for (0..count) |x| { + const bits = width.extend(std.mem.readInt( + width.Int(), + bytes[x * size ..][0..size], + .little, + )); + applyCell( + page, + row, + cells, + x, + bits, + style_remap, + hyperlink_remap, + ); + } + reader.toss(total); + return; + } + + // Streaming sources fall back to per-cell reads. + for (0..count) |x| { + const bits = width.extend(try io.readInt(reader, width.Int())); + applyCell( + page, + row, + cells, + x, + bits, + style_remap, + hyperlink_remap, + ); + } +} + +/// Whether the value is a valid Unicode scalar value. +inline fn validScalar(cp: u32) bool { + return cp <= 0x10FFFF and (cp < 0xD800 or cp > 0xDFFF); +} + +/// Normalize one encoded cell word and store it at `cells[x]`. +/// +/// This owns every per-cell decode rule except grapheme suffixes: content +/// validation, reserved-value degradation, style and hyperlink remapping +/// with reference counting, and wide-pair normalization against already +/// decoded neighbors. +fn applyCell( + page: *TerminalPage, + row: *TerminalRow, + cells: []TerminalCell, + x: usize, + bits_wire: u64, + style_remap: *const StyleRemap, + hyperlink_remap: *const HyperlinkRemap, +) void { + const cell = &cells[x]; + + // The default cell is the common case and needs no normalization, + // reference counting, or table lookups. + if (bits_wire == 0) { + storeCell(cell, 0); + normalizeWide(row, cells, x); + return; + } + + // Any word is a valid wire cell because its fields are raw integers, + // so all normalization below is plain field access. + var wire: Cell = @bitCast(bits_wire); + + // Hyperlink linkage is derived from the remapped ID below. The stored + // native cell starts unlinked either way. + const link_encoded = wire.hyperlink_id; + wire.hyperlink_id = 0; + wire.hyperlink = false; + + switch (@as(Cell.Kind, @enumFromInt(wire.kind))) { + // Kind 1 differs from 0 only by declaring a grapheme suffix section + // entry, which reattaches through the native grapheme APIs later. + .codepoint, .codepoint_grapheme => { + wire.kind = @intFromEnum(Cell.Kind.codepoint); + if (!validScalar(wire.content)) wire.content = 0xFFFD; + + // Kitty image and placement state is not part of this snapshot + // version, but the placeholder is still a valid Unicode scalar. + // Preserve it and derive the native row hint so later row + // operations remain correct. + if (wire.content == kitty_virtual_placeholder) { + row.kitty_virtual_placeholder = true; } + }, - // A following cell resolves whether the previous wide marker owns - // a tail. Normalize the current marker immediately when possible, - // including a wide marker at the row end. - if (x > 0 and - cells[x - 1].wide == .wide and - cell.wide != .spacer_tail) - { - cells[x - 1].wide = .narrow; + // Only the palette index is meaningful; the remaining content bits + // are reserved and must not obscure it. + .bg_color_palette => wire.content = @as(u8, @truncate(wire.content)), + + .bg_color_rgb => {}, + } + + // Reserved semantic content degrades to plain output. + wire.semantic_content = @intFromEnum(std.enums.fromInt( + TerminalCell.SemanticContent, + wire.semantic_content, + ) orelse .output); + + // IDs belong to the encoded page. Translate them to IDs assigned by the + // destination page before storing them on cells. The table owns one + // reference and each decoded cell owns one additional reference. + if (wire.style_id != 0) { + wire.style_id = style_remap.get(wire.style_id); + if (wire.style_id != 0) { + page.styles.use(page.memory, wire.style_id); + row.styled = true; + } + } + + storeCell(cell, @bitCast(wire)); + + if (link_encoded != 0) link: { + const link_native = hyperlink_remap.get(link_encoded); + if (link_native == 0) break :link; + + // setHyperlink records the cell mapping but intentionally does not + // increment the set's reference count. If its map is full, undo our + // reference and leave this cell unlinked. + page.hyperlink_set.use(page.memory, link_native); + page.setHyperlink(row, cell, link_native) catch { + page.hyperlink_set.release(page.memory, link_native); + }; + } + + normalizeWide(row, cells, x); +} + +/// Resolve wide-pair relationships for the cell at `x` against its already +/// normalized predecessors. +fn normalizeWide(row: *const TerminalRow, cells: []TerminalCell, x: usize) void { + // A following cell resolves whether the previous wide marker owns a + // tail. Normalize the current marker immediately when possible, + // including a wide marker at the row end. + if (x > 0 and + cells[x - 1].wide == .wide and + cells[x].wide != .spacer_tail) + { + cells[x - 1].wide = .narrow; + } + switch (cells[x].wide) { + .narrow => {}, + + // A non-final wide marker remains pending until the next cell. + .wide => if (x + 1 == cells.len) { + cells[x].wide = .narrow; + }, + + .spacer_tail => if (x == 0 or + cells[x - 1].wide != .wide) + { + cells[x].wide = .narrow; + }, + + .spacer_head => if (x + 1 != cells.len or !row.wrap) { + cells[x].wide = .narrow; + }, + } +} + +/// Decode the grapheme suffix section into already decoded cells. +fn decodeGraphemes( + page: *TerminalPage, + reader: *std.Io.Reader, +) DecodeError!void { + const entries = try io.readInt(reader, u32); + for (0..entries) |_| { + const y = try io.readInt(reader, u16); + const x = try io.readInt(reader, u16); + const cp_count = try io.readInt(reader, u16); + + // Resolve the target cell. Entries whose target cannot carry a + // suffix are optional detail: their codepoints are consumed to + // preserve framing and then dropped. + const target: ?struct { + row: *TerminalRow, + cell: *TerminalCell, + } = target: { + if (y >= page.size.rows or x >= page.size.cols) { + break :target null; } - switch (cell.wide) { - .narrow => {}, + const row = page.getRow(y); + const cell = &page.getCells(row)[x]; - // A non-final wide marker remains pending until the next cell. - .wide => if (x + 1 == cells.len) { - cell.wide = .narrow; - }, + // Cell decoding stores every kind 1 cell as a plain codepoint, + // so this also gives duplicate entries first-wins semantics. + if (cell.content_tag != .codepoint) break :target null; + if (cell.content.codepoint.data == 0) break :target null; + break :target .{ .row = row, .cell = cell }; + }; - .spacer_tail => if (x == 0 or - cells[x - 1].wide != .wide) - { - cell.wide = .narrow; - }, + // Always consume every declared codepoint. Invalid scalars and NUL are + // not meaningful grapheme suffix components and are ignored. If native + // capacity is exhausted, remove any prefix already attached so the + // cell never exposes a truncated cluster. + var accept = target != null; + for (0..cp_count) |_| { + const cp = try io.readInt(reader, u32); + if (!accept) continue; + if (cp == 0 or !validScalar(cp)) continue; - .spacer_head => if (x + 1 != cells.len or !row.wrap) { - cell.wide = .narrow; - }, - } + page.appendGrapheme( + target.?.row, + target.?.cell, + @intCast(cp), + ) catch { + if (target.?.cell.hasGrapheme()) { + page.clearGrapheme(target.?.cell); + page.updateRowGraphemeFlag(target.?.row); + } + accept = false; + }; } } } -/// The header before every row. -const RowHeader = packed struct(u8) { - wrap: bool = false, - wrap_continuation: bool = false, - semantic_prompt: TerminalRow.SemanticPrompt = .none, - _padding: u4 = 0, -}; - -/// The fixed fields that precede a cell's grapheme suffix codepoints. -const CellHeader = struct { - /// Number of bytes written by `encode`, calculated using the encoder itself - /// so this remains synchronized with the field-by-field wire format. - pub const len = computeLen(); - - comptime { - // This size is part of the wire format. If it changes, the snapshot - // version and golden fixtures must also change. - std.debug.assert(len == 16); +/// The encoded word for one native cell and its hyperlink ID. +fn cellBits(cell: TerminalCell, link_id: TerminalHyperlinkId) u64 { + if (comptime native_matches_wire) { + var wire: Cell = @bitCast(cell); + wire.hyperlink = link_id != 0; + wire.hyperlink_id = link_id; + return @bitCast(wire); } - /// Interpretation of `value`. - content_kind: Kind = .codepoint, - - /// Display width and spacer role of the cell. - width: TerminalCell.Wide = .narrow, - - /// Whether selective erase operations protect the cell. - protected: bool = false, - - /// Semantic role assigned by shell integration. - semantic_content: TerminalCell.SemanticContent = .output, - - /// ID in the encoded page's style table, or zero for the default style. - style_id: TerminalStyleId = 0, - - /// ID in the encoded page's hyperlink table, or zero for no hyperlink. - hyperlink_id: TerminalHyperlinkId = 0, - - /// Codepoint or packed background color selected by `content_kind`. - value: Value = .{ .codepoint = 0 }, - - /// Number of grapheme suffix codepoints immediately following the header. - grapheme_count: u32 = 0, - - /// Encode the fixed cell header. - pub fn encode( - self: CellHeader, - writer: *std.Io.Writer, - ) std.Io.Writer.Error!void { - const flags: Flags = .{ - .protected = self.protected, - .semantic_content = self.semantic_content, - }; - - // 0 1 2 3 4 6 8 12 16 - // +-------+-------+-------+-------+--------+--------+--------+--------+ - // | kind | width | flags | zero | style | link | value | count | - // | u8 | u8 | u8 | u8 | u16 LE | u16 LE | u32 LE | u32 LE | - // +-------+-------+-------+-------+--------+--------+--------+--------+ - try writer.writeByte(@intFromEnum(self.content_kind)); - try writer.writeByte(@intFromEnum(self.width)); - try writer.writeByte(@bitCast(flags)); - try writer.writeByte(0); - try io.writeInt(writer, TerminalStyleId, self.style_id); - try io.writeInt(writer, TerminalHyperlinkId, self.hyperlink_id); - try io.writeInt(writer, u32, @bitCast(self.value)); - try io.writeInt(writer, u32, self.grapheme_count); - } - - /// A valid header, or the suffix count from an uninterpretable header. - const DecodeResult = union(enum) { - valid: CellHeader, - invalid: u32, + const wire: Cell = .{ + .kind = @intFromEnum(cell.content_tag), + .content = switch (cell.content_tag) { + .codepoint, + .codepoint_grapheme, + => cell.content.codepoint.data, + .bg_color_palette => cell.content.color_palette.data, + .bg_color_rgb => @as(u24, cell.content.color_rgb.r) | + (@as(u24, cell.content.color_rgb.g) << 8) | + (@as(u24, cell.content.color_rgb.b) << 16), + }, + .style_id = cell.style_id, + .width = switch (cell.wide) { + .narrow => 0, + .wide => 1, + .spacer_tail => 2, + .spacer_head => 3, + }, + .protected = cell.protected, + .hyperlink = link_id != 0, + .semantic_content = switch (cell.semantic_content) { + .output => 0, + .input => 1, + .prompt => 2, + }, + .hyperlink_id = link_id, }; + return @bitCast(wire); +} - /// Decode a fixed cell header, normalizing unknown semantic values. - pub fn decode(reader: *std.Io.Reader) std.Io.Reader.Error!DecodeResult { - // Decode various fields. We need to be very defensive here - // because it can come from an untrusted source and may be invalid. - const content_kind_optional = kind: { - const raw = try reader.takeByte(); - break :kind std.enums.fromInt(Kind, raw); - }; - const width = width: { - const raw = try reader.takeByte(); - break :width std.enums.fromInt(TerminalCell.Wide, raw) orelse .narrow; - }; - - const flags: Flags = @bitCast(try reader.takeByte()); - - // We can't trust `flags` to have a valid semantic content so - // we need extract the bits and do a safe conversion. - const semantic_content = semantic: { - const flags_raw: u8 = @bitCast(flags); - const raw: u2 = @truncate(flags_raw >> @bitOffsetOf(Flags, "semantic_content")); - break :semantic std.enums.fromInt( - TerminalCell.SemanticContent, - raw, - ) orelse .output; - }; - - // This byte is reserved so the IDs and content value remain naturally - // aligned within the fixed cell header. Ignore it so future versions - // can give it meaning without making old decoders reject the cell. - _ = try reader.takeByte(); - - const style_id = try io.readInt(reader, TerminalStyleId); - const hyperlink_id = try io.readInt(reader, TerminalHyperlinkId); - const value: Value = @bitCast(try io.readInt(reader, u32)); - const grapheme_count = try io.readInt(reader, u32); - - // If we don't have a valid content kind, we return invalid and - // note the suffix bytes so that the reader can discard - const content_kind = content_kind_optional orelse - return .{ .invalid = grapheme_count }; - - return .{ .valid = .{ - .content_kind = content_kind, - .width = width, - .protected = flags.protected, - .semantic_content = semantic_content, - .style_id = style_id, - .hyperlink_id = hyperlink_id, - .value = value, - .grapheme_count = grapheme_count, - } }; +/// Store one normalized, hyperlink-free wire word as a native cell. +fn storeCell(cell: *TerminalCell, bits: u64) void { + if (comptime native_matches_wire) { + const words: [*]u64 = @ptrCast(cell); + words[0] = bits; + return; } - /// Computes the fixed header size using the encoder itself. - fn computeLen() usize { - comptime { - var buf: [128]u8 = undefined; - var writer: std.Io.Writer = .fixed(&buf); - CellHeader.encode(.{}, &writer) catch unreachable; - return writer.end; + const wire: Cell = @bitCast(bits); + var native: TerminalCell = .init(0); + switch (@as(Cell.Kind, @enumFromInt(wire.kind))) { + .codepoint, .codepoint_grapheme => native.content = .{ + .codepoint = .{ .data = @intCast(wire.content) }, + }, + .bg_color_palette => { + native.content_tag = .bg_color_palette; + native.content = .{ + .color_palette = .{ .data = @truncate(wire.content) }, + }; + }, + .bg_color_rgb => { + native.content_tag = .bg_color_rgb; + native.content = .{ .color_rgb = .{ + .r = @truncate(wire.content), + .g = @truncate(wire.content >> 8), + .b = @truncate(wire.content >> 16), + } }; + }, + } + native.style_id = wire.style_id; + native.wide = @enumFromInt(wire.width); + native.protected = wire.protected; + native.semantic_content = @enumFromInt(wire.semantic_content); + cell.* = native; +} + +/// Maps encoded table IDs to IDs assigned by the destination page. +/// +/// Build this by inserting each decoded table entry into the page, then +/// recording the encoded ID and the ID returned by the page's set. ID zero +/// is implicit and does not need an entry. Lookup must be cheap because the +/// grid decoder consults it for every styled or linked cell, so this is a +/// direct-indexed table rather than a hash map. +fn Remap(comptime Id: type) type { + return struct { + const Self = @This(); + + /// One slot for every possible encoded ID. + pub const capacity = std.math.maxInt(Id) + 1; + + /// Indexed by encoded ID; zero means unmapped or default. + entries: []Id, + + /// Tracks IDs that received an entry, including ones mapped to the + /// default, so callers can give duplicate table entries first-wins + /// semantics. + seen: std.DynamicBitSetUnmanaged, + + pub fn init(alloc: Allocator) Allocator.Error!Self { + const entries = try alloc.alloc(Id, capacity); + errdefer alloc.free(entries); + @memset(entries, 0); + const seen = try std.DynamicBitSetUnmanaged.initEmpty( + alloc, + capacity, + ); + return .{ .entries = entries, .seen = seen }; } - } - /// Determines how `value` is interpreted. - pub const Kind = enum(u8) { - codepoint = 0, - bg_color_palette = 1, - bg_color_rgb = 2, + pub fn deinit(self: *Self, alloc: Allocator) void { + alloc.free(self.entries); + self.seen.deinit(alloc); + self.* = undefined; + } + + /// Record one encoded-to-native mapping. + pub fn put(self: *Self, encoded: Id, native: Id) void { + assert(!self.seen.isSet(encoded)); + self.entries[encoded] = native; + self.seen.set(encoded); + } + + /// Whether the encoded ID already has an entry, even a default one. + pub fn contains(self: *const Self, encoded: Id) bool { + return self.seen.isSet(encoded); + } + + /// The native ID for an encoded ID, or zero when unmapped. + pub inline fn get(self: *const Self, encoded: Id) Id { + return self.entries[encoded]; + } }; - - /// The alternate interpretations of the four-byte content field. - pub const Value = packed union(u32) { - codepoint: u32, - bg_color_palette: packed struct(u32) { - index: u8, - _padding: u24 = 0, - }, - bg_color_rgb: packed struct(u32) { - r: u8, - g: u8, - b: u8, - _padding: u8 = 0, - }, - }; - - const Flags = packed struct(u8) { - protected: bool = false, - semantic_content: TerminalCell.SemanticContent = .output, - _padding: u5 = 0, - }; -}; - +} const test_golden_fixture = test_fixture.parse( @embedFile("testdata/grid-v1.hex"), ); +test "cell wire layout registry" { + const testing = std.testing; + + // The wire bit positions are format constants. The shifts and masks + // derive from the packed struct, so pin the struct itself to the + // documented registry so an edit cannot silently move wire bits. + try testing.expectEqual(0, @bitOffsetOf(Cell, "kind")); + try testing.expectEqual(2, @bitOffsetOf(Cell, "content")); + try testing.expectEqual(26, @bitOffsetOf(Cell, "style_id")); + try testing.expectEqual(42, @bitOffsetOf(Cell, "width")); + try testing.expectEqual(44, @bitOffsetOf(Cell, "protected")); + try testing.expectEqual(45, @bitOffsetOf(Cell, "hyperlink")); + try testing.expectEqual(46, @bitOffsetOf(Cell, "semantic_content")); + try testing.expectEqual(48, @bitOffsetOf(Cell, "hyperlink_id")); + + // The native cell currently matches the wire registry, which enables + // the bulk row codec. If this fails, the native layout diverged: either + // restore it or accept the portable codec and update this expectation. + try testing.expect(native_matches_wire); +} + +test "encoded cell width transport registry" { + const testing = std.testing; + + // Pin the transported value and admission mask of every width against + // the documented format: widths one and two carry the bare codepoint, + // width four the low word half, width eight the complete word. + const word: u64 = @bitCast(Cell{ + .kind = 1, + .content = 0xABCDEF, + .style_id = 0x1234, + .hyperlink_id = 0x5678, + }); + try testing.expectEqual(@as(u8, 0xEF), Cell.EncodedWidth.one.truncate(word)); + try testing.expectEqual(@as(u16, 0xCDEF), Cell.EncodedWidth.two.truncate(word)); + try testing.expectEqual( + @as(u32, @truncate(word)), + Cell.EncodedWidth.four.truncate(word), + ); + try testing.expectEqual(word, Cell.EncodedWidth.eight.truncate(word)); + + try testing.expectEqual( + @as(u64, 0x0000_0000_0000_03FC), + Cell.EncodedWidth.one.mask(), + ); + try testing.expectEqual( + @as(u64, 0x0000_0000_0003_FFFC), + Cell.EncodedWidth.two.mask(), + ); + try testing.expectEqual( + @as(u64, 0x0000_0000_FFFF_FFFF), + Cell.EncodedWidth.four.mask(), + ); + try testing.expectEqual( + @as(u64, 0xFFFF_FFFF_FFFF_FFFF), + Cell.EncodedWidth.eight.mask(), + ); + + // Selection returns the smallest admissible width, and every admitted + // word round-trips through its transport. + const cases = [_]struct { cell: Cell, width: Cell.EncodedWidth }{ + .{ .cell = .{}, .width = .one }, + .{ .cell = .{ .content = 0xFF }, .width = .one }, + .{ .cell = .{ .content = 0x100 }, .width = .two }, + .{ .cell = .{ .content = 0xFFFF }, .width = .two }, + .{ .cell = .{ .content = 0x10000 }, .width = .four }, + .{ .cell = .{ .kind = 2, .content = 7 }, .width = .four }, + .{ .cell = .{ .content = 'a', .style_id = 63 }, .width = .four }, + .{ .cell = .{ .content = 'a', .style_id = 64 }, .width = .eight }, + .{ .cell = .{ .width = 1, .content = 'a' }, .width = .eight }, + .{ .cell = .{ .protected = true }, .width = .eight }, + .{ .cell = .{ .semantic_content = 1 }, .width = .eight }, + .{ .cell = .{ .hyperlink = true, .hyperlink_id = 1 }, .width = .eight }, + }; + inline for (cases) |case| { + const case_word: u64 = @bitCast(case.cell); + try testing.expectEqual( + case.width, + Cell.EncodedWidth.select(case_word), + ); + try testing.expectEqual( + case_word, + case.width.extend(case.width.truncate(case_word)), + ); + } +} + test "grid golden encoding and decoding" { const capacity: terminal_page.Capacity = .{ .cols = 3, - .rows = 2, + .rows = 4, .styles = 0, .hyperlink_bytes = 0, .grapheme_bytes = 64, @@ -643,7 +1372,16 @@ test "grid golden encoding and decoding" { head.row.wrap_continuation = true; head.row.semantic_prompt = .prompt_continuation; - var encoded: [128]u8 = undefined; + // The third row is bare ASCII text with an interior blank, which uses + // the one-byte encoded cell width. + source.getRowAndCell(0, 2).cell.* = .init('h'); + source.getRowAndCell(2, 2).cell.* = .init('i'); + + // The fourth row needs the two-byte width for a codepoint above U+00FF. + source.getRowAndCell(0, 3).cell.* = .init(0x0416); // Ж + source.getRowAndCell(1, 3).cell.* = .init('!'); + + var encoded: [160]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); try encode(&source, &writer); try test_fixture.expectEqual( @@ -656,10 +1394,10 @@ test "grid golden encoding and decoding" { var destination = try TerminalPage.init(capacity); defer destination.deinit(); - var style_remap = StyleRemap.init(std.testing.allocator); - defer style_remap.deinit(); - var hyperlink_remap = HyperlinkRemap.init(std.testing.allocator); - defer hyperlink_remap.deinit(); + var style_remap = try StyleRemap.init(std.testing.allocator); + defer style_remap.deinit(std.testing.allocator); + var hyperlink_remap = try HyperlinkRemap.init(std.testing.allocator); + defer hyperlink_remap.deinit(std.testing.allocator); // Decode the checked-in reference through a one-byte reader buffer. var fixture_reader: std.Io.Reader = .fixed(&test_golden_fixture); @@ -736,8 +1474,26 @@ test "grid golden encoding and decoding" { decoded_head.row.semantic_prompt, ); + try std.testing.expectEqual( + @as(u21, 'h'), + destination.getRowAndCell(0, 2).cell.codepoint(), + ); + try std.testing.expect(destination.getRowAndCell(1, 2).cell.isZero()); + try std.testing.expectEqual( + @as(u21, 'i'), + destination.getRowAndCell(2, 2).cell.codepoint(), + ); + try std.testing.expectEqual( + @as(u21, 0x0416), + destination.getRowAndCell(0, 3).cell.codepoint(), + ); + try std.testing.expectEqual( + @as(u21, '!'), + destination.getRowAndCell(1, 3).cell.codepoint(), + ); + // A re-encode proves the decoded native page retains every wire field. - var reencoded: [128]u8 = undefined; + var reencoded: [160]u8 = undefined; var rewriter: std.Io.Writer = .fixed(&reencoded); try encode(&destination, &rewriter); try std.testing.expectEqualStrings( @@ -746,10 +1502,81 @@ test "grid golden encoding and decoding" { ); } +test "grid elides trailing default cells" { + const testing = std.testing; + var page = try TerminalPage.init(.{ .cols = 80, .rows = 3 }); + defer page.deinit(); + + // Row 0 is fully default. Row 1 has content in columns zero and two. + // Row 2 has one protected-only cell at column four. + const middle = page.getRowAndCell(2, 1); + middle.cell.* = .init('b'); + page.getRowAndCell(0, 1).cell.* = .init('a'); + page.getRowAndCell(4, 2).cell.protected = true; + + var encoded: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try encode(&page, &writer); + + // 3 bytes per row header and cells only through the last non-default + // cell, followed by an empty grapheme section. The text row uses the + // one-byte width while the protected flag forces the full width. + try testing.expectEqual( + @as(usize, 3 + (3 + 3 * 1) + (3 + 5 * 8) + 4), + writer.buffered().len, + ); + + var destination = try TerminalPage.init(.{ .cols = 80, .rows = 3 }); + defer destination.deinit(); + var style_remap = try StyleRemap.init(testing.allocator); + defer style_remap.deinit(testing.allocator); + var hyperlink_remap = try HyperlinkRemap.init(testing.allocator); + defer hyperlink_remap.deinit(testing.allocator); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + try decode(&destination, &reader, &style_remap, &hyperlink_remap); + try destination.verifyIntegrity(testing.allocator); + + try testing.expectEqual( + @as(u21, 'a'), + destination.getRowAndCell(0, 1).cell.codepoint(), + ); + try testing.expectEqual( + @as(u21, 'b'), + destination.getRowAndCell(2, 1).cell.codepoint(), + ); + try testing.expect(destination.getRowAndCell(4, 2).cell.protected); + try testing.expect(destination.getRowAndCell(79, 1).cell.isZero()); +} + +test "grid rejects a row cell count above the column count" { + const testing = std.testing; + var page = try TerminalPage.init(.{ .cols = 2, .rows = 1 }); + defer page.deinit(); + var style_remap = try StyleRemap.init(testing.allocator); + defer style_remap.deinit(testing.allocator); + var hyperlink_remap = try HyperlinkRemap.init(testing.allocator); + defer hyperlink_remap.deinit(testing.allocator); + + var payload: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&payload); + try writer.writeByte(@bitCast(Row{})); + try io.writeInt(&writer, u16, 3); + for (0..3) |_| try io.writeInt(&writer, u64, 0); + try io.writeInt(&writer, u32, 0); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + try testing.expectError( + error.InvalidRowCellCount, + decode(&page, &reader, &style_remap, &hyperlink_remap), + ); +} + test "grid normalizes incomplete wide cells" { const testing = std.testing; // One column puts the wide cell at the row end. Two columns put an // ordinary narrow cell after it. Neither case supplies a spacer tail. + // Both rely on the implicit narrow cell rule when the tail is elided. for ([_]u16{ 1, 2 }) |columns| { const capacity: terminal_page.Capacity = .{ .cols = columns, @@ -760,21 +1587,23 @@ test "grid normalizes incomplete wide cells" { // content but makes the incomplete wide cell narrow. var payload: [64]u8 = undefined; var payload_writer: std.Io.Writer = .fixed(&payload); - try payload_writer.writeByte(@bitCast(RowHeader{})); - try (CellHeader{ - .width = .wide, - .value = .{ .codepoint = 'A' }, - }).encode(&payload_writer); - if (capacity.cols > 1) try (CellHeader{ - .value = .{ .codepoint = 'B' }, - }).encode(&payload_writer); + try payload_writer.writeByte(@bitCast(Row{ .cell_width = .eight })); + try io.writeInt(&payload_writer, u16, columns); + try io.writeInt(&payload_writer, u64, @bitCast(Cell{ + .width = 1, // wide + .content = 'A', + })); + if (columns > 1) try io.writeInt(&payload_writer, u64, @bitCast(Cell{ + .content = 'B', + })); + try io.writeInt(&payload_writer, u32, 0); var destination = try TerminalPage.init(capacity); defer destination.deinit(); - var style_remap = StyleRemap.init(testing.allocator); - defer style_remap.deinit(); - var hyperlink_remap = HyperlinkRemap.init(testing.allocator); - defer hyperlink_remap.deinit(); + var style_remap = try StyleRemap.init(testing.allocator); + defer style_remap.deinit(testing.allocator); + var hyperlink_remap = try HyperlinkRemap.init(testing.allocator); + defer hyperlink_remap.deinit(testing.allocator); var payload_reader: std.Io.Reader = .fixed( payload_writer.buffered(), @@ -801,4 +1630,442 @@ test "grid normalizes incomplete wide cells" { try testing.expectEqual(TerminalCell.Wide.narrow, second.wide); } } + + // A wide marker whose spacer tail was elided by a short cell count is + // also normalized, and the trailing cells stay default. + var page = try TerminalPage.init(.{ .cols = 4, .rows = 1 }); + defer page.deinit(); + var style_remap = try StyleRemap.init(testing.allocator); + defer style_remap.deinit(testing.allocator); + var hyperlink_remap = try HyperlinkRemap.init(testing.allocator); + defer hyperlink_remap.deinit(testing.allocator); + + var payload: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&payload); + try writer.writeByte(@bitCast(Row{ .cell_width = .eight })); + try io.writeInt(&writer, u16, 1); + try io.writeInt(&writer, u64, @bitCast(Cell{ + .width = 1, // wide + .content = 'W', + })); + try io.writeInt(&writer, u32, 0); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + try decode(&page, &reader, &style_remap, &hyperlink_remap); + try page.verifyIntegrity(testing.allocator); + try testing.expectEqual( + TerminalCell.Wide.narrow, + page.getRowAndCell(0, 0).cell.wide, + ); + try testing.expect(page.getRowAndCell(1, 0).cell.isZero()); +} + +test "grid normalizes reserved cell values" { + const testing = std.testing; + var page = try TerminalPage.init(.{ .cols = 3, .rows = 1 }); + defer page.deinit(); + var style_remap = try StyleRemap.init(testing.allocator); + defer style_remap.deinit(testing.allocator); + var hyperlink_remap = try HyperlinkRemap.init(testing.allocator); + defer hyperlink_remap.deinit(testing.allocator); + + var payload: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&payload); + + // Reserved row flag bits six and seven are ignored while the unknown + // semantic-prompt value degrades to none and wrap survives. Bits four + // and five select the full encoded cell width. + try writer.writeByte(0xFD); + try io.writeInt(&writer, u16, 3); + + // A surrogate codepoint with reserved semantic content 3. + try io.writeInt(&writer, u64, @bitCast(Cell{ + .content = 0xD800, + .semantic_content = 3, + })); + + // Reserved palette content bits do not obscure the palette index. + try io.writeInt(&writer, u64, @bitCast(Cell{ + .kind = 2, + .content = 0xFFFF07, + })); + + // An unknown style reference degrades to the default style, and an + // unknown hyperlink reference leaves the cell unlinked. + try io.writeInt(&writer, u64, @bitCast(Cell{ + .content = 'x', + .style_id = 5, + .hyperlink = true, + .hyperlink_id = 9, + })); + try io.writeInt(&writer, u32, 0); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + try decode(&page, &reader, &style_remap, &hyperlink_remap); + try page.verifyIntegrity(testing.allocator); + + const first = page.getRowAndCell(0, 0); + try testing.expect(first.row.wrap); + try testing.expectEqual( + TerminalRow.SemanticPrompt.none, + first.row.semantic_prompt, + ); + try testing.expectEqual(@as(u21, 0xFFFD), first.cell.codepoint()); + try testing.expectEqual( + TerminalCell.SemanticContent.output, + first.cell.semantic_content, + ); + + const second = page.getRowAndCell(1, 0).cell; + try testing.expectEqual( + TerminalCell.ContentTag.bg_color_palette, + second.content_tag, + ); + try testing.expectEqual(@as(u8, 7), second.content.color_palette.data); + + const third = page.getRowAndCell(2, 0).cell; + try testing.expectEqual(@as(u21, 'x'), third.codepoint()); + try testing.expectEqual(@as(TerminalStyleId, 0), third.style_id); + try testing.expect(!third.hyperlink); + try testing.expectEqual(null, page.lookupHyperlink(third)); +} + +test "grid drops undeliverable grapheme entries" { + const testing = std.testing; + var page = try TerminalPage.init(.{ + .cols = 4, + .rows = 1, + .grapheme_bytes = 64, + }); + defer page.deinit(); + var style_remap = try StyleRemap.init(testing.allocator); + defer style_remap.deinit(testing.allocator); + var hyperlink_remap = try HyperlinkRemap.init(testing.allocator); + defer hyperlink_remap.deinit(testing.allocator); + + var payload: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&payload); + try writer.writeByte(@bitCast(Row{ .cell_width = .eight })); + try io.writeInt(&writer, u16, 4); + // A kind 1 cell that receives a valid entry. + try io.writeInt(&writer, u64, @bitCast(Cell{ .kind = 1, .content = 'x' })); + // A kind 1 cell without an entry decodes as a plain codepoint. + try io.writeInt(&writer, u64, @bitCast(Cell{ .kind = 1, .content = 'y' })); + // A background cell cannot carry a suffix. + try io.writeInt(&writer, u64, @bitCast(Cell{ .kind = 2, .content = 7 })); + // An empty codepoint cannot carry a suffix. + try io.writeInt(&writer, u64, @bitCast(Cell{})); + + try io.writeInt(&writer, u32, 5); + // Valid entry with one invalid scalar dropped from within it. + try io.writeInt(&writer, u16, 0); + try io.writeInt(&writer, u16, 0); + try io.writeInt(&writer, u16, 4); + try io.writeInt(&writer, u32, 0x0301); + try io.writeInt(&writer, u32, 0); + try io.writeInt(&writer, u32, 0xD800); + try io.writeInt(&writer, u32, 0x0302); + // Duplicate entry for the same cell is consumed and dropped. + try io.writeInt(&writer, u16, 0); + try io.writeInt(&writer, u16, 0); + try io.writeInt(&writer, u16, 1); + try io.writeInt(&writer, u32, 0x0303); + // Entry for the background cell is consumed and dropped. + try io.writeInt(&writer, u16, 0); + try io.writeInt(&writer, u16, 2); + try io.writeInt(&writer, u16, 1); + try io.writeInt(&writer, u32, 0x0301); + // Entry for the empty cell is consumed and dropped. + try io.writeInt(&writer, u16, 0); + try io.writeInt(&writer, u16, 3); + try io.writeInt(&writer, u16, 1); + try io.writeInt(&writer, u32, 0x0301); + // Entry outside the grid is consumed and dropped. + try io.writeInt(&writer, u16, 7); + try io.writeInt(&writer, u16, 0); + try io.writeInt(&writer, u16, 1); + try io.writeInt(&writer, u32, 0x0301); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + try decode(&page, &reader, &style_remap, &hyperlink_remap); + try page.verifyIntegrity(testing.allocator); + + const first = page.getRowAndCell(0, 0); + try testing.expectEqualSlices( + u21, + &.{ 0x0301, 0x0302 }, + page.lookupGrapheme(first.cell).?, + ); + const second = page.getRowAndCell(1, 0).cell; + try testing.expectEqual(@as(u21, 'y'), second.codepoint()); + try testing.expect(!second.hasGrapheme()); + try testing.expect(!page.getRowAndCell(2, 0).cell.hasGrapheme()); + try testing.expect(!page.getRowAndCell(3, 0).cell.hasGrapheme()); +} + +test "grid drops a complete grapheme when capacity fails mid-cluster" { + const testing = std.testing; + var page = try TerminalPage.init(.{ + .cols = 4, + .rows = 1, + .grapheme_bytes = 512, + }); + defer page.deinit(); + var style_remap = try StyleRemap.init(testing.allocator); + defer style_remap.deinit(testing.allocator); + var hyperlink_remap = try HyperlinkRemap.init(testing.allocator); + defer hyperlink_remap.deinit(testing.allocator); + + var payload: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&payload); + try writer.writeByte(@bitCast(Row{ .cell_width = .eight })); + try io.writeInt(&writer, u16, 4); + for (0..4) |i| try io.writeInt( + &writer, + u64, + @bitCast(Cell{ .kind = 1, .content = @intCast('x' + i) }), + ); + + // BitmapAllocator rounds this capacity to 64 four-codepoint chunks. The + // first three cells consume 34 chunks. On the fourth cell, appending the + // 61st suffix needs 16 new chunks while the old 15-chunk slice is still + // live, exceeding capacity and forcing the complete suffix to be dropped. + try io.writeInt(&writer, u32, 4); + for ([_]u16{ 64, 64, 8, 61 }, 0..) |count, x| { + try io.writeInt(&writer, u16, 0); + try io.writeInt(&writer, u16, @intCast(x)); + try io.writeInt(&writer, u16, count); + for (0..count) |i| try io.writeInt( + &writer, + u32, + @intCast(0x0300 + i), + ); + } + + var reader: std.Io.Reader = .fixed(writer.buffered()); + try decode(&page, &reader, &style_remap, &hyperlink_remap); + try page.verifyIntegrity(testing.allocator); + + const cell = page.getRowAndCell(3, 0); + try testing.expectEqual(@as(u21, @intCast('x' + 3)), cell.cell.codepoint()); + try testing.expect(!cell.cell.hasGrapheme()); + try testing.expect(cell.row.grapheme); + try testing.expectEqual(@as(usize, 3), page.graphemeCount()); +} + +test "grid encodes rows at their narrowest width" { + const testing = std.testing; + var page = try TerminalPage.init(.{ + .cols = 2, + .rows = 4, + .styles = 8, + }); + defer page.deinit(); + + // Width zero: bare Latin-1 text. + page.getRowAndCell(0, 0).cell.* = .init('A'); + page.getRowAndCell(1, 0).cell.* = .init(0xFF); + + // Width one: any BMP codepoint. + page.getRowAndCell(0, 1).cell.* = .init(0x0100); + + // Width two: a small style ID and a background color. + const style_id = try page.styles.add(page.memory, .{ + .flags = .{ .bold = true }, + }); + try testing.expect(style_id <= 63); + const styled = page.getRowAndCell(0, 2); + styled.cell.* = .init('s'); + styled.cell.style_id = style_id; + styled.row.styled = true; + const bg = page.getRowAndCell(1, 2); + bg.cell.content_tag = .bg_color_palette; + bg.cell.content = .{ .color_palette = .{ .data = 7 } }; + + // Width three: a protected cell. + page.getRowAndCell(0, 3).cell.protected = true; + + var encoded: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try encode(&page, &writer); + + const bytes = writer.buffered(); + try testing.expectEqual( + @as(usize, (3 + 2 * 1) + (3 + 1 * 2) + (3 + 2 * 4) + (3 + 1 * 8) + 4), + bytes.len, + ); + + // Each row header carries the expected width bits. + try testing.expectEqual(@as(u8, 0 << 4), bytes[0] & 0x30); + try testing.expectEqual(@as(u8, 1 << 4), bytes[5] & 0x30); + try testing.expectEqual(@as(u8, 2 << 4), bytes[10] & 0x30); + try testing.expectEqual(@as(u8, 3 << 4), bytes[21] & 0x30); + + var destination = try TerminalPage.init(.{ + .cols = 2, + .rows = 4, + .styles = 8, + }); + defer destination.deinit(); + var style_remap = try StyleRemap.init(testing.allocator); + defer style_remap.deinit(testing.allocator); + var hyperlink_remap = try HyperlinkRemap.init(testing.allocator); + defer hyperlink_remap.deinit(testing.allocator); + const native_style = try destination.styles.add(destination.memory, .{ + .flags = .{ .bold = true }, + }); + style_remap.put(style_id, native_style); + + var reader: std.Io.Reader = .fixed(bytes); + try decode(&destination, &reader, &style_remap, &hyperlink_remap); + try destination.verifyIntegrity(testing.allocator); + + try testing.expectEqual( + @as(u21, 'A'), + destination.getRowAndCell(0, 0).cell.codepoint(), + ); + try testing.expectEqual( + @as(u21, 0xFF), + destination.getRowAndCell(1, 0).cell.codepoint(), + ); + try testing.expectEqual( + @as(u21, 0x0100), + destination.getRowAndCell(0, 1).cell.codepoint(), + ); + const decoded_styled = destination.getRowAndCell(0, 2); + try testing.expectEqual(@as(u21, 's'), decoded_styled.cell.codepoint()); + try testing.expectEqual(native_style, decoded_styled.cell.style_id); + try testing.expect(decoded_styled.row.styled); + try testing.expectEqual( + @as(u8, 7), + destination.getRowAndCell(1, 2).cell.content.color_palette.data, + ); + try testing.expect(destination.getRowAndCell(0, 3).cell.protected); +} + +test "grid decodes non-canonical cell widths" { + const testing = std.testing; + var page = try TerminalPage.init(.{ .cols = 2, .rows = 1 }); + defer page.deinit(); + var style_remap = try StyleRemap.init(testing.allocator); + defer style_remap.deinit(testing.allocator); + var hyperlink_remap = try HyperlinkRemap.init(testing.allocator); + defer hyperlink_remap.deinit(testing.allocator); + + // A bare ASCII row encoded at the full width is wasteful but valid. + var payload: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&payload); + try writer.writeByte(@bitCast(Row{ .cell_width = .eight })); + try io.writeInt(&writer, u16, 2); + try io.writeInt(&writer, u64, @bitCast(Cell{ .content = 'o' })); + try io.writeInt(&writer, u64, @bitCast(Cell{ .content = 'k' })); + try io.writeInt(&writer, u32, 0); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + try decode(&page, &reader, &style_remap, &hyperlink_remap); + try page.verifyIntegrity(testing.allocator); + try testing.expectEqual( + @as(u21, 'o'), + page.getRowAndCell(0, 0).cell.codepoint(), + ); + try testing.expectEqual( + @as(u21, 'k'), + page.getRowAndCell(1, 0).cell.codepoint(), + ); + + // Re-encoding canonicalizes the row back to the one-byte width. + var reencoded: [16]u8 = undefined; + var rewriter: std.Io.Writer = .fixed(&reencoded); + try encode(&page, &rewriter); + try testing.expectEqual(@as(usize, 3 + 2 + 4), rewriter.buffered().len); +} + +test "grid normalizes surrogates in two-byte cells" { + const testing = std.testing; + var page = try TerminalPage.init(.{ .cols = 2, .rows = 1 }); + defer page.deinit(); + var style_remap = try StyleRemap.init(testing.allocator); + defer style_remap.deinit(testing.allocator); + var hyperlink_remap = try HyperlinkRemap.init(testing.allocator); + defer hyperlink_remap.deinit(testing.allocator); + + var payload: [16]u8 = undefined; + var writer: std.Io.Writer = .fixed(&payload); + try writer.writeByte(@bitCast(Row{ .cell_width = .two })); + try io.writeInt(&writer, u16, 2); + try io.writeInt(&writer, u16, 0xD800); + try io.writeInt(&writer, u16, 0x0416); + try io.writeInt(&writer, u32, 0); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + try decode(&page, &reader, &style_remap, &hyperlink_remap); + try page.verifyIntegrity(testing.allocator); + try testing.expectEqual( + @as(u21, 0xFFFD), + page.getRowAndCell(0, 0).cell.codepoint(), + ); + try testing.expectEqual( + @as(u21, 0x0416), + page.getRowAndCell(1, 0).cell.codepoint(), + ); +} + +test "grid four-byte cells run full normalization" { + const testing = std.testing; + var page = try TerminalPage.init(.{ + .cols = 3, + .rows = 1, + .styles = 8, + }); + defer page.deinit(); + var style_remap = try StyleRemap.init(testing.allocator); + defer style_remap.deinit(testing.allocator); + var hyperlink_remap = try HyperlinkRemap.init(testing.allocator); + defer hyperlink_remap.deinit(testing.allocator); + + var payload: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&payload); + try writer.writeByte(@bitCast(Row{ .cell_width = .four })); + try io.writeInt(&writer, u16, 3); + + // The Kitty placeholder does not fit two-byte cells but fits here + // and must still derive the native row hint. + try io.writeInt(&writer, u32, @truncate(@as(u64, @bitCast(Cell{ + .content = kitty_virtual_placeholder, + })))); + + // An unknown small style reference degrades to the default style. + try io.writeInt(&writer, u32, @truncate(@as(u64, @bitCast(Cell{ + .content = 'x', + .style_id = 63, + })))); + + // Reserved palette content bits are cleared at this width too. + try io.writeInt(&writer, u32, @truncate(@as(u64, @bitCast(Cell{ + .kind = 2, + .content = 0xFFFF07, + })))); + try io.writeInt(&writer, u32, 0); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + try decode(&page, &reader, &style_remap, &hyperlink_remap); + try page.verifyIntegrity(testing.allocator); + + const first = page.getRowAndCell(0, 0); + try testing.expectEqual( + kitty_virtual_placeholder, + first.cell.codepoint(), + ); + try testing.expect(first.row.kitty_virtual_placeholder); + + const second = page.getRowAndCell(1, 0).cell; + try testing.expectEqual(@as(u21, 'x'), second.codepoint()); + try testing.expectEqual(@as(TerminalStyleId, 0), second.style_id); + + const third = page.getRowAndCell(2, 0).cell; + try testing.expectEqual( + TerminalCell.ContentTag.bg_color_palette, + third.content_tag, + ); + try testing.expectEqual(@as(u8, 7), third.content.color_palette.data); } diff --git a/src/terminal/snapshot/history.zig b/src/terminal/snapshot/history.zig index dc5fcb65d..90f1a7498 100644 --- a/src/terminal/snapshot/history.zig +++ b/src/terminal/snapshot/history.zig @@ -193,6 +193,40 @@ pub const DecodeError = Decoder.InitError || UnexpectedScreenKey, }; +/// Errors possible while restoring one history PAGE into a native Screen. +pub const DecodePageError = Allocator.Error || + page.DecodeError || + TerminalPageList.PageAllocation.FinalizeError; + +/// Restore the next history PAGE record and prepend it to the native Screen. +/// +/// Returns the number of rows added above the screen's existing content. +/// The page is decoded directly into a detached PageList-pooled allocation +/// and prepended only after its record validates, so a failure leaves the +/// screen unchanged. A limit failure from `finalize` occurs after the record +/// bytes were fully consumed, leaving `source` aligned on the next record. +pub fn decodePage( + source: *std.Io.Reader, + alloc: Allocator, + terminal_screen: *TerminalScreen, +) DecodePageError!usize { + // PAGE exposes its exact capacity before decoding the payload, allowing + // the destination PageList to allocate the final backing memory once. + var decoder: page.Decoder = undefined; + try decoder.init(source); + var allocation = try terminal_screen.pages.allocatePage( + decoder.capacity(), + ); + defer allocation.deinit(); + try decoder.decode(allocation.page(), alloc); + + const rows = allocation.page().size.rows; + const contains_prompt = hasSemanticPrompt(allocation.page()); + try allocation.finalize(.prepend); + if (contains_prompt) terminal_screen.semantic_prompt.seen = true; + return rows; +} + /// A decoded HISTORY manifest ready to restore its following PAGE records. /// /// Keeping manifest decoding separate lets the full snapshot wrapper route a @@ -209,13 +243,10 @@ pub const Decoder = struct { UnexpectedRecordTag, }; - pub const RestoreError = Allocator.Error || - page.DecodeError || - TerminalPageList.PageAllocation.FinalizeError || - error{ - /// The Screen already contains complete pages before its active page. - ExistingHistory, - }; + pub const RestoreError = DecodePageError || error{ + /// The Screen already contains complete pages before its active page. + ExistingHistory, + }; /// Decode and finish the self-contained HISTORY manifest. pub fn init(self: *Decoder, source: *std.Io.Reader) InitError!void { @@ -245,20 +276,7 @@ pub const Decoder = struct { // Native row totals remain derived from the actual PAGE dimensions. for (0..self.header.page_count) |_| { - // PAGE exposes its exact capacity before decoding the payload, - // allowing the destination PageList to allocate the final backing - // memory once. - var decoder: page.Decoder = undefined; - try decoder.init(self.source); - var allocation = try terminal_screen.pages.allocatePage( - decoder.capacity(), - ); - defer allocation.deinit(); - try decoder.decode(allocation.page(), alloc); - - const contains_prompt = hasSemanticPrompt(allocation.page()); - try allocation.finalize(.prepend); - if (contains_prompt) terminal_screen.semantic_prompt.seen = true; + _ = try decodePage(self.source, alloc, terminal_screen); } terminal_screen.pages.assertIntegrity(); diff --git a/src/terminal/snapshot/main.zig b/src/terminal/snapshot/main.zig index 7e546a5e6..f69d78c91 100644 --- a/src/terminal/snapshot/main.zig +++ b/src/terminal/snapshot/main.zig @@ -10,13 +10,11 @@ //! To do that, it sends the active terminal state followed by a READY record, //! then complete history. //! -//! READY denotes that enough of the terminal state is down that it can -//! be fully rendered at that point. This is also the point where live -//! terminals can also start accepting pty bytes, typically. But the current -//! snapshot format lacks some of the information necessary to synchronize -//! pty byte state with an authoritative server. +//! READY denotes that enough state has been sent down to render the +//! terminal and reconstruct its unfinished VT Stream state. //! -//! After READY, we send history pages (scrollback). +//! After READY, we send history pages (scrollback). Finally, the +//! snapshot ends with an empty FINISH marker. //! //! ## Snapshot Format //! @@ -53,6 +51,8 @@ //! | SCREEN * terminal.screen_count | //! | PAGE * each screen.page_count | //! +----------------------------------------+ +//! | CONTINUATION | +//! +----------------------------------------+ //! | READY | //! +----------------------------------------+ //! | HISTORY * terminal.screen_count | @@ -71,12 +71,16 @@ //! is zero. FINISH terminates the snapshot. Bytes after FINISH belong to the //! containing transport and are not consumed by snapshot decoding. //! -//! READY and FINISH contain BLAKE3-256 digests of all preceding snapshot bytes. -//! READY therefore validates the renderable active-state prefix. FINISH covers -//! READY and all history as well, validating the complete snapshot and its -//! record ordering. Each SCREEN declares its complete logical history extent, -//! allowing a client to size its scrollbar at READY even though older PAGE -//! records arrive afterward. +//! CONTINUATION contains the bytes required to bring the terminal's +//! VT parser/stream up to the same state, or no bytes if it should be +//! in the ground state. +//! +//! READY and FINISH are empty marker records. READY separates the renderable +//! active state and continuation from history; FINISH terminates the complete +//! snapshot. Every record, including both markers, is independently protected +//! by CRC32C. Each SCREEN declares its complete logical history extent, allowing +//! a client to size its scrollbar at READY even though older PAGE records arrive +//! afterward. //! //! ## Encoding //! @@ -86,19 +90,21 @@ //! var output: std.Io.Writer.Allocating = .init(alloc); //! defer output.deinit(); //! -//! try snapshot.encode(alloc, &output.writer, &terminal); +//! try snapshot.encode(alloc, &output.writer, &terminal, .{ +//! .continuation = .ground, +//! }); //! //! const bytes = output.written(); //! ``` //! //! Encoding begins at the writer's current position, so unrelated bytes may //! precede the snapshot. The encoder buffers only the current record payload -//! to calculate its length and CRC32C; completed records stream immediately -//! and BLAKE3 checkpoint coverage is updated incrementally. Buffering is an -//! encoder implementation detail, not a requirement of the wire format. +//! to calculate its length and CRC32C; completed records stream immediately. +//! Buffering is an encoder implementation detail, not a requirement of the +//! wire format. //! //! A failure may leave prior complete records, or a partial record if the -//! destination itself fails. Such a prefix has no valid FINISH checkpoint and +//! destination itself fails. Such a prefix has no FINISH marker and //! cannot be restored as a complete snapshot. //! //! Each record type usually exposes an `encode` function that encodes @@ -113,15 +119,41 @@ //! outside this ordered snapshot record sequence. //! //! ```zig -//! var terminal = try snapshot.decode(alloc, io, &reader); +//! var decoded = try snapshot.decode(alloc, io, &reader, .{ +//! .max_continuation_bytes = 1024 * 1024, +//! }); +//! defer decoded.deinit(alloc); +//! var terminal = decoded.toOwned(); //! defer terminal.deinit(alloc); //! ``` //! //! Use `snapshot.decodeExact` for a bounded file or buffer that must contain //! only one snapshot. It preserves the stricter end-of-file check, which may //! block when used with a live stream. +//! +//! `snapshot.Decoder` decodes the same stream incrementally so the terminal +//! becomes usable at READY, before history has arrived. Each `next` call +//! applies one history page to the by-then live terminal and returning null +//! validates FINISH: +//! +//! ```zig +//! var decoder: snapshot.Decoder = .init(&reader); +//! var decoded = try decoder.ready(alloc, io, .{ +//! .max_continuation_bytes = 1024 * 1024, +//! }); +//! defer decoded.deinit(alloc); +//! +//! var terminal = decoded.toOwned(); +//! defer terminal.deinit(alloc); +//! +//! // Render, replay the continuation, process input... +//! while (try decoder.next(alloc, &terminal)) |progress| { +//! _ = progress; // Scrollback grew. +//! } +//! ``` pub const checkpoint = @import("checkpoint.zig"); +pub const continuation = @import("continuation.zig"); pub const envelope = @import("envelope.zig"); pub const grid = @import("grid.zig"); pub const history = @import("history.zig"); @@ -136,6 +168,11 @@ const codec = @import("snapshot.zig"); pub const EncodeError = codec.EncodeError; pub const DecodeError = codec.DecodeError; pub const DecodeExactError = codec.DecodeExactError; +pub const Continuation = codec.Continuation; +pub const EncodeOptions = codec.EncodeOptions; +pub const DecodeOptions = codec.DecodeOptions; +pub const Decoded = codec.Decoded; +pub const Decoder = codec.Decoder; pub const encode = codec.encode; pub const decode = codec.decode; pub const decodeExact = codec.decodeExact; diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig index e71bc53c4..0d166e389 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -63,19 +63,19 @@ //! | hyperlink_count entries | //! +---------------------------+ //! | Grid | -//! | one record per row | +//! | rows and grapheme section | //! +---------------------------+ //! //! Style entry = encoded ID + style record //! Hyperlink entry = encoded ID + hyperlink record -//! Grid = row 0 ... row (rows - 1) -//! Row = row header + cell 0 ... cell (columns - 1) -//! Cell = cell header + grapheme suffix codepoints +//! Grid = row 0 ... row (rows - 1) + grapheme suffix section +//! Row = row header + encoded cells //! ``` //! //! Following the header, the payload contains exactly `style_count` style //! records and `hyperlink_count` hyperlink records, followed by exactly -//! `rows` row records. There is no padding between records. +//! `rows` row records and one grapheme suffix section. There is no padding +//! between records. //! //! Each style begins with an ID (`u16`) followed by the typical style //! binary representation (in style.zig). The ID is what cells will use @@ -93,6 +93,7 @@ //! Rows and cells use the grid encoding documented in `grid.zig`. const std = @import("std"); +const build_options = @import("terminal_options"); const Allocator = std.mem.Allocator; const test_fixture = @import("fixture.zig"); const grid = @import("grid.zig"); @@ -119,6 +120,7 @@ const PayloadEncodeError = hyperlink.EncodeError || grid.EncodeError; const PayloadDecodeError = std.Io.Reader.Error || Header.CapacityError || + grid.DecodeError || error{ /// The hyperlink kind is not defined by snapshot version 1. InvalidKind, @@ -202,10 +204,17 @@ pub const Decoder = struct { return self.header.pageCapacity() catch unreachable; } + /// Largest remaining PAGE payload staged into one contiguous buffer. + /// This comfortably covers every payload a standard-capacity page can + /// produce while keeping the allocation an untrusted length can force + /// far below the record framing's four-byte length limit. + const max_staged_payload = 8 * 1024 * 1024; + /// Decode the remaining payload into caller-owned native page storage. /// /// The destination must be freshly initialized with `capacity`. `alloc` - /// is used only for temporary ID remaps and integrity-check storage. + /// is used only for temporary staging, ID remaps, and integrity-check + /// storage. pub fn decode( self: *Decoder, destination: *TerminalPage, @@ -219,17 +228,81 @@ pub const Decoder = struct { .cols = self.header.columns, .rows = self.header.rows, }; - try decodePayloadBody( - self.record_reader.payloadReader(), - alloc, - destination, - self.header, - ); + + // `init` consumed exactly the fixed header from the declared + // payload, so this is the byte count of the tables and grid. + const remaining = self.record_reader.header.payload_len - Header.len; + + if (remaining <= max_staged_payload) { + // Stage the payload with one bulk read. The whole payload passes + // through the checksum hasher as one update and the payload + // decoders then parse a flat buffer, which keeps per-row work free + // of stream adapters. The CRC and exact-length checks in `finish` + // are unaffected. + const staged = try alloc.alloc(u8, remaining); + defer alloc.free(staged); + try self.record_reader.payloadReader().readSliceAll(staged); + + var staged_reader: std.Io.Reader = .fixed(staged); + try decodePayloadBody( + &staged_reader, + alloc, + destination, + self.header, + ); + if (staged_reader.bufferedLen() != 0) { + return error.PayloadNotExhausted; + } + } else { + // A payload this large is either hostile or a page far beyond + // native capacities. Decode it through the streaming payload + // reader so its declared length cannot force an allocation. + try decodePayloadBody( + self.record_reader.payloadReader(), + alloc, + destination, + self.header, + ); + } try self.record_reader.finish(); - try destination.verifyIntegrity(alloc); + + // The decoder normalizes every semantic value, so a complete decode + // upholds native page invariants by construction. Verifying them + // again is a defense against decoder bugs and follows the native + // page policy: full integrity verification only when slow runtime + // safety is enabled. + if (comptime build_options.slow_runtime_safety) { + try destination.verifyIntegrity(alloc); + } } }; +/// Errors possible while discarding one PAGE record without decoding it. +pub const DiscardError = record.Reader.InitError || + record.Reader.FinishError || + std.Io.Reader.Error || + error{ + /// The next record is valid but is not a PAGE. + UnexpectedRecordTag, + }; + +/// Consume exactly one complete PAGE record, validating its framing and +/// CRC32C while discarding the payload without structural validation. +/// +/// This lets a decoder stay aligned with the record sequence while dropping +/// page content it can no longer apply. +pub fn discard(source: *std.Io.Reader) DiscardError!void { + var record_reader: record.Reader = undefined; + try record_reader.init(source); + if (record_reader.header.tag != .page) { + return error.UnexpectedRecordTag; + } + try record_reader.payloadReader().discardAll( + record_reader.header.payload_len, + ); + try record_reader.finish(); +} + /// Encode a PAGE payload directly from a native page. fn encodePayload( page: *const TerminalPage, @@ -284,20 +357,18 @@ fn decodePayloadBody( page.pauseIntegrityChecks(true); defer page.pauseIntegrityChecks(false); - var style_remap = grid.StyleRemap.init(alloc); - defer style_remap.deinit(); - style_remap.ensureTotalCapacity(header.style_count) catch + var style_remap = grid.StyleRemap.init(alloc) catch return error.OutOfMemory; + defer style_remap.deinit(alloc); - var hyperlink_remap = grid.HyperlinkRemap.init(alloc); - defer hyperlink_remap.deinit(); - hyperlink_remap.ensureTotalCapacity(header.hyperlink_count) catch + var hyperlink_remap = grid.HyperlinkRemap.init(alloc) catch return error.OutOfMemory; + defer hyperlink_remap.deinit(alloc); // Styles for (0..header.style_count) |_| { const native_id = try io.readInt(reader, TerminalStyleId); - const value: ?TerminalStyle = style.decodeOrDiscard(reader) catch null; + const value = try style.decodeOrNull(reader); // Zero is reserved for the implicit default. For a duplicate encoded // ID, the first entry wins and this complete entry is simply ignored. @@ -316,10 +387,7 @@ fn decodePayloadBody( valid, ) catch 0; } else 0; - style_remap.putAssumeCapacityNoClobber( - native_id, - decoded_id, - ); + style_remap.put(native_id, decoded_id); } // Hyperlinks @@ -338,10 +406,7 @@ fn decodePayloadBody( // Zero records an ignored table entry. Grid decoding treats every // reference to it as no hyperlink. - hyperlink_remap.putAssumeCapacityNoClobber( - native_id, - decoded_id, - ); + hyperlink_remap.put(native_id, decoded_id); } // Rows and cells @@ -351,6 +416,28 @@ fn decodePayloadBody( &style_remap, &hyperlink_remap, ); + + // A newly inserted table value starts with one reference so grid decoding + // can safely attach it to any number of cells. Unlike organically built + // pages, that initial reference does not itself represent a cell. Release + // it once per distinct live style after every cell reference is installed; + // unused entries then become dead and disappear from canonical re-encoding. + for (1..@as(usize, page.styles.next_id)) |raw_id| { + const id: TerminalStyleId = @intCast(raw_id); + if (page.styles.refCount(page.memory, id) > 0) { + page.styles.release(page.memory, id); + } + } + + // Hyperlink insertion likewise creates one temporary reference for every + // accepted wire table entry. Release through the encoded-ID remap rather + // than the native set so duplicate values which deduplicated to the same + // native ID each surrender their own reference. + var hyperlink_it = hyperlink_remap.seen.iterator(.{}); + while (hyperlink_it.next()) |encoded_id| { + const id = hyperlink_remap.entries[encoded_id]; + if (id != 0) page.hyperlink_set.release(page.memory, id); + } } /// The fixed logical dimensions, table counts, and allocation hints at the @@ -684,6 +771,14 @@ test "framed PAGE encode and decode a sparse native page" { .bg_color = .{ .palette = 42 }, }).eql(decoded_style_b.value_ptr.*)); try std.testing.expectEqual(null, style_it.next()); + try std.testing.expectEqual( + @as(u16, 2), + decoded.styles.refCount(decoded.memory, decoded_style_a.id), + ); + try std.testing.expectEqual( + @as(u16, 1), + decoded.styles.refCount(decoded.memory, decoded_style_b.id), + ); var hyperlink_it = decoded.hyperlink_set.iterator(decoded.memory); const decoded_link_a = hyperlink_it.next().?; @@ -847,7 +942,7 @@ test "decode sparse page rejects every truncation" { test "decode accepts unordered sparse style IDs and ignores zero" { const header: Header = .{ - .columns = 1, + .columns = 2, .rows = 1, .style_count = 2, .hyperlink_count = 0, @@ -860,8 +955,7 @@ test "decode accepts unordered sparse style IDs and ignores zero" { var descending: [ Header.len + 2 * (2 + style.len) + - 1 + - 16 + 3 + 2 * 8 + 4 ]u8 = undefined; var descending_writer: std.Io.Writer = .fixed(&descending); try header.encode(&descending_writer); @@ -869,8 +963,17 @@ test "decode accepts unordered sparse style IDs and ignores zero" { try style.encode(.{ .flags = .{ .bold = true } }, &descending_writer); try io.writeInt(&descending_writer, TerminalStyleId, 2); try style.encode(.{ .flags = .{ .italic = true } }, &descending_writer); - try descending_writer.writeByte(0); - try descending_writer.splatByteAll(0, 16); + try descending_writer.writeByte(@bitCast(grid.Row{ .cell_width = .eight })); + try io.writeInt(&descending_writer, u16, 2); // cell count + try io.writeInt(&descending_writer, u64, @bitCast(grid.Cell{ + .content = 'A', + .style_id = 3, + })); + try io.writeInt(&descending_writer, u64, @bitCast(grid.Cell{ + .content = 'B', + .style_id = 2, + })); + try io.writeInt(&descending_writer, u32, 0); // grapheme section var descending_reader: std.Io.Reader = .fixed( descending_writer.buffered(), @@ -881,6 +984,14 @@ test "decode accepts unordered sparse style IDs and ignores zero" { ); defer decoded_descending.deinit(); try std.testing.expectEqual(@as(usize, 2), decoded_descending.styles.count()); + try std.testing.expectEqual( + @as(TerminalStyleId, 1), + decoded_descending.getRowAndCell(0, 0).cell.style_id, + ); + try std.testing.expectEqual( + @as(TerminalStyleId, 2), + decoded_descending.getRowAndCell(1, 0).cell.style_id, + ); const one_header: Header = .{ .columns = 1, @@ -893,7 +1004,7 @@ test "decode accepts unordered sparse style IDs and ignores zero" { .string_capacity_bytes = 0, }; - var zero: [Header.len + 2 + style.len + 17]u8 = undefined; + var zero: [Header.len + 2 + style.len + 7]u8 = undefined; var zero_writer: std.Io.Writer = .fixed(&zero); try one_header.encode(&zero_writer); try io.writeInt(&zero_writer, TerminalStyleId, 0); @@ -914,7 +1025,7 @@ test "decode accepts unordered sparse style IDs and ignores zero" { test "decode accepts unordered sparse hyperlink IDs" { const header: Header = .{ - .columns = 1, + .columns = 2, .rows = 1, .style_count = 0, .hyperlink_count = 2, @@ -936,8 +1047,7 @@ test "decode accepts unordered sparse hyperlink IDs" { var encoded: [ Header.len + 2 * 14 + - 1 + - 16 + 3 + 2 * 8 + 4 ]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); try header.encode(&writer); @@ -945,8 +1055,19 @@ test "decode accepts unordered sparse hyperlink IDs" { try hyperlink.encode(first, &writer); try io.writeInt(&writer, TerminalHyperlinkId, 2); try hyperlink.encode(second, &writer); - try writer.writeByte(0); - try writer.splatByteAll(0, 16); + try writer.writeByte(@bitCast(grid.Row{ .cell_width = .eight })); + try io.writeInt(&writer, u16, 2); // cell count + try io.writeInt(&writer, u64, @bitCast(grid.Cell{ + .content = 'A', + .hyperlink = true, + .hyperlink_id = 3, + })); + try io.writeInt(&writer, u64, @bitCast(grid.Cell{ + .content = 'B', + .hyperlink = true, + .hyperlink_id = 2, + })); + try io.writeInt(&writer, u32, 0); // grapheme section var reader: std.Io.Reader = .fixed(writer.buffered()); var decoded = try decodePayload( @@ -958,6 +1079,34 @@ test "decode accepts unordered sparse hyperlink IDs" { @as(usize, 2), decoded.hyperlink_set.count(), ); + const first_id = decoded.lookupHyperlink( + decoded.getRowAndCell(0, 0).cell, + ).?; + const second_id = decoded.lookupHyperlink( + decoded.getRowAndCell(1, 0).cell, + ).?; + try std.testing.expectEqual( + @as(u16, 1), + decoded.hyperlink_set.refCount(decoded.memory, first_id), + ); + try std.testing.expectEqual( + @as(u16, 1), + decoded.hyperlink_set.refCount(decoded.memory, second_id), + ); + try std.testing.expectEqualStrings( + "one", + decoded.hyperlink_set.get( + decoded.memory, + first_id, + ).uri.slice(decoded.memory), + ); + try std.testing.expectEqualStrings( + "two", + decoded.hyperlink_set.get( + decoded.memory, + second_id, + ).uri.slice(decoded.memory), + ); } test "decode defaults missing sparse cell references" { @@ -973,15 +1122,15 @@ test "decode defaults missing sparse cell references" { .string_capacity_bytes = 0, }; - var style_encoded: [Header.len + 1 + 16]u8 = undefined; + var style_encoded: [Header.len + 3 + 8 + 4]u8 = undefined; var style_writer: std.Io.Writer = .fixed(&style_encoded); try style_header.encode(&style_writer); - try style_writer.writeByte(0); - try style_writer.writeAll(&.{ 0, 0, 0, 0 }); - try io.writeInt(&style_writer, TerminalStyleId, 1); - try io.writeInt(&style_writer, TerminalHyperlinkId, 0); - try io.writeInt(&style_writer, u32, 0); - try io.writeInt(&style_writer, u32, 0); + try style_writer.writeByte(0x30); // row flags, full cell width + try io.writeInt(&style_writer, u16, 1); // cell count + try io.writeInt(&style_writer, u64, @bitCast(grid.Cell{ + .style_id = 1, + })); + try io.writeInt(&style_writer, u32, 0); // grapheme section var style_reader: std.Io.Reader = .fixed(style_writer.buffered()); var style_page = try decodePayload( @@ -1006,15 +1155,16 @@ test "decode defaults missing sparse cell references" { .string_capacity_bytes = 0, }; - var hyperlink_encoded: [Header.len + 1 + 16]u8 = undefined; + var hyperlink_encoded: [Header.len + 3 + 8 + 4]u8 = undefined; var hyperlink_writer: std.Io.Writer = .fixed(&hyperlink_encoded); try hyperlink_header.encode(&hyperlink_writer); - try hyperlink_writer.writeByte(0); - try hyperlink_writer.writeAll(&.{ 0, 0, 0, 0 }); - try io.writeInt(&hyperlink_writer, TerminalStyleId, 0); - try io.writeInt(&hyperlink_writer, TerminalHyperlinkId, 1); - try io.writeInt(&hyperlink_writer, u32, 0); - try io.writeInt(&hyperlink_writer, u32, 0); + try hyperlink_writer.writeByte(0x30); // row flags, full cell width + try io.writeInt(&hyperlink_writer, u16, 1); // cell count + try io.writeInt(&hyperlink_writer, u64, @bitCast(grid.Cell{ + .hyperlink = true, + .hyperlink_id = 1, + })); + try io.writeInt(&hyperlink_writer, u32, 0); // grapheme section var hyperlink_reader: std.Io.Reader = .fixed( hyperlink_writer.buffered(), @@ -1044,41 +1194,42 @@ test "decode normalizes invalid grid semantics" { .string_capacity_bytes = 0, }; - var encoded: [Header.len + 1 + 3 * 16 + 12]u8 = undefined; + var encoded: [Header.len + 3 + 3 * 8 + 4 + 10]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); try header.encode(&writer); // Preserve wrap while degrading the unknown semantic-prompt value to none // and ignoring all reserved row bits. try writer.writeByte(0xFD); + try io.writeInt(&writer, u16, 3); - // An unknown content and width kind, invalid semantic content, reserved - // bytes, and suffix data all degrade to a blank narrow output cell. - try writer.writeAll(&.{ 3, 4, 0xFF, 0xFF }); - try io.writeInt(&writer, TerminalStyleId, 0); - try io.writeInt(&writer, TerminalHyperlinkId, 0); - try io.writeInt(&writer, u32, 0xD800); - try io.writeInt(&writer, u32, 1); - try io.writeInt(&writer, u32, 0x110000); + // A text cell replaces an invalid Unicode scalar with U+FFFD while its + // reserved semantic content degrades to output. + try io.writeInt(&writer, u64, @bitCast(grid.Cell{ + .content = 0xD800, + .semantic_content = 3, + })); - // A recognized text cell replaces an invalid Unicode scalar with U+FFFD. - // Its valid suffix cannot fit the advertised zero capacity, so decoding - // preserves the base character and drops the optional suffix. - try writer.writeAll(&.{ 0, 0, 0, 0 }); - try io.writeInt(&writer, TerminalStyleId, 0); - try io.writeInt(&writer, TerminalHyperlinkId, 0); - try io.writeInt(&writer, u32, 0xD800); - try io.writeInt(&writer, u32, 1); - try io.writeInt(&writer, u32, 'x'); + // A declared grapheme suffix cannot fit the advertised zero capacity, + // so decoding preserves the base character and drops the suffix. + try io.writeInt(&writer, u64, @bitCast(grid.Cell{ + .kind = 1, + .content = 'x', + })); - // Reserved palette bits and a nonsensical suffix do not obscure the valid - // low palette byte; the suffix is consumed and ignored. - try writer.writeAll(&.{ 1, 0, 0, 0xFF }); - try io.writeInt(&writer, TerminalStyleId, 0); - try io.writeInt(&writer, TerminalHyperlinkId, 0); - try io.writeInt(&writer, u32, 0xFFFFFF07); + // Reserved palette content bits do not obscure the valid low palette + // byte. + try io.writeInt(&writer, u64, @bitCast(grid.Cell{ + .kind = 2, + .content = 0xFFFF07, + })); + + // The grapheme section carries the suffix for the second cell. try io.writeInt(&writer, u32, 1); - try io.writeInt(&writer, u32, 'x'); + try io.writeInt(&writer, u16, 0); + try io.writeInt(&writer, u16, 1); + try io.writeInt(&writer, u16, 1); + try io.writeInt(&writer, u32, 0x0301); var reader: std.Io.Reader = .fixed(writer.buffered()); var page = try decodePayload(&reader, std.testing.allocator); @@ -1091,7 +1242,7 @@ test "decode normalizes invalid grid semantics" { terminal_page.Row.SemanticPrompt.none, first.row.semantic_prompt, ); - try std.testing.expectEqual(@as(u21, 0), first.cell.codepoint()); + try std.testing.expectEqual(@as(u21, 0xFFFD), first.cell.codepoint()); try std.testing.expectEqual( terminal_page.Cell.Wide.narrow, first.cell.wide, @@ -1104,7 +1255,7 @@ test "decode normalizes invalid grid semantics" { try std.testing.expect(!first.cell.hasGrapheme()); const second = page.getRowAndCell(1, 0).cell; - try std.testing.expectEqual(@as(u21, 0xFFFD), second.codepoint()); + try std.testing.expectEqual(@as(u21, 'x'), second.codepoint()); try std.testing.expect(!second.hasGrapheme()); const third = page.getRowAndCell(2, 0).cell; @@ -1156,6 +1307,82 @@ test "decode validates dimensions" { } } +test "decode propagates a style table read failure" { + const FailOnceReader = struct { + source: std.Io.Reader, + interface: std.Io.Reader, + bytes_before_failure: usize, + failed: bool = false, + + fn init(bytes: []const u8, bytes_before_failure: usize) @This() { + return .{ + .source = .fixed(bytes), + .interface = .{ + .vtable = &.{ .stream = stream }, + .buffer = &.{}, + .seek = 0, + .end = 0, + }, + .bytes_before_failure = bytes_before_failure, + }; + } + + fn stream( + reader: *std.Io.Reader, + writer: *std.Io.Writer, + limit: std.Io.Limit, + ) std.Io.Reader.StreamError!usize { + const self: *@This() = @fieldParentPtr("interface", reader); + if (self.bytes_before_failure == 0 and !self.failed) { + self.failed = true; + return error.ReadFailed; + } + + const read_limit = if (self.failed) + limit + else + limit.min(.limited(self.bytes_before_failure)); + const n = try self.source.stream(writer, read_limit); + if (!self.failed) self.bytes_before_failure -= n; + return n; + } + }; + + const header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 1, + .hyperlink_count = 0, + .style_capacity = 8, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + + // If the style read error is swallowed, its sixteen zero bytes are then + // misread as a valid empty grid and the unframed payload appears to decode. + var encoded: [Header.len + @sizeOf(TerminalStyleId) + style.len]u8 = + @splat(0); + var writer: std.Io.Writer = .fixed(&encoded); + try header.encode(&writer); + try io.writeInt(&writer, TerminalStyleId, 1); + try writer.splatByteAll(0, style.len); + + var source = FailOnceReader.init( + writer.buffered(), + Header.len + @sizeOf(TerminalStyleId), + ); + var decoded = decodePayload( + &source.interface, + std.testing.allocator, + ) catch |err| { + try std.testing.expectEqual(error.ReadFailed, err); + return; + }; + defer decoded.deinit(); + try std.testing.expect(false); +} + test "decode normalizes duplicate default and invalid style entries" { const header: Header = .{ .columns = 1, @@ -1293,6 +1520,45 @@ test "decode normalizes duplicate default and invalid style entries" { ); } +test "decode releases unused style table references" { + const header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 1, + .hyperlink_count = 0, + .style_capacity = 8, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + + var empty_page = try TerminalPage.init(.{ .cols = 1, .rows = 1 }); + defer empty_page.deinit(); + var grid_bytes: [32]u8 = undefined; + var grid_writer: std.Io.Writer = .fixed(&grid_bytes); + try grid.encode(&empty_page, &grid_writer); + + var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer encoded.deinit(); + try header.encode(&encoded.writer); + try io.writeInt(&encoded.writer, TerminalStyleId, 7); + try style.encode(.{ .flags = .{ .bold = true } }, &encoded.writer); + try encoded.writer.writeAll(grid_writer.buffered()); + + var reader: std.Io.Reader = .fixed(encoded.written()); + var decoded = try decodePayload(&reader, std.testing.allocator); + defer decoded.deinit(); + try std.testing.expectEqual(@as(usize, 0), decoded.styles.count()); + + // The dead table-only entry is absent from the very first re-encode. + var canonical: [64]u8 = undefined; + var canonical_writer: std.Io.Writer = .fixed(&canonical); + try encodePayload(&decoded, &canonical_writer); + var canonical_reader: std.Io.Reader = .fixed(canonical_writer.buffered()); + const canonical_header = try Header.decode(&canonical_reader); + try std.testing.expectEqual(@as(u16, 0), canonical_header.style_count); +} + test "decode reuses duplicate hyperlinks" { const header: Header = .{ .columns = 1, @@ -1374,10 +1640,79 @@ test "decode reuses duplicate hyperlinks" { try std.testing.expect(cell.hyperlink); const id = decoded.lookupHyperlink(cell).?; const entry = decoded.hyperlink_set.get(decoded.memory, id); + try std.testing.expectEqual( + @as(u16, 1), + decoded.hyperlink_set.refCount(decoded.memory, id), + ); try std.testing.expectEqualStrings( "uri", entry.uri.slice(decoded.memory), ); + + // Overwriting the sole linked cell must make the deduplicated entry dead. + // Any surviving reference belongs to the decode-time wire table, not the + // native page. + decoded.clearCells(decoded.getRow(0), 0, 1); + try std.testing.expectEqual(@as(usize, 0), decoded.hyperlink_set.count()); +} + +test "discard consumes exactly one PAGE record" { + const testing = std.testing; + + // Discard validates framing only, so an arbitrary payload keeps this test + // focused on record consumption rather than page structure. + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + var stream: record.Writer = .init(testing.allocator, &encoded.writer); + defer stream.deinit(); + { + const payload = stream.begin(.page); + errdefer stream.cancel(); + try payload.writeAll("undecodable page payload"); + try stream.finish(); + } + const record_len = encoded.written().len; + try encoded.writer.writeAll("next"); + + var source: std.Io.Reader = .fixed(encoded.written()); + try discard(&source); + try testing.expectEqualStrings("next", try source.take(4)); + + // Only PAGE records may be discarded; the tag remains strict. + var wrong_tag: std.Io.Writer.Allocating = .init(testing.allocator); + defer wrong_tag.deinit(); + var wrong_tag_stream: record.Writer = .init( + testing.allocator, + &wrong_tag.writer, + ); + defer wrong_tag_stream.deinit(); + { + const payload = wrong_tag_stream.begin(.history); + errdefer wrong_tag_stream.cancel(); + try payload.writeAll("payload"); + try wrong_tag_stream.finish(); + } + var wrong_tag_source: std.Io.Reader = .fixed(wrong_tag.written()); + try testing.expectError( + error.UnexpectedRecordTag, + discard(&wrong_tag_source), + ); + + // Discarded bytes are still covered by the record CRC. + const corrupted = try testing.allocator.dupe( + u8, + encoded.written()[0..record_len], + ); + defer testing.allocator.free(corrupted); + corrupted[corrupted.len - 1] ^= 1; + var corrupted_source: std.Io.Reader = .fixed(corrupted); + try testing.expectError(error.InvalidChecksum, discard(&corrupted_source)); + + // A truncated payload is detected before `finish`. + var truncated_source: std.Io.Reader = .fixed( + encoded.written()[0 .. record_len - 1], + ); + try testing.expectError(error.EndOfStream, discard(&truncated_source)); } test "decode ignores empty hyperlink strings" { diff --git a/src/terminal/snapshot/record.zig b/src/terminal/snapshot/record.zig index 0126c2bcd..e8dada911 100644 --- a/src/terminal/snapshot/record.zig +++ b/src/terminal/snapshot/record.zig @@ -24,14 +24,10 @@ const Allocator = std.mem.Allocator; const test_fixture = @import("fixture.zig"); const io = @import("io.zig"); -const Blake3 = std.crypto.hash.Blake3; - -/// The running digest shared by snapshot stream codecs and checkpoints. -pub const PrefixDigest = [Blake3.digest_length]u8; - -/// CRC32C as specified by the snapshot format. Zig names this standard -/// parameter set after its iSCSI use. -pub const Crc32c = std.hash.crc.Crc32Iscsi; +/// CRC32C as specified by the snapshot format. This is the parameter set +/// Zig's standard library names after its iSCSI use, backed by dedicated +/// CRC32C instructions where the target has them. +pub const Crc32c = @import("../../crc32c.zig").Crc32c; /// Identifies the layout and meaning of a record payload. /// The current snapshot version rejects every value not listed here. @@ -48,11 +44,14 @@ pub const Tag = enum(u16) { /// One screen's complete history manifest and page sequence. history = 4, - /// Digest marking the validated terminal-state prefix. + /// Marker separating the renderable terminal state from history. ready = 5, - /// Digest validating the complete snapshot blob. + /// Marker terminating the complete snapshot blob. finish = 6, + + /// Canonical unfinished standard TerminalStream input. + continuation = 7, }; /// The fixed framing that precedes every record payload. @@ -151,11 +150,10 @@ pub const Checksum = struct { /// Streams complete records while retaining only one payload at a time. /// -/// All emitted bytes pass through one unbuffered BLAKE3 writer. The scratch -/// allocation is retained between records so a stream's peak memory is the -/// largest record payload rather than the complete snapshot. +/// The scratch allocation is retained between records so a stream's peak +/// memory is the largest record payload rather than the complete snapshot. pub const Writer = struct { - hashing: std.Io.Writer.Hashed(Blake3), + destination: *std.Io.Writer, scratch: std.Io.Writer.Allocating, active_tag: ?Tag, @@ -164,7 +162,7 @@ pub const Writer = struct { destination: *std.Io.Writer, ) Writer { return .{ - .hashing = destination.hashed(Blake3.init(.{}), &.{}), + .destination = destination, .scratch = .init(alloc), .active_tag = null, }; @@ -177,10 +175,10 @@ pub const Writer = struct { self.* = undefined; } - /// Return the digest-updating writer for unframed snapshot bytes. + /// Return the destination writer for unframed snapshot bytes. pub fn writer(self: *Writer) *std.Io.Writer { assert(self.active_tag == null); - return &self.hashing.writer; + return self.destination; } /// Begin one record and return its reusable payload writer. @@ -225,8 +223,8 @@ pub const Writer = struct { var header_writer: std.Io.Writer = .fixed(&header_bytes); header.encode(&header_writer) catch unreachable; - try self.hashing.writer.writeAll(&header_bytes); - try self.hashing.writer.writeAll(payload); + try self.destination.writeAll(&header_bytes); + try self.destination.writeAll(payload); } /// Discard the active record without emitting any bytes. @@ -237,53 +235,6 @@ pub const Writer = struct { self.scratch.shrinkRetainingCapacity(0); self.active_tag = null; } - - /// Finalize the prefix written so far without consuming the hasher. - pub fn prefixDigest(self: *const Writer) PrefixDigest { - // Checkpoints require an exact byte boundary. Writer owns this - // adapter and always constructs it without a buffer. - assert(self.active_tag == null); - assert(self.scratch.writer.end == 0); - assert(self.hashing.writer.buffer.len == 0); - assert(self.hashing.writer.buffered().len == 0); - - var result: PrefixDigest = undefined; - self.hashing.hasher.final(&result); - return result; - } -}; - -/// Hashes snapshot bytes as they are consumed without reading ahead. -pub const StreamReader = struct { - hashing: std.Io.Reader.Hashed(Blake3), - - pub fn init(input: *std.Io.Reader) StreamReader { - return .{ - .hashing = input.hashed(Blake3.init(.{}), &.{}), - }; - } - - /// Return the digest-updating reader used before and through READY. - pub fn reader(self: *StreamReader) *std.Io.Reader { - return &self.hashing.reader; - } - - /// Return the source used to consume FINISH without hashing it. - pub fn source(self: *StreamReader) *std.Io.Reader { - return self.hashing.in; - } - - /// Finalize the prefix consumed so far without consuming the hasher. - pub fn prefixDigest(self: *const StreamReader) PrefixDigest { - // A nonempty adapter buffer could contain bytes beyond a checkpoint. - // Construction is private to this type and fixes its capacity at zero. - assert(self.hashing.reader.buffer.len == 0); - assert(self.hashing.reader.bufferedLen() == 0); - - var result: PrefixDigest = undefined; - self.hashing.hasher.final(&result); - return result; - } }; /// Reads one complete record. @@ -293,13 +244,14 @@ pub const StreamReader = struct { pub const Reader = struct { header: Header, - // The limited reader normally streams directly into the hashing reader's + // The limited reader normally streams directly into the checksum reader's // buffer. One byte is enough for operations that require it to buffer, // such as peek and discard. limited_buffer: [1]u8, - // PAGE decoding performs many small reads. 256 bytes batches several cells - // while CRC32C is calculated without making Reader large on the stack. + // Fixed-header decoding performs several small reads. 256 bytes batches + // them while CRC32C is calculated without making Reader large on the + // stack. Bulk payload reads bypass this buffer entirely. hashing_buffer: [256]u8, limited: std.Io.Reader.Limited, @@ -408,7 +360,7 @@ test "golden PAGE record header and checksum" { } test "reject invalid tags" { - for ([_]u16{ 0, 7, std.math.maxInt(u16) }) |tag| { + for ([_]u16{ 0, 8, std.math.maxInt(u16) }) |tag| { var fixture = [_]u8{0} ** Header.len; std.mem.writeInt(u16, fixture[0..2], tag, .little); var reader: std.Io.Reader = .fixed(&fixture); diff --git a/src/terminal/snapshot/screen.zig b/src/terminal/snapshot/screen.zig index 875d2bb0e..d4ef16f73 100644 --- a/src/terminal/snapshot/screen.zig +++ b/src/terminal/snapshot/screen.zig @@ -205,6 +205,7 @@ const Allocator = std.mem.Allocator; const hyperlink = @import("hyperlink.zig"); const test_fixture = @import("fixture.zig"); const io = @import("io.zig"); +const grid = @import("grid.zig"); const page = @import("page.zig"); const record = @import("record.zig"); const style = @import("style.zig"); @@ -232,7 +233,7 @@ const PayloadEncodeError = hyperlink.EncodeError || error{ }; /// Errors possible while encoding a SCREEN and its complete PAGE sequence. -pub const EncodeError = PayloadEncodeError || page.EncodeError || error{ +pub const EncodeError = Allocator.Error || PayloadEncodeError || page.EncodeError || error{ /// The active area spans more pages than the SCREEN header can declare. PageCountOverflow, }; @@ -241,7 +242,7 @@ pub const EncodeError = PayloadEncodeError || page.EncodeError || error{ /// /// The suffix begins with the page containing the active area's first row and /// ends with the newest page. Completed records may already be emitted if a -/// later record fails; the missing READY checkpoint makes that prefix invalid. +/// later record fails; the missing READY marker makes that prefix invalid. pub fn encode( screen: *const TerminalScreen, key: TerminalScreenKey, @@ -272,13 +273,14 @@ pub fn encode( try destination.finish(); } - // PageList never compresses the active-boundary page or any later page. - // Encoding this resident suffix therefore does not restore cold history or - // otherwise mutate the source screen. + // Active pages are resident today, but use the representation-safe access + // path so a future PageList compression-policy change cannot turn this + // wire encoder's optimization invariant into undefined behavior. node = first; while (node) |current| : (node = current.next) { - std.debug.assert(current.pageIfResident() != null); - try page.encode(current.pageAssumeResident(), destination); + var preserved = try current.pagePreservingState(screen.alloc); + defer preserved.deinit(); + try page.encode(preserved.page(), destination); } } @@ -410,7 +412,7 @@ pub fn decode( .y = y, .cursor_style = header.cursor_style, .pending_wrap = header.cursor_flags.pending_wrap and - x == options.cols - 1, + x == row_pin.node.cols() - 1, .protected = header.cursor_flags.protected, .style = header.cursor_pen, .hyperlink_implicit_id = header.hyperlink_implicit_id, @@ -433,7 +435,7 @@ pub fn decode( options.max_scrollback_bytes == 0, .cursor = cursor, .saved_cursor = if (saved_cursor) |value| - value.terminal() + value.terminal(options.cols, options.rows) else null, .charset = header.charset, @@ -503,7 +505,7 @@ pub fn decode( alloc, &result, options.kitty_image_storage_limit, - ) catch unreachable; + ); result.kitty_images.image_limits = options.kitty_image_loading_limits; } @@ -811,13 +813,18 @@ pub const SavedCursor = struct { }; } - fn terminal(self: SavedCursor) TerminalScreen.SavedCursor { + fn terminal( + self: SavedCursor, + cols: u16, + rows: u16, + ) TerminalScreen.SavedCursor { + const x = @min(self.x, cols - 1); return .{ - .x = self.x, - .y = self.y, + .x = x, + .y = @min(self.y, rows - 1), .style = self.pen, .protected = self.flags.protected, - .pending_wrap = self.flags.pending_wrap, + .pending_wrap = self.flags.pending_wrap and x == cols - 1, .origin = self.flags.origin, .charset = self.charset, }; @@ -845,7 +852,7 @@ pub const SavedCursor = struct { return .{ .x = try io.readInt(reader, u16), .y = try io.readInt(reader, u16), - .pen = style.decodeOrDiscard(reader) catch .{}, + .pen = (try style.decodeOrNull(reader)) orelse .{}, .flags = try Flags.decode(reader), .charset = decodeCharsetState( try io.readInt(reader, u16), @@ -1006,7 +1013,8 @@ pub const Header = struct { try reader.takeByte(), ) orelse .block; const cursor_flags = try CursorFlags.decode(reader); - const cursor_pen: TerminalStyle = style.decodeOrDiscard(reader) catch .{}; + const cursor_pen: TerminalStyle = + (try style.decodeOrNull(reader)) orelse .{}; const hyperlink_implicit_id = try io.readInt(reader, u32); // Charset and selective-erase state. @@ -1781,8 +1789,8 @@ test "framed native SCREEN and PAGE sequence" { } const restored_saved = restored.saved_cursor.?; - try std.testing.expectEqual(@as(u16, 0x0102), restored_saved.x); - try std.testing.expectEqual(@as(u16, 0x0304), restored_saved.y); + try std.testing.expectEqual(@as(u16, 7), restored_saved.x); + try std.testing.expectEqual(@as(u16, 7), restored_saved.y); try std.testing.expect(restored_saved.protected); try std.testing.expect(restored_saved.pending_wrap); try std.testing.expect(restored_saved.origin); @@ -1962,6 +1970,62 @@ test "SCREEN encodes the minimal complete-page active suffix" { try std.testing.expectError(error.EndOfStream, restore_source.takeByte()); } +test "SCREEN encoding preserves a compressed suffix page" { + const testing = std.testing; + const compression = @import("../compress.zig"); + + var screen = try TerminalScreen.init( + testing.io, + testing.allocator, + .{ .cols = 8, .rows = 2, .max_scrollback_bytes = 0 }, + ); + defer screen.deinit(); + screen.pages.getCell(.{ .active = .{} }).?.cell.* = .init('A'); + + // Force the active suffix into the representation that current PageList + // policy normally reserves for history. This models a future policy change + // and makes pageAssumeResident an invalid tagged-union access. + const node = screen.pages.getTopLeft(.active).node; + const resident = node.pageAssumeResident(); + const scratch = try testing.allocator.alloc( + u8, + try compression.Page.requiredScratch(resident.memory.len), + ); + defer testing.allocator.free(scratch); + var table: compression.lz4.HashTable = undefined; + const compressed = (try compression.Page.init( + testing.allocator, + resident, + scratch, + &table, + )).?; + node.data = .{ .compressed = compressed }; + try testing.expectEqual(.compressed, node.storage()); + + var destination: std.Io.Writer.Allocating = .init(testing.allocator); + defer destination.deinit(); + var stream: record.Writer = .init(testing.allocator, &destination.writer); + defer stream.deinit(); + try encode(&screen, .primary, &stream); + + // Encoding borrows or clones through PreservedPage and never changes the + // source node's storage representation. + try testing.expectEqual(.compressed, node.storage()); + + var source: std.Io.Reader = .fixed(destination.written()); + var decoded = try decode( + &source, + testing.io, + testing.allocator, + .{ .cols = 8, .rows = 2, .max_scrollback_bytes = 0 }, + ); + defer decoded.deinit(); + try testing.expectEqual( + @as(u21, 'A'), + decoded.screen.pages.getCell(.{ .active = .{} }).?.cell.codepoint(), + ); +} + test "SCREEN restoration normalizes invalid cursor positions" { var screen = try TerminalScreen.init( std.testing.io, @@ -2047,6 +2111,109 @@ test "SCREEN restoration normalizes invalid cursor positions" { } } +test "SCREEN validates pending wrap against a mixed-width cursor page" { + var screen = try TerminalScreen.init( + std.testing.io, + std.testing.allocator, + .{ .cols = 8, .rows = 1, .max_scrollback_bytes = 0 }, + ); + defer screen.deinit(); + + // Model a lazily reflowed active page that is narrower than the terminal. + // Column three is its physical final column even though it is not column + // seven of the current terminal dimensions. + var narrow_page = try terminal_page.Page.init(.{ .cols = 4, .rows = 1 }); + defer narrow_page.deinit(); + + var destination: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer destination.deinit(); + var stream: record.Writer = .init( + std.testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + + var header = Header.init(&screen, .primary, 1); + header.cursor_x = 3; + header.cursor_y = 0; + header.cursor_flags.pending_wrap = true; + header.saved_cursor_present = false; + + const screen_payload = stream.begin(.screen); + errdefer stream.cancel(); + try header.encode(screen_payload); + try screen_payload.writeByte(0); + try stream.finish(); + try page.encode(&narrow_page, &stream); + + var source: std.Io.Reader = .fixed(destination.written()); + var decoded = try decode( + &source, + std.testing.io, + std.testing.allocator, + .{ .cols = 8, .rows = 1, .max_scrollback_bytes = 0 }, + ); + defer decoded.deinit(); + + try std.testing.expectEqual(@as(u16, 4), decoded.screen.cursor.page_pin.node.cols()); + try std.testing.expectEqual(@as(u16, 3), decoded.screen.cursor.x); + try std.testing.expect(decoded.screen.cursor.pending_wrap); + decoded.screen.assertIntegrity(); +} + +test "SCREEN clamps a decoded saved cursor to terminal dimensions" { + var screen = try TerminalScreen.init( + std.testing.io, + std.testing.allocator, + .{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 }, + ); + defer screen.deinit(); + + var destination: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer destination.deinit(); + var stream: record.Writer = .init( + std.testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + + var header = Header.init(&screen, .primary, 1); + header.saved_cursor_present = true; + const screen_payload = stream.begin(.screen); + try header.encode(screen_payload); + try (SavedCursor{ + .x = std.math.maxInt(u16), + .y = std.math.maxInt(u16), + .pen = .{}, + .flags = .{ .pending_wrap = true }, + .charset = .{}, + }).encode(screen_payload); + try screen_payload.writeByte(0); + try stream.finish(); + try page.encode( + screen.pages.getTopLeft(.active).node.pageAssumeResident(), + &stream, + ); + + var source: std.Io.Reader = .fixed(destination.written()); + var decoded = try decode( + &source, + std.testing.io, + std.testing.allocator, + .{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 }, + ); + defer decoded.deinit(); + + const saved = decoded.screen.saved_cursor.?; + try std.testing.expectEqual(@as(u16, 1), saved.x); + try std.testing.expectEqual(@as(u16, 1), saved.y); + try std.testing.expect(saved.pending_wrap); +} + test "SCREEN restoration rejects invalid and incomplete sequences" { var screen = try TerminalScreen.init( std.testing.io, @@ -2261,20 +2428,14 @@ test "SCREEN decode ignores a PAGE with an empty hyperlink URI" { // One narrow codepoint cell refers to the hyperlink table entry above. // Since that entry is ignored, the cell must restore without a hyperlink. - try page_payload.writeByte(0); - try page_payload.writeAll(&.{ 0, 0, 0, 0 }); - try io.writeInt( - page_payload, - terminal_style.Id, - 0, - ); - try io.writeInt( - page_payload, - terminal_hyperlink.Id, - 1, - ); - try io.writeInt(page_payload, u32, 'A'); - try io.writeInt(page_payload, u32, 0); + try page_payload.writeByte(0x30); // row flags, full cell width + try io.writeInt(page_payload, u16, 1); // cell count + try io.writeInt(page_payload, u64, @bitCast(grid.Cell{ + .content = 'A', + .hyperlink = true, + .hyperlink_id = 1, + })); + try io.writeInt(page_payload, u32, 0); // grapheme section try stream.finish(); var source: std.Io.Reader = .fixed(destination.written()); diff --git a/src/terminal/snapshot/snapshot.ksy b/src/terminal/snapshot/snapshot.ksy index ad8f6c3a0..80a1d182f 100644 --- a/src/terminal/snapshot/snapshot.ksy +++ b/src/terminal/snapshot/snapshot.ksy @@ -8,15 +8,16 @@ doc: | Ghostty terminal snapshot format version 1. A complete snapshot contains an envelope, terminal-wide state, one or two - renderable screen sequences, a READY checkpoint, matching history sequences, - and a FINISH checkpoint. SCREEN pages are oldest-to-newest. HISTORY pages are - newest-to-oldest. FINISH terminates the snapshot; bytes that follow belong to - the containing transport and are outside this schema. Each SCREEN declares - its complete logical history extent before READY. + renderable screen sequences, one raw standard-Stream CONTINUATION, a READY + marker, matching history sequences, and a FINISH marker. SCREEN pages + are oldest-to-newest. HISTORY pages are newest-to-oldest. FINISH terminates the + snapshot; bytes that follow belong to the containing transport and are outside + this schema. Each SCREEN declares its complete logical history extent before + READY. - Record CRC32C values and checkpoint BLAKE3-256 digests are represented here - but cannot be calculated by portable Kaitai Struct expressions. The adjacent - verify-kaitai.py script validates those values after parsing. + Record CRC32C values are represented here but cannot be calculated by + portable Kaitai Struct expressions. The adjacent verify-kaitai.py script + validates those values after parsing. seq: - id: envelope type: envelope @@ -26,6 +27,8 @@ seq: type: screen_sequence repeat: expr repeat-expr: terminal.payload.header.screen_count + - id: continuation + type: continuation_record - id: ready type: checkpoint_record(5) - id: histories @@ -42,6 +45,7 @@ enums: 4: history 5: ready 6: finish + 7: continuation screen_key: 0: primary 1: alternate @@ -202,7 +206,18 @@ types: type: history_payload size: header.payload_length + continuation_record: + doc: | + Raw canonical standard TerminalStream continuation bytes. An empty + payload explicitly represents ground state. + seq: + - id: header + type: record_header(7) + - id: payload + size: header.payload_length + checkpoint_record: + doc: READY and FINISH are empty marker records. params: - id: expected_tag type: u2 @@ -210,8 +225,9 @@ types: - id: header type: record_header(expected_tag) - id: payload - type: checkpoint_payload size: header.payload_length + valid: + expr: _.size == 0 screen_sequence: doc: SCREEN followed by its declared PAGE records, oldest-to-newest. @@ -233,15 +249,6 @@ types: repeat: expr repeat-expr: history.payload.page_count - checkpoint_payload: - seq: - - id: prefix_digest - size: 32 - - id: trailing_data - size-eos: true - valid: - expr: _.size == 0 - terminal_payload: seq: - id: header @@ -900,25 +907,41 @@ types: type: grid_row(columns) repeat: expr repeat-expr: num_rows + - id: num_grapheme_entries + type: u4 + - id: grapheme_entries + type: grapheme_entry(num_rows, columns) + repeat: expr + repeat-expr: num_grapheme_entries grid_row: params: - - id: num_cells + - id: columns type: u2 seq: - id: flags type: grid_row_flags + - id: cell_count + type: u2 + valid: + expr: _ <= columns - id: cells - type: grid_cell(_index, num_cells, flags.wrap) + type: + switch-on: flags.width_log2 + cases: + 0: grid_cell_1 + 1: grid_cell_2 + 2: grid_cell_4 + 3: grid_cell(_index, columns, flags.wrap) repeat: expr - repeat-expr: num_cells + repeat-expr: cell_count grid_row_flags: seq: - id: raw type: u1 valid: - expr: (_ & 0xf0) == 0 and ((_ >> 2) & 0x3) <= 2 + expr: (_ & 0xc0) == 0 and ((_ >> 2) & 0x3) <= 2 instances: wrap: value: (raw & 1) != 0 @@ -926,8 +949,59 @@ types: value: (raw & 2) != 0 semantic_prompt: value: (raw >> 2) & 0x3 + width_log2: + value: (raw >> 4) & 0x3 + + grid_cell_1: + doc: One-byte encoded cell; the value is a codepoint at or below U+00FF. + seq: + - id: codepoint + type: u1 + + grid_cell_2: + doc: | + Two-byte encoded cell; the value is a codepoint at or below U+FFFF. + Canonical encoders never emit surrogates. + seq: + - id: codepoint + type: u2 + valid: + expr: not (_ >= 0xd800 and _ <= 0xdfff) + + grid_cell_4: + doc: | + Four-byte encoded cell holding the low half of the cell word: any + content kind and codepoint, style IDs one through sixty-three, and no + width, flag, or hyperlink bits. + seq: + - id: raw + type: u4 + valid: + expr: | + (content_kind >= 2 or + (content <= 0x10ffff and + not (content >= 0xd800 and content <= 0xdfff))) and + (content_kind != 2 or content <= 0xff) + instances: + content_kind: + value: raw % 4 + content: + value: (raw / 4) % 16777216 + style_id: + value: raw / 67108864 grid_cell: + doc: | + One 64-bit little-endian cell word. The word is parsed as two 32-bit + halves so every derived field stays within JavaScript's safe integer + range, following the same approach as mode_set. + + Canonical rules validated here: reserved semantic content is not + emitted, the hyperlink flag matches a nonzero hyperlink ID, codepoint + content is a Unicode scalar value, palette content uses only its low + eight bits, and spacer relationships match the preceding cell. Wide + markers cannot be validated forward because their spacer tail may be + the next cell or the implicit narrow cell after a short row. params: - id: index type: u2 @@ -936,59 +1010,62 @@ types: - id: row_wrap type: bool seq: - - id: content_kind - type: u1 - valid: - max: 2 - - id: width - type: u1 - valid: - expr: | - _ <= 3 and - (_ != 2 or - (index > 0 and _parent.cells[index - 1].width == 1)) and - (_ != 3 or - (index + 1 == columns and row_wrap)) - - id: flags - type: grid_cell_flags - - id: reserved - type: u1 - valid: 0 - - id: style_id - type: u2 - - id: hyperlink_id - type: u2 - - id: value + - id: lo + type: u4 + - id: hi type: u4 valid: expr: | - content_kind == 0 ? - (_ <= 0x10ffff and not (_ >= 0xd800 and _ <= 0xdfff) and _ != 0x10eeee) : - content_kind == 1 ? - _ <= 0xff : - _ <= 0xffffff - - id: num_graphemes - type: u4 + semantic_content <= 2 and + hyperlink == (hyperlink_id != 0) and + (content_kind >= 2 or + (content <= 0x10ffff and + not (content >= 0xd800 and content <= 0xdfff))) and + (content_kind != 2 or content <= 0xff) and + (width != 2 or + (index > 0 and + _parent.cells[index - 1].as.width == 1)) and + (width != 3 or (index + 1 == columns and row_wrap)) + instances: + content_kind: + value: lo % 4 + content: + value: (lo / 4) % 16777216 + style_id: + value: (lo / 67108864) + (hi % 1024) * 64 + width: + value: (hi / 1024) % 4 + protected: + value: (hi / 4096) % 2 != 0 + hyperlink: + value: (hi / 8192) % 2 != 0 + semantic_content: + value: (hi / 16384) % 4 + hyperlink_id: + value: hi / 65536 + + grapheme_entry: + seq: + - id: row + type: u2 valid: - expr: | - content_kind == 0 ? - (_ == 0 or value != 0) : - _ == 0 - - id: graphemes + expr: _ < num_rows + - id: col + type: u2 + valid: + expr: _ < columns + - id: num_codepoints + type: u2 + valid: + min: 1 + - id: codepoints type: u4 repeat: expr - repeat-expr: num_graphemes + repeat-expr: num_codepoints valid: - expr: _ <= 0x10ffff and not (_ >= 0xd800 and _ <= 0xdfff) and _ != 0x10eeee - - grid_cell_flags: - seq: - - id: raw - type: u1 - valid: - expr: (_ & 0xf8) == 0 and ((_ >> 1) & 0x3) <= 2 - instances: - protected: - value: (raw & (1 << 0)) != 0 - semantic_content: - value: (raw >> 1) & 0x3 + expr: _ <= 0x10ffff and not (_ >= 0xd800 and _ <= 0xdfff) + params: + - id: num_rows + type: u2 + - id: columns + type: u2 diff --git a/src/terminal/snapshot/snapshot.zig b/src/terminal/snapshot/snapshot.zig index ac76df44f..b2dfbe157 100644 --- a/src/terminal/snapshot/snapshot.zig +++ b/src/terminal/snapshot/snapshot.zig @@ -2,41 +2,54 @@ const std = @import("std"); const build_options = @import("terminal_options"); +const assert = std.debug.assert; const Allocator = std.mem.Allocator; const checkpoint = @import("checkpoint.zig"); +const continuation = @import("continuation.zig"); const envelope = @import("envelope.zig"); const test_fixture = @import("fixture.zig"); const history = @import("history.zig"); +const page = @import("page.zig"); const record = @import("record.zig"); const screen = @import("screen.zig"); +const size = @import("../size.zig"); const terminal = @import("terminal.zig"); const Terminal = @import("../Terminal.zig"); +const TerminalStream = @import("../stream_terminal.zig").Stream; const terminal_kitty = @import("../kitty.zig"); const TerminalPageList = @import("../PageList.zig"); const TerminalScreen = @import("../Screen.zig"); const TerminalScreenKey = @import("../ScreenSet.zig").Key; -const test_complete_fixture = test_fixture.parse( - @embedFile("testdata/complete-v1.hex"), -); +/// Re-export continuation to make it a bit more ergonomic to reference. +pub const Continuation = continuation.Value; /// Errors possible while encoding one complete terminal snapshot. pub const EncodeError = terminal.EncodeError || screen.EncodeError || history.EncodeError || - checkpoint.EncodeError; + checkpoint.EncodeError || + continuation.EncodeError; + +pub const EncodeOptions = struct { + continuation: Continuation, +}; /// Encode one complete terminal snapshot. /// /// Encoding starts at the destination's current position. Only one record /// payload is buffered at a time; completed records stream immediately. On /// failure, the destination may contain a snapshot prefix without its required -/// checkpoints, and an output failure may have written part of a record. +/// FINISH marker, and an output failure may have written part of a record. pub fn encode( alloc: Allocator, destination: *std.Io.Writer, t: *const Terminal, + options: EncodeOptions, ) EncodeError!void { + // Continuation errors must not emit even the snapshot envelope. + try continuation.validate(options.continuation); + var stream: record.Writer = .init(alloc, destination); defer stream.deinit(); @@ -58,11 +71,13 @@ pub fn encode( &stream, ); - // 4. Ready checkpoint. In the future we'll put our continuation - // state before this so pty bytes can also flow. + // 4. Standard Stream continuation. + try continuation.encode(options.continuation, &stream); + + // 5. Ready marker. try checkpoint.encode(.ready, &stream); - // 5. History + // 6. History try history.encode( t.screens.get(.primary).?, .primary, @@ -74,148 +89,523 @@ pub fn encode( &stream, ); - // 6. Finish + // 7. Finish try checkpoint.encode(.finish, &stream); } /// Errors possible while restoring one complete terminal snapshot. -pub const DecodeError = envelope.DecodeError || - terminal.DecodeError || - screen.DecodeError || - history.DecodeError || - checkpoint.DecodeError || - error{ - /// A SCREEN names a key not declared by TERMINAL. - UnexpectedScreenKey, +pub const DecodeError = Decoder.ReadyError || Decoder.NextError; - /// More than one SCREEN names the same key. - DuplicateScreen, +pub const DecodeOptions = struct { + /// Largest non-ground continuation the decoder may allocate and return. + /// Set this to zero when only ground-state snapshots are acceptable. + max_continuation_bytes: usize, +}; - /// A HISTORY names a key not declared by TERMINAL. - UnexpectedHistoryKey, +/// One decoded Terminal and the bytes needed to resume its Stream. +/// +/// `decode` returns this with all history present; `Decoder.ready` returns +/// it at READY, when the terminal is complete for interactive use but its +/// scrollback is still arriving through `Decoder.next`. +/// +/// A successful decode owns both values. Keep this result alive until the +/// Terminal's Stream has been restored, and always finish by calling `deinit`. +/// The usual restoration sequence is: +/// +/// 1. Inspect `continuation` and use its length to size continuation tracking. +/// 2. Call `toOwned` once and store the returned Terminal at its final address. +/// 3. Create the persistent, read-only standard TerminalStream for that +/// address. If `continuation` contains bytes, feed them exactly once and +/// verify that re-exporting the continuation returns the same bytes. +/// 4. Call `deinit` to release the decoded continuation. The transferred +/// Terminal and its Stream remain owned by the caller. +/// 5. Process PTY bytes that came after the snapshot cut. +/// +/// Do not create a Stream against the address of `terminal` in this struct. +/// `toOwned` moves the Terminal, which would leave such a Stream pointing at +/// its old address. +pub const Decoded = struct { + /// Present until `toOwned` transfers the Terminal to the caller. Callers + /// may inspect it, but must transfer it before attaching a persistent + /// TerminalStream. + terminal: ?Terminal, - /// More than one HISTORY names the same key. - DuplicateHistory, + /// For decoded results, nonempty bytes are allocator-owned and remain + /// valid until `deinit`. Ground needs no replay. Bytes must be replayed + /// exactly once after the Terminal has reached its final address. + continuation: Continuation, + + /// Advisory complete logical history extent declared by each restored + /// screen, including the resident overlap already carried by that + /// screen's own pages. A client may size scrollback UI from this at + /// READY, before HISTORY pages arrive; native row totals always remain + /// derived from the pages actually applied. + history_rows: std.EnumMap(TerminalScreenKey, u64), + + /// Destroy an untransferred Terminal and always free continuation bytes. + /// After `toOwned`, this leaves the caller-owned Terminal untouched. + pub fn deinit(self: *Decoded, alloc: Allocator) void { + if (self.terminal) |*value| value.deinit(alloc); + switch (self.continuation) { + .ground => {}, + .bytes => |bytes| alloc.free(bytes), + } + self.* = undefined; + } + + /// Transfer the Terminal while retaining the continuation for replay. + /// + /// This may be called exactly once. Store the returned value directly at + /// its final address before creating the TerminalStream that will replay + /// `continuation`. + pub fn toOwned(self: *Decoded) Terminal { + const result = self.terminal.?; + self.terminal = null; + return result; + } +}; + +/// Incrementally restore one snapshot: renderable state first, then history. +/// +/// The snapshot record order makes a terminal renderable at the READY +/// marker, after only a small prefix of the stream. `Decoder` exposes +/// that boundary: `ready` returns a complete, integrity-checked `Decoded` while +/// scrollback is still in flight, and each `next` call then applies one +/// history PAGE record off the critical path. `snapshot.decode` is the +/// one-shot form and is implemented over this type. +/// +/// ```zig +/// var decoder: snapshot.Decoder = .init(&reader); +/// var decoded = try decoder.ready(alloc, io, .{ +/// .max_continuation_bytes = 1024 * 1024, +/// }); +/// defer decoded.deinit(alloc); +/// +/// var terminal = decoded.toOwned(); +/// defer terminal.deinit(alloc); +/// // The terminal is usable now: restore its Stream from the decoded +/// // continuation and size scrollback UI from `decoded.history_rows`. +/// +/// while (try decoder.next(alloc, &terminal)) |progress| { +/// _ = progress; // Scrollback grew; e.g. refresh the scrollbar. +/// } +/// // FINISH validated: the complete snapshot has been decoded. +/// ``` +/// +/// The terminal is live between `next` calls: the caller may render it and +/// even feed it PTY bytes that arrived after the snapshot cut. `next` +/// re-validates its destination on every call and degrades to discarding, +/// consuming and validating wire bytes without applying them, when live +/// changes have made a page inapplicable. See `next` for the drop rules. +/// +/// The decoder owns no allocations and needs no deinit. It retains the +/// source pointer given to `init`; the source must stay valid, and must not +/// be read elsewhere, until decoding ends. The decoder itself is movable +/// between calls. After any returned error both the decoder and the stream +/// position are invalid: abandon the decoder, and destroy an already +/// transferred Terminal or keep it with partial history, but do not resume. +pub const Decoder = struct { + source: *std.Io.Reader, + state: State, + + const State = union(enum) { + /// `ready` has not been called. + start, + + /// READY validated; HISTORY sequences remain before FINISH. + history: History, + + /// FINISH validated. `next` returns null. + finished, + + /// A call returned an error; the stream position is unknown. + failed, }; -/// Restore one complete snapshot into a native terminal. + const History = struct { + /// The generation observed at READY for every screen the snapshot + /// declared. Presence is the declaration check for HISTORY routing; + /// the value detects screens removed or replaced by live terminal + /// use between calls. + generations: std.EnumMap(TerminalScreenKey, usize), + + /// Keys whose HISTORY sequence was already consumed. + routed: std.EnumSet(TerminalScreenKey), + + /// HISTORY sequences that have not begun streaming yet. + pending: usize, + + /// The sequence currently streaming PAGE records. + current: ?Sequence, + + /// Terminal width at READY. Encoded pages assume this width and are + /// dropped rather than mixed into a screen reflowed to another. + cols: size.CellCountInt, + }; + + const Sequence = struct { + key: TerminalScreenKey, + + /// PAGE records not yet consumed from this sequence. + remaining: u32, + + /// False once any page in this sequence was dropped. Later pages + /// are older, and applying one above a gap would corrupt scrollback + /// order, so a single drop discards the remainder of the sequence. + apply: bool, + }; + + /// The decoder begins reading only when `ready` is called. + pub fn init(source: *std.Io.Reader) Decoder { + return .{ + .source = source, + .state = .start, + }; + } + + /// Errors possible while decoding the renderable prefix through READY. + pub const ReadyError = envelope.DecodeError || + terminal.DecodeError || + screen.DecodeError || + continuation.DecodeError || + checkpoint.DecodeError || + error{ + /// A SCREEN names a key not declared by TERMINAL. + UnexpectedScreenKey, + + /// More than one SCREEN names the same key. + DuplicateScreen, + }; + + /// Decode through the READY marker and return the renderable state. + /// + /// The result is transactional and complete for interactive use: the + /// Terminal, its continuation, and the advisory history extents are all + /// validated through READY. Scrollback history is not present yet and + /// arrives through `next`. Asserts this is the first call. + pub fn ready( + self: *Decoder, + alloc: Allocator, + io_: std.Io, + options: DecodeOptions, + ) ReadyError!Decoded { + assert(self.state == .start); + errdefer self.state = .failed; + const reader = self.source; + + // Read the envelope, which is currently just a verification step. + try envelope.decode(reader); + + // TERMINAL establishes terminal-wide state and allocates empty + // screen slots with their final routing. SCREEN values replace those + // slots in place so ScreenSet pointers, including the active + // pointer, stay valid. + var result = try terminal.decode(reader, io_, alloc); + errdefer result.deinit(alloc); + + // TERMINAL initializes exactly the number of screen slots it + // declared. Decode that many SCREEN sequences and route each one by + // its encoded key. + const screen_count = result.screens.all.count(); + const screen_options: TerminalScreen.Options = options: { + const primary = result.screens.get(.primary).?; + const explicit_bytes = primary.pages.limits.bytes.explicit; + const explicit_lines = primary.pages.limits.lines.explicit; + break :options .{ + .cols = result.cols, + .rows = result.rows, + .max_scrollback_bytes = if (explicit_bytes == std.math.maxInt(usize)) + null + else + explicit_bytes, + .max_scrollback_lines = if (explicit_lines == std.math.maxInt(usize)) + null + else + explicit_lines, + }; + }; + + var routed: std.EnumSet(TerminalScreenKey) = .initEmpty(); + var history_rows: std.EnumMap(TerminalScreenKey, u64) = .init(.{}); + for (0..screen_count) |_| { + var decoded = try screen.decode( + reader, + io_, + alloc, + screen_options, + ); + errdefer decoded.deinit(); + + const slot = result.screens.get(decoded.key) orelse + return error.UnexpectedScreenKey; + if (routed.contains(decoded.key)) return error.DuplicateScreen; + routed.insert(decoded.key); + history_rows.put(decoded.key, decoded.history_rows); + + slot.deinit(); + slot.* = decoded.screen; + decoded.screen = undefined; + } + + // CONTINUATION is required after every active screen. Nonempty bytes + // are owned locally until the READY prefix validates. + const decoded_continuation = try continuation.decode( + alloc, + reader, + options.max_continuation_bytes, + ); + errdefer switch (decoded_continuation) { + .ground => {}, + .bytes => |bytes| alloc.free(bytes), + }; + + // READY marks the exact envelope-through-CONTINUATION boundary. + try checkpoint.decode(.ready, self.source); + + // Record where history may be applied. The declared keys route the + // HISTORY sequences, and the generations detect declared screens + // that live terminal use destroys or replaces before their history + // arrives. + var generations: std.EnumMap(TerminalScreenKey, usize) = .init(.{}); + var it = result.screens.all.iterator(); + while (it.next()) |entry| generations.put( + entry.key, + result.screens.generation(entry.key), + ); + + self.state = .{ .history = .{ + .generations = generations, + .routed = .initEmpty(), + .pending = screen_count, + .current = null, + .cols = result.cols, + } }; + return .{ + .terminal = result, + .continuation = decoded_continuation, + .history_rows = history_rows, + }; + } + + /// One history PAGE record consumed by `next`. + pub const Progress = struct { + /// The screen whose HISTORY sequence contained the decoded page. + key: TerminalScreenKey, + + /// Rows prepended above that screen's existing content, or zero + /// when the page was consumed and validated but dropped. + rows: usize, + + /// PAGE records still pending in the same HISTORY sequence. + remaining: u32, + }; + + /// Errors possible while decoding history records through FINISH. + pub const NextError = checkpoint.DecodeError || + history.Decoder.InitError || + page.DiscardError || + page.DecodeError || + Allocator.Error || + error{ + /// `next` was called before `ready` completed successfully. + DecoderNotReady, + + /// A prior `next` call failed and invalidated the stream position. + DecoderFailed, + + /// A HISTORY names a key not declared by TERMINAL. + UnexpectedHistoryKey, + + /// More than one HISTORY names the same key. + DuplicateHistory, + + /// A history PAGE cannot join a native PageList. + /// These propagate from `nextPage`'s non-limit finalize errors; + /// only limit failures are deliberately converted into drops. + InvalidPageDimensions, + RowCountOverflow, + PageSizeOverflow, + }; + + /// Decode one history PAGE record and prepend it to its screen in `t`. + /// + /// `t` must be the Terminal restored by this decoder's `ready` call, + /// wherever the caller now stores it. Returns null once FINISH has + /// validated, which completes the snapshot; the source is + /// left positioned exactly after it. Further calls return null. + /// + /// Wire validation stays strict: framing, CRCs, record order, routing, + /// and both marker records are always enforced. Application is + /// forgiving, because the terminal is live and may have changed since + /// READY. A page is consumed but dropped, reported as zero rows, when: + /// + /// * its screen was removed, or removed and recreated, since READY + /// (detected through ScreenSet generations); + /// * the terminal was resized since READY, since encoded pages assume + /// the encoded width; + /// * prepending it would exceed the screen's scrollback limits, e.g. + /// because post-cut output already consumed that budget, making + /// immediate eviction the only alternative. + /// + /// Once a page in a sequence is dropped, the rest of that sequence is + /// discarded too: later pages are older, and applying one above a gap + /// would corrupt scrollback ordering. History for other screens is + /// unaffected. + pub fn next( + self: *Decoder, + alloc: Allocator, + t: *Terminal, + ) NextError!?Progress { + switch (self.state) { + // `ready` must succeed before history exists to decode, and a + // failed decoder no longer knows its stream position. + .start => return error.DecoderNotReady, + .failed => return error.DecoderFailed, + .finished => return null, + .history => {}, + } + errdefer self.state = .failed; + const state = &self.state.history; + + while (true) { + // Stream the current sequence's next PAGE if one remains. + if (state.current) |*sequence| { + if (sequence.remaining > 0) { + return try self.nextPage(alloc, t, sequence); + } + state.current = null; + } + + // All sequences are consumed: FINISH ends the snapshot, leaving + // trailing transport bytes unread. + if (state.pending == 0) { + try checkpoint.decode(.finish, self.source); + if (comptime build_options.slow_runtime_safety) { + var it = state.generations.iterator(); + while (it.next()) |entry| { + const restored = t.screens.get(entry.key) orelse + continue; + restored.pages.assertIntegrity(); + restored.assertIntegrity(); + } + } + self.state = .finished; + return null; + } + + // Begin the next HISTORY sequence. Keys route the sequences + // order-independently, but each must name a declared screen and + // may not repeat. + state.pending -= 1; + var manifest: history.Decoder = undefined; + try manifest.init(self.source); + const key = manifest.header.key; + if (state.generations.get(key) == null) { + return error.UnexpectedHistoryKey; + } + if (state.routed.contains(key)) return error.DuplicateHistory; + state.routed.insert(key); + state.current = .{ + .key = key, + .remaining = manifest.header.page_count, + .apply = true, + }; + } + } + + /// Consume one PAGE record from the current sequence, applying it to + /// the live terminal when it is still applicable and discarding it + /// otherwise. + fn nextPage( + self: *Decoder, + alloc: Allocator, + t: *Terminal, + sequence: *Sequence, + ) NextError!Progress { + const state = &self.state.history; + + // Application is re-decided on every page so live terminal changes + // between calls degrade to discarding instead of failing decoding. + const destination: ?*TerminalScreen = destination: { + if (!sequence.apply) break :destination null; + if (t.cols != state.cols) break :destination null; + const restored = t.screens.get(sequence.key) orelse + break :destination null; + if (t.screens.generation(sequence.key) != + state.generations.get(sequence.key).?) + { + break :destination null; + } + break :destination restored; + }; + + sequence.remaining -= 1; + const rows: usize = rows: { + const restored = destination orelse { + // The record must still be consumed so decoding remains + // aligned with the following records and FINISH marker. + sequence.apply = false; + try page.discard(self.source); + break :rows 0; + }; + // The one-shot history decoder rejects a Screen which already has + // complete history. This incremental path intentionally bypasses + // that guard: the terminal has been live since READY and may have + // accumulated new history. Generation and width checks above keep + // the destination compatible, while per-page finalization enforces + // its current scrollback limits. + break :rows history.decodePage( + self.source, + alloc, + restored, + ) catch |err| switch (err) { + // The page no longer fits the screen's scrollback limits, + // e.g. because post-cut output consumed the budget, so it + // would be evicted immediately anyway. Its record was fully + // consumed, leaving the stream aligned on the next record. + error.MaxSizeExceeded, + error.MaxLinesExceeded, + => drop: { + sequence.apply = false; + break :drop 0; + }, + else => |other| return other, + }; + }; + + return .{ + .key = sequence.key, + .rows = rows, + .remaining = sequence.remaining, + }; + } +}; + +/// Restore one complete snapshot into a native Terminal and continuation. /// -/// This consumes one snapshot through FINISH and leaves any following bytes in -/// the reader for the containing transport. Restoration is transactional: the -/// returned terminal is either complete and ready or not (error return). -/// Individual record codecs normalize optional semantic state, while framing, -/// checkpoints, declared sequence counts, and unique cross-record screen -/// routing remain strict. +/// This consumes one snapshot through FINISH and leaves any following bytes +/// in the reader for the containing transport. Restoration is transactional: +/// the returned result is either complete and ready or not (error return). +/// Individual record codecs normalize optional semantic state, and history +/// beyond the declared scrollback limits is dropped rather than rejected, +/// while framing, markers, declared sequence counts, and unique +/// cross-record screen routing remain strict. +/// +/// This is the one-shot form of `Decoder`, which can additionally hand the +/// renderable terminal to the caller at READY, before history arrives. pub fn decode( alloc: Allocator, io_: std.Io, source: *std.Io.Reader, -) DecodeError!Terminal { - // StreamReader owns a zero-buffer hashing adapter, making checkpoint - // boundaries part of its API rather than a caller-maintained invariant. - var stream: record.StreamReader = .init(source); - const reader = stream.reader(); - - // Read the envelope, which is currently just a verification step. - try envelope.decode(reader); - - // TERMINAL establishes terminal-wide state and allocates empty screen - // slots with their final routing. SCREEN values replace those slots in - // place so ScreenSet pointers, including the active pointer, stay valid. - var result = try terminal.decode(reader, io_, alloc); + options: DecodeOptions, +) DecodeError!Decoded { + var decoder: Decoder = .init(source); + var result = try decoder.ready(alloc, io_, options); errdefer result.deinit(alloc); - // TERMINAL initializes exactly the number of screen slots it declared. - // Decode that many SCREEN sequences and route each one by its encoded key. - const screen_count = result.screens.all.count(); - const options: TerminalScreen.Options = options: { - const primary = result.screens.get(.primary).?; - const explicit_bytes = primary.pages.limits.bytes.explicit; - const explicit_lines = primary.pages.limits.lines.explicit; - break :options .{ - .cols = result.cols, - .rows = result.rows, - .max_scrollback_bytes = if (explicit_bytes == std.math.maxInt(usize)) - null - else - explicit_bytes, - .max_scrollback_lines = if (explicit_lines == std.math.maxInt(usize)) - null - else - explicit_lines, - }; - }; - - for (0..screen_count) |_| { - var decoded = try screen.decode( - reader, - io_, - alloc, - options, - ); - errdefer decoded.deinit(); - - const slot = result.screens.get(decoded.key) orelse - return error.UnexpectedScreenKey; - - // The fresh ScreenSet starts every declared slot at generation zero. - // Replacing a slot advances its generation, so a nonzero value means - // an earlier SCREEN in this snapshot already supplied the same key. - if (result.screens.generation(decoded.key) != 0) return error.DuplicateScreen; - - slot.deinit(); - slot.* = decoded.screen; - decoded.screen = undefined; - - // We put an artificial generation in just so we can detect duplicates. - result.screens.generations.put( - decoded.key, - result.screens.generation(decoded.key) +% 1, - ); - } - - // READY covers the exact envelope-through-SCREEN prefix. Finalizing does - // not consume the hasher, so the same stream continues toward FINISH. - try checkpoint.decode(.ready, &stream); - - // HISTORY keys make this sequence order-independent just like SCREEN. - // Although a decoder may publish recent pages as they validate, any later - // full-snapshot failure deinitializes the whole result. - for (0..screen_count) |_| { - var decoder: history.Decoder = undefined; - try decoder.init(reader); - - const key = decoder.header.key; - const restored = result.screens.get(key) orelse - return error.UnexpectedHistoryKey; - - // SCREEN routing advanced every decoded slot from generation zero to - // one. A value other than one means this key already received HISTORY. - if (result.screens.generation(key) != 1) { - return error.DuplicateHistory; - } - - try decoder.decode(alloc, restored); - result.screens.generations.put(key, 2); - } - - // FINISH authenticates READY and all history. Decode it directly from the - // underlying reader so the digest does not include FINISH itself. - try checkpoint.decode(.finish, &stream); - - const keys = [_]TerminalScreenKey{ .primary, .alternate }; - if (comptime build_options.slow_runtime_safety) { - for (keys) |key| { - const restored = result.screens.get(key) orelse continue; - restored.pages.assertIntegrity(); - restored.assertIntegrity(); - } - } - - // Generations are only scratch state while routing the two keyed sequence - // groups. The completed terminal has not escaped yet, so reset them to the - // same initial state as any newly constructed ScreenSet. - for (keys) |key| result.screens.generations.put(key, 0); + // The Terminal has not been transferred, so history applies in place. + const t = &result.terminal.?; + while (try decoder.next(alloc, t)) |_| {} return result; } @@ -233,8 +623,9 @@ pub fn decodeExact( alloc: Allocator, io_: std.Io, source: *std.Io.Reader, -) DecodeExactError!Terminal { - var result = try decode(alloc, io_, source); + options: DecodeOptions, +) DecodeExactError!Decoded { + var result = try decode(alloc, io_, source, options); errdefer result.deinit(alloc); _ = source.peekByte() catch |err| switch (err) { @@ -244,6 +635,78 @@ pub fn decodeExact( return error.TrailingData; } +const test_encode_options: EncodeOptions = .{ .continuation = .ground }; +const test_decode_options: DecodeOptions = .{ .max_continuation_bytes = 1024 }; +const test_complete_fixture = test_fixture.parse(@embedFile("testdata/complete-v1.hex")); + +/// Build a 2x3 test terminal whose primary screen carries `history_pages` +/// complete two-row pages above a three-row active page. The top-left cell +/// of each page is marked 'A', 'B', ... from oldest history to active. +fn testHistoryTerminal(history_pages: u8) !Terminal { + const testing = std.testing; + var t = try Terminal.init(testing.io, testing.allocator, .{ + .cols = 2, + .rows = 3, + .max_scrollback_bytes = null, + .max_scrollback_lines = null, + }); + errdefer t.deinit(testing.allocator); + + const primary = t.screens.get(.primary).?; + var replacement: TerminalScreen = replacement: { + var builder = try TerminalPageList.Builder.init( + testing.allocator, + .{ + .cols = t.cols, + .rows = t.rows, + .max_size = null, + .max_lines = null, + }, + ); + defer builder.deinit(); + + for (0..history_pages) |index| { + const built = try builder.allocatePage(.{ .cols = 2, .rows = 2 }); + built.size.rows = 2; + built.getRowAndCell(0, 0).cell.* = .init( + @intCast('A' + index), + ); + } + const active = try builder.allocatePage(.{ .cols = 2, .rows = 3 }); + active.size.rows = 3; + active.getRowAndCell(0, 0).cell.* = .init( + @intCast('A' + @as(usize, history_pages)), + ); + + var pages = try builder.finish(); + errdefer pages.deinit(); + const cursor_pin = try pages.trackPin( + pages.pin(.{ .active = .{} }).?, + ); + const cursor_rac = cursor_pin.rowAndCell(); + break :replacement .{ + .io = testing.io, + .alloc = testing.allocator, + .pages = pages, + .cursor = .{ + .page_pin = cursor_pin, + .page_row = cursor_rac.row, + .page_cell = cursor_rac.cell, + }, + }; + }; + primary.deinit(); + primary.* = replacement; + replacement = undefined; + return t; +} + +/// The top-left screen cell, which lands in the oldest applied history page. +fn testTopLeftCodepoint(terminal_screen: *const TerminalScreen) u21 { + return terminal_screen.pages.getTopLeft(.screen).node + .page().getRowAndCell(0, 0).cell.codepoint(); +} + test "complete snapshot round trip with history and alternate screen" { const testing = std.testing; @@ -330,7 +793,7 @@ test "complete snapshot round trip with history and alternate screen" { var encoded: std.Io.Writer.Allocating = .init(testing.allocator); defer encoded.deinit(); - try encode(testing.allocator, &encoded.writer, &t); + try encode(testing.allocator, &encoded.writer, &t, test_encode_options); try testing.expectEqualDeep(source_memory, primary.pages.memoryStats()); try test_fixture.expectEqual( .snapshot, @@ -344,24 +807,16 @@ test "complete snapshot round trip with history and alternate screen" { // Independently hash that output so both its length and complete byte // sequence are checked without retaining a second snapshot copy. var discard: std.Io.Writer.Discarding = .init(&.{}); - var hashing = discard.writer.hashed( - std.crypto.hash.Blake3.init(.{}), - &.{}, - ); - try encode(testing.allocator, &hashing.writer, &t); + var hashing = discard.writer.hashed(record.Crc32c.init(), &.{}); + try encode(testing.allocator, &hashing.writer, &t, test_encode_options); try testing.expectEqual( @as(u64, test_complete_fixture.len), discard.fullCount(), ); - var expected_digest: checkpoint.Digest = undefined; - std.crypto.hash.Blake3.hash( - &test_complete_fixture, - &expected_digest, - .{}, + try testing.expectEqual( + record.Crc32c.hash(&test_complete_fixture), + hashing.hasher.final(), ); - var actual_digest: checkpoint.Digest = undefined; - hashing.hasher.final(&actual_digest); - try testing.expectEqual(expected_digest, actual_digest); // Restore the checked-in reference rather than the just-generated bytes. var encoded_source: std.Io.Reader = .fixed(&test_complete_fixture); @@ -371,32 +826,42 @@ test "complete snapshot round trip with history and alternate screen" { testing.allocator, testing.io, &limited.interface, + test_decode_options, ); defer restored.deinit(testing.allocator); + const restored_terminal = &restored.terminal.?; - try testing.expectEqual(TerminalScreenKey.alternate, restored.screens.active_key); try testing.expectEqual( - restored.screens.get(.alternate).?, - restored.screens.active, + TerminalScreenKey.alternate, + restored_terminal.screens.active_key, + ); + try testing.expectEqual( + restored_terminal.screens.get(.alternate).?, + restored_terminal.screens.active, ); try testing.expectEqualStrings( "file:///tmp/snapshot", - restored.getPwd().?, + restored_terminal.getPwd().?, ); try testing.expectEqualStrings( "complete snapshot", - restored.getTitle().?, + restored_terminal.getTitle().?, ); try testing.expectEqual( primary.pages.scrollbar().total, - restored.screens.get(.primary).?.pages.scrollbar().total, + restored_terminal.screens.get(.primary).?.pages.scrollbar().total, ); // Re-encoding is a compact semantic equality check over all TERMINAL, - // SCREEN, PAGE, and HISTORY fields and both checkpoint boundaries. + // SCREEN, PAGE, and HISTORY fields and both marker boundaries. var reencoded: std.Io.Writer.Allocating = .init(testing.allocator); defer reencoded.deinit(); - try encode(testing.allocator, &reencoded.writer, &restored); + try encode( + testing.allocator, + &reencoded.writer, + restored_terminal, + test_encode_options, + ); try testing.expectEqualStrings( &test_complete_fixture, reencoded.written(), @@ -418,6 +883,7 @@ test "complete snapshot round trip with history and alternate screen" { &reversed_stream, ); try screen.encode(primary, .primary, &reversed_stream); + try continuation.encode(.ground, &reversed_stream); try checkpoint.encode(.ready, &reversed_stream); try history.encode( t.screens.get(.alternate).?, @@ -432,22 +898,302 @@ test "complete snapshot round trip with history and alternate screen" { testing.allocator, testing.io, &reversed_source, + test_decode_options, ); defer reversed_restored.deinit(testing.allocator); + const reversed_terminal = &reversed_restored.terminal.?; try testing.expectEqual( TerminalScreenKey.alternate, - reversed_restored.screens.active_key, + reversed_terminal.screens.active_key, ); try testing.expectEqual( @as(usize, 0), - reversed_restored.screens.generation(.primary), + reversed_terminal.screens.generation(.primary), ); try testing.expectEqual( @as(usize, 0), - reversed_restored.screens.generation(.alternate), + reversed_terminal.screens.generation(.alternate), ); } +test "complete snapshot restores a canonical Stream continuation" { + const testing = std.testing; + + var source_terminal = try Terminal.init( + testing.io, + testing.allocator, + .{ .cols = 8, .rows = 2 }, + ); + defer source_terminal.deinit(testing.allocator); + var source_stream = TerminalStream.init(.{ + .allocator = testing.allocator, + .handler = .init(&source_terminal), + .continuation_max_bytes = 1024, + }); + defer source_stream.deinit(); + + source_stream.nextSlice("A\x1b[31"); + var exported_bytes: [1024]u8 = undefined; + var exported: std.Io.Writer = .fixed(&exported_bytes); + try source_stream.writeContinuation(&exported); + try testing.expectEqualStrings("\x1b[31", exported.buffered()); + + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + try encode(testing.allocator, &encoded.writer, &source_terminal, .{ + .continuation = .{ .bytes = exported.buffered() }, + }); + + var encoded_source: std.Io.Reader = .fixed(encoded.written()); + var decoded = try decode( + testing.allocator, + testing.io, + &encoded_source, + .{ .max_continuation_bytes = 1024 }, + ); + defer decoded.deinit(testing.allocator); + try testing.expectEqualStrings( + exported.buffered(), + decoded.continuation.bytes, + ); + + var restored_terminal = decoded.toOwned(); + defer restored_terminal.deinit(testing.allocator); + try testing.expect(decoded.terminal == null); + var restored_stream = TerminalStream.init(.{ + .allocator = testing.allocator, + .handler = .init(&restored_terminal), + .continuation_max_bytes = 1024, + }); + defer restored_stream.deinit(); + + restored_stream.nextSlice(decoded.continuation.bytes); + var reexported_bytes: [1024]u8 = undefined; + var reexported: std.Io.Writer = .fixed(&reexported_bytes); + try restored_stream.writeContinuation(&reexported); + try testing.expectEqualStrings(exported.buffered(), reexported.buffered()); + + // The identical post-cut bytes may use different feed chunking without + // changing terminal semantics. + source_stream.nextSlice("mB"); + restored_stream.next('m'); + restored_stream.next('B'); + const source_text = try source_terminal.plainString(testing.allocator); + defer testing.allocator.free(source_text); + const restored_text = try restored_terminal.plainString(testing.allocator); + defer testing.allocator.free(restored_text); + try testing.expectEqualStrings(source_text, restored_text); + try testing.expectEqual( + source_terminal.screens.active.cursor.style_id, + restored_terminal.screens.active.cursor.style_id, + ); +} + +test "complete snapshot validates continuation before writing" { + const testing = std.testing; + var t = try Terminal.init( + testing.io, + testing.allocator, + .{ .cols = 2, .rows = 1 }, + ); + defer t.deinit(testing.allocator); + + var destination: std.Io.Writer.Allocating = .init(testing.allocator); + defer destination.deinit(); + try destination.writer.writeAll("prefix"); + try testing.expectError( + error.NoPendingState, + encode(testing.allocator, &destination.writer, &t, .{ + .continuation = .{ .bytes = "\x1b[31m" }, + }), + ); + try testing.expectEqualStrings("prefix", destination.written()); + + var tail = [_]u8{ 0x1b, '[', '3', '1' }; + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + try encode(testing.allocator, &encoded.writer, &t, .{ + .continuation = .{ .bytes = &tail }, + }); + tail[2] = '4'; + + var source: std.Io.Reader = .fixed(encoded.written()); + var decoded = try decode( + testing.allocator, + testing.io, + &source, + test_decode_options, + ); + defer decoded.deinit(testing.allocator); + try testing.expectEqualStrings("\x1b[31", decoded.continuation.bytes); +} + +test "complete snapshot waits for continuation tracking recovery" { + const testing = std.testing; + var t = try Terminal.init( + testing.io, + testing.allocator, + .{ .cols = 2, .rows = 1 }, + ); + defer t.deinit(testing.allocator); + var stream = TerminalStream.init(.{ + .allocator = testing.allocator, + .handler = .init(&t), + .continuation_max_bytes = 4, + }); + defer stream.deinit(); + + stream.nextSlice("\x1b[123"); + var unavailable_bytes: [4]u8 = undefined; + var unavailable: std.Io.Writer = .fixed(&unavailable_bytes); + try testing.expectError( + error.ContinuationUnavailable, + stream.writeContinuation(&unavailable), + ); + + // A new replay start replaces the lost suffix and makes a later cut + // publishable without affecting normal terminal parsing. + stream.nextSlice("\x1b["); + var recovered_bytes: [4]u8 = undefined; + var recovered: std.Io.Writer = .fixed(&recovered_bytes); + try stream.writeContinuation(&recovered); + try testing.expectEqualStrings("\x1b[", recovered.buffered()); + + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + try encode(testing.allocator, &encoded.writer, &t, .{ + .continuation = .{ .bytes = recovered.buffered() }, + }); +} + +test "complete snapshot preserves every supported continuation cut" { + const testing = std.testing; + const corpora = [_][]const u8{ + "plain \xF0\x9F\x98\x84 utf8", + "bad \xE0\xA0\xF0\x9F\x98\x84 utf8", + "\x1b[1\x07;2mstyled\x1b[0m", + "\x1b]2;window title\x1b\\text", + "\x1bP$qm\x1b\\text", + "\x1b_Ga=q;payload\x1b\\text", + "\x1b_25a1;s\x1b\\text", + "\x1b]2;first\x1b\\\x1b_Gsecond", + "\x1b[12\x9D2;title\x1b\\text", + "\x1b[12\x18text\x1b[1\x1Atext", + }; + + for (corpora) |corpus| for (0..corpus.len + 1) |cut| { + var source_terminal = try Terminal.init( + testing.io, + testing.allocator, + .{ .cols = 20, .rows = 4 }, + ); + defer source_terminal.deinit(testing.allocator); + var source_stream = TerminalStream.init(.{ + .allocator = testing.allocator, + .handler = .init(&source_terminal), + .continuation_max_bytes = 4096, + }); + defer source_stream.deinit(); + source_stream.nextSlice(corpus[0..cut]); + + var cut_bytes: [4096]u8 = undefined; + var cut_writer: std.Io.Writer = .fixed(&cut_bytes); + try source_stream.writeContinuation(&cut_writer); + const cut_continuation: Continuation = if (cut_writer.end == 0) + .ground + else + .{ .bytes = cut_writer.buffered() }; + + var snapshot_bytes: std.Io.Writer.Allocating = .init(testing.allocator); + defer snapshot_bytes.deinit(); + try encode( + testing.allocator, + &snapshot_bytes.writer, + &source_terminal, + .{ .continuation = cut_continuation }, + ); + + var snapshot_source: std.Io.Reader = .fixed(snapshot_bytes.written()); + var decoded = try decode( + testing.allocator, + testing.io, + &snapshot_source, + .{ .max_continuation_bytes = 4096 }, + ); + defer decoded.deinit(testing.allocator); + var restored_terminal = decoded.toOwned(); + defer restored_terminal.deinit(testing.allocator); + var restored_stream = TerminalStream.init(.{ + .allocator = testing.allocator, + .handler = .init(&restored_terminal), + .continuation_max_bytes = 4096, + }); + defer restored_stream.deinit(); + switch (decoded.continuation) { + .ground => {}, + .bytes => |bytes| restored_stream.nextSlice(bytes), + } + + var reexport_bytes: [4096]u8 = undefined; + var reexport_writer: std.Io.Writer = .fixed(&reexport_bytes); + try restored_stream.writeContinuation(&reexport_writer); + try testing.expectEqualStrings( + cut_writer.buffered(), + reexport_writer.buffered(), + ); + + source_stream.nextSlice(corpus[cut..]); + var offset = cut; + var partition = cut +% corpus.len +% 1; + while (offset < corpus.len) { + partition = partition *% 1664525 +% 1013904223; + const len = @min(1 + partition % 7, corpus.len - offset); + restored_stream.nextSlice(corpus[offset..][0..len]); + offset += len; + } + + var source_final_bytes: [4096]u8 = undefined; + var source_final_writer: std.Io.Writer = .fixed(&source_final_bytes); + try source_stream.writeContinuation(&source_final_writer); + var restored_final_bytes: [4096]u8 = undefined; + var restored_final_writer: std.Io.Writer = .fixed(&restored_final_bytes); + try restored_stream.writeContinuation(&restored_final_writer); + try testing.expectEqualStrings( + source_final_writer.buffered(), + restored_final_writer.buffered(), + ); + + const final_continuation: Continuation = if (source_final_writer.end == 0) + .ground + else + .{ .bytes = source_final_writer.buffered() }; + var source_final_snapshot: std.Io.Writer.Allocating = .init( + testing.allocator, + ); + defer source_final_snapshot.deinit(); + try encode( + testing.allocator, + &source_final_snapshot.writer, + &source_terminal, + .{ .continuation = final_continuation }, + ); + var restored_final_snapshot: std.Io.Writer.Allocating = .init( + testing.allocator, + ); + defer restored_final_snapshot.deinit(); + try encode( + testing.allocator, + &restored_final_snapshot.writer, + &restored_terminal, + .{ .continuation = final_continuation }, + ); + try testing.expectEqualStrings( + source_final_snapshot.written(), + restored_final_snapshot.written(), + ); + }; +} + test "complete snapshot preserves Kitty virtual placeholders" { if (comptime !build_options.kitty_graphics) return error.SkipZigTest; @@ -465,11 +1211,13 @@ test "complete snapshot preserves Kitty virtual placeholders" { try t.screens.active.kitty_images.addImage( testing.io, testing.allocator, + t.screens.active, .{ .id = 1 }, ); try t.screens.active.kitty_images.addPlacement( testing.io, testing.allocator, + t.screens.active, 1, 0, .{ @@ -491,17 +1239,19 @@ test "complete snapshot preserves Kitty virtual placeholders" { var encoded: std.Io.Writer.Allocating = .init(testing.allocator); defer encoded.deinit(); - try encode(testing.allocator, &encoded.writer, &t); + try encode(testing.allocator, &encoded.writer, &t, test_encode_options); var encoded_source: std.Io.Reader = .fixed(encoded.written()); var restored = try decode( testing.allocator, testing.io, &encoded_source, + test_decode_options, ); defer restored.deinit(testing.allocator); + const restored_terminal = &restored.terminal.?; - const restored_cell = restored.screens.active.pages.getCell(.{ + const restored_cell = restored_terminal.screens.active.pages.getCell(.{ .screen = .{}, }).?; try testing.expectEqual( @@ -512,11 +1262,11 @@ test "complete snapshot preserves Kitty virtual placeholders" { try testing.expect(restored_cell.row.kitty_virtual_placeholder); try testing.expectEqual( @as(usize, 0), - restored.screens.active.kitty_images.images.count(), + restored_terminal.screens.active.kitty_images.images.count(), ); try testing.expectEqual( @as(usize, 0), - restored.screens.active.kitty_images.placements.count(), + restored_terminal.screens.active.kitty_images.placements.count(), ); } @@ -529,13 +1279,13 @@ test "complete snapshot encoding streams from the current writer position" { }); defer t.deinit(testing.allocator); - // Prefix hashing begins with this call's envelope, independent of bytes - // that were already present in the destination. + // Encoding begins with this call's envelope, independent of bytes that + // were already present in the destination. var nonempty: std.Io.Writer.Allocating = .init(testing.allocator); defer nonempty.deinit(); try nonempty.writer.writeAll("prefix"); const snapshot_offset = nonempty.written().len; - try encode(testing.allocator, &nonempty.writer, &t); + try encode(testing.allocator, &nonempty.writer, &t, test_encode_options); try testing.expectEqualStrings( "prefix", nonempty.written()[0..snapshot_offset], @@ -547,6 +1297,7 @@ test "complete snapshot encoding streams from the current writer position" { testing.allocator, testing.io, &appended_source, + test_decode_options, ); appended.deinit(testing.allocator); @@ -558,7 +1309,12 @@ test "complete snapshot encoding streams from the current writer position" { try destination.writer.writeAll("prefix"); try testing.expectError( error.InvalidPalette, - encode(testing.allocator, &destination.writer, &t), + encode( + testing.allocator, + &destination.writer, + &t, + test_encode_options, + ), ); var expected_envelope: [envelope.encoded_len]u8 = undefined; var envelope_writer: std.Io.Writer = .fixed(&expected_envelope); @@ -569,7 +1325,7 @@ test "complete snapshot encoding streams from the current writer position" { ); } -test "complete snapshot rejects ordering and invalid checkpoints" { +test "complete snapshot rejects ordering and invalid markers" { const testing = std.testing; var t = try Terminal.init(testing.io, testing.allocator, .{ @@ -579,6 +1335,58 @@ test "complete snapshot rejects ordering and invalid checkpoints" { defer t.deinit(testing.allocator); const primary = t.screens.get(.primary).?; + // Old version-1 snapshots proceeded directly from SCREEN to READY. READY + // is individually valid here, but CONTINUATION is now required first. + var old_order: std.Io.Writer.Allocating = .init(testing.allocator); + defer old_order.deinit(); + var old_order_stream: record.Writer = .init( + testing.allocator, + &old_order.writer, + ); + defer old_order_stream.deinit(); + try envelope.encode(old_order_stream.writer()); + try terminal.encode(&t, &old_order_stream); + try screen.encode(primary, .primary, &old_order_stream); + try checkpoint.encode(.ready, &old_order_stream); + var old_order_source: std.Io.Reader = .fixed(old_order.written()); + try testing.expectError( + error.UnexpectedRecordTag, + decode( + testing.allocator, + testing.io, + &old_order_source, + test_decode_options, + ), + ); + + // A second CONTINUATION is rejected where READY is required. + var duplicate_continuation: std.Io.Writer.Allocating = .init( + testing.allocator, + ); + defer duplicate_continuation.deinit(); + var duplicate_continuation_stream: record.Writer = .init( + testing.allocator, + &duplicate_continuation.writer, + ); + defer duplicate_continuation_stream.deinit(); + try envelope.encode(duplicate_continuation_stream.writer()); + try terminal.encode(&t, &duplicate_continuation_stream); + try screen.encode(primary, .primary, &duplicate_continuation_stream); + try continuation.encode(.ground, &duplicate_continuation_stream); + try continuation.encode(.ground, &duplicate_continuation_stream); + var duplicate_continuation_source: std.Io.Reader = .fixed( + duplicate_continuation.written(), + ); + try testing.expectError( + error.UnexpectedRecordTag, + decode( + testing.allocator, + testing.io, + &duplicate_continuation_source, + test_decode_options, + ), + ); + // HISTORY is individually valid here, but the full decoder requires the // primary SCREEN before READY. var reordered: std.Io.Writer.Allocating = .init(testing.allocator); @@ -594,11 +1402,16 @@ test "complete snapshot rejects ordering and invalid checkpoints" { var reordered_source: std.Io.Reader = .fixed(reordered.written()); try testing.expectError( error.UnexpectedRecordTag, - decode(testing.allocator, testing.io, &reordered_source), + decode( + testing.allocator, + testing.io, + &reordered_source, + test_decode_options, + ), ); - // Construct a correctly framed READY with an intentionally unrelated - // digest so the full driver, rather than record CRC validation, rejects it. + // Construct a correctly framed READY with a nonempty payload so the full + // driver rejects a marker that does not match the required empty shape. var invalid_ready: std.Io.Writer.Allocating = .init(testing.allocator); defer invalid_ready.deinit(); var invalid_ready_stream: record.Writer = .init( @@ -609,19 +1422,22 @@ test "complete snapshot rejects ordering and invalid checkpoints" { try envelope.encode(invalid_ready_stream.writer()); try terminal.encode(&t, &invalid_ready_stream); try screen.encode(primary, .primary, &invalid_ready_stream); + try continuation.encode(.ground, &invalid_ready_stream); const ready_payload = invalid_ready_stream.begin(.ready); errdefer invalid_ready_stream.cancel(); - try ready_payload.splatByteAll( - 0, - @sizeOf(checkpoint.Digest), - ); + try ready_payload.writeByte(0); try invalid_ready_stream.finish(); var invalid_ready_source: std.Io.Reader = .fixed( invalid_ready.written(), ); try testing.expectError( - error.InvalidDigest, - decode(testing.allocator, testing.io, &invalid_ready_source), + error.PayloadNotExhausted, + decode( + testing.allocator, + testing.io, + &invalid_ready_source, + test_decode_options, + ), ); // A SCREEN key must name one of the slots declared by TERMINAL. @@ -638,7 +1454,12 @@ test "complete snapshot rejects ordering and invalid checkpoints" { var undeclared_source: std.Io.Reader = .fixed(undeclared.written()); try testing.expectError( error.UnexpectedScreenKey, - decode(testing.allocator, testing.io, &undeclared_source), + decode( + testing.allocator, + testing.io, + &undeclared_source, + test_decode_options, + ), ); // HISTORY sequences are also routed by key, which must name a declared @@ -653,6 +1474,7 @@ test "complete snapshot rejects ordering and invalid checkpoints" { try envelope.encode(undeclared_history_stream.writer()); try terminal.encode(&t, &undeclared_history_stream); try screen.encode(primary, .primary, &undeclared_history_stream); + try continuation.encode(.ground, &undeclared_history_stream); try checkpoint.encode(.ready, &undeclared_history_stream); try history.encode(primary, .alternate, &undeclared_history_stream); var undeclared_history_source: std.Io.Reader = .fixed( @@ -660,7 +1482,12 @@ test "complete snapshot rejects ordering and invalid checkpoints" { ); try testing.expectError( error.UnexpectedHistoryKey, - decode(testing.allocator, testing.io, &undeclared_history_source), + decode( + testing.allocator, + testing.io, + &undeclared_history_source, + test_decode_options, + ), ); // The declared count cannot be satisfied by repeating the same key. @@ -679,7 +1506,12 @@ test "complete snapshot rejects ordering and invalid checkpoints" { var duplicate_source: std.Io.Reader = .fixed(duplicate.written()); try testing.expectError( error.DuplicateScreen, - decode(testing.allocator, testing.io, &duplicate_source), + decode( + testing.allocator, + testing.io, + &duplicate_source, + test_decode_options, + ), ); // The declared count cannot be satisfied by repeating one HISTORY key. @@ -698,6 +1530,7 @@ test "complete snapshot rejects ordering and invalid checkpoints" { .alternate, &duplicate_history_stream, ); + try continuation.encode(.ground, &duplicate_history_stream); try checkpoint.encode(.ready, &duplicate_history_stream); try history.encode(primary, .primary, &duplicate_history_stream); try history.encode(primary, .primary, &duplicate_history_stream); @@ -706,7 +1539,12 @@ test "complete snapshot rejects ordering and invalid checkpoints" { ); try testing.expectError( error.DuplicateHistory, - decode(testing.allocator, testing.io, &duplicate_history_source), + decode( + testing.allocator, + testing.io, + &duplicate_history_source, + test_decode_options, + ), ); } @@ -721,22 +1559,32 @@ test "complete snapshot leaves continuation bytes unread" { var encoded: std.Io.Writer.Allocating = .init(testing.allocator); defer encoded.deinit(); - try encode(testing.allocator, &encoded.writer, &t); + try encode(testing.allocator, &encoded.writer, &t, test_encode_options); const snapshot_len = encoded.written().len; try encoded.writer.writeAll("pty"); var source: std.Io.Reader = .fixed(encoded.written()); - var restored = try decode(testing.allocator, testing.io, &source); + var restored = try decode( + testing.allocator, + testing.io, + &source, + test_decode_options, + ); defer restored.deinit(testing.allocator); - var continuation: [3]u8 = undefined; - try source.readSliceAll(&continuation); - try testing.expectEqualStrings("pty", &continuation); + var trailing: [3]u8 = undefined; + try source.readSliceAll(&trailing); + try testing.expectEqualStrings("pty", &trailing); var exact_source: std.Io.Reader = .fixed(encoded.written()); try testing.expectError( error.TrailingData, - decodeExact(testing.allocator, testing.io, &exact_source), + decodeExact( + testing.allocator, + testing.io, + &exact_source, + test_decode_options, + ), ); var bounded_source: std.Io.Reader = .fixed( @@ -746,6 +1594,7 @@ test "complete snapshot leaves continuation bytes unread" { testing.allocator, testing.io, &bounded_source, + test_decode_options, ); defer bounded.deinit(testing.allocator); } @@ -761,13 +1610,488 @@ test "complete snapshots decode sequentially from one reader" { var encoded: std.Io.Writer.Allocating = .init(testing.allocator); defer encoded.deinit(); - try encode(testing.allocator, &encoded.writer, &t); - try encode(testing.allocator, &encoded.writer, &t); + try encode(testing.allocator, &encoded.writer, &t, test_encode_options); + try encode(testing.allocator, &encoded.writer, &t, test_encode_options); var source: std.Io.Reader = .fixed(encoded.written()); - var first = try decode(testing.allocator, testing.io, &source); + var first = try decode( + testing.allocator, + testing.io, + &source, + test_decode_options, + ); defer first.deinit(testing.allocator); - var second = try decode(testing.allocator, testing.io, &source); + var second = try decode( + testing.allocator, + testing.io, + &source, + test_decode_options, + ); defer second.deinit(testing.allocator); try testing.expectError(error.EndOfStream, source.takeByte()); } + +test "complete snapshot decode allocation failures are transactional" { + const testing = std.testing; + const S = struct { + fn exercise(bytes: []const u8) !void { + var baseline = testing.FailingAllocator.init(testing.allocator, .{ + .fail_index = std.math.maxInt(usize), + }); + var baseline_source: std.Io.Reader = .fixed(bytes); + var baseline_decoded = try decode( + baseline.allocator(), + testing.io, + &baseline_source, + test_decode_options, + ); + baseline_decoded.deinit(baseline.allocator()); + const allocation_count = baseline.alloc_index; + try testing.expect(allocation_count > 0); + + var saw_out_of_memory = false; + for (0..allocation_count) |fail_index| { + var failing = testing.FailingAllocator.init( + testing.allocator, + .{ .fail_index = fail_index }, + ); + var source: std.Io.Reader = .fixed(bytes); + var decoded = decode( + failing.allocator(), + testing.io, + &source, + test_decode_options, + ) catch |err| switch (err) { + error.OutOfMemory => { + saw_out_of_memory = true; + continue; + }, + else => return err, + }; + decoded.deinit(failing.allocator()); + } + try testing.expect(saw_out_of_memory); + } + }; + + // The complete golden exercises Terminal, both screens, and history. + try S.exercise(&test_complete_fixture); + + // A non-ground component additionally exercises owned continuation bytes. + var t = try Terminal.init( + testing.io, + testing.allocator, + .{ .cols = 2, .rows = 1 }, + ); + defer t.deinit(testing.allocator); + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + try encode(testing.allocator, &encoded.writer, &t, .{ + .continuation = .{ .bytes = "\x1b[31" }, + }); + try S.exercise(encoded.written()); +} + +test "incremental decode restores a renderable terminal at READY" { + const testing = std.testing; + + // Trailing transport bytes must remain unread after FINISH. + var transport: std.Io.Writer.Allocating = .init(testing.allocator); + defer transport.deinit(); + try transport.writer.writeAll(&test_complete_fixture); + try transport.writer.writeAll("pty"); + + var source: std.Io.Reader = .fixed(transport.written()); + var decoder: Decoder = .init(&source); + var decoded = try decoder.ready( + testing.allocator, + testing.io, + test_decode_options, + ); + defer decoded.deinit(testing.allocator); + + // READY restored the complete interactive state, so the Terminal can + // move to its final address before any history has been decoded. + var restored = decoded.toOwned(); + defer restored.deinit(testing.allocator); + try testing.expectEqual( + TerminalScreenKey.alternate, + restored.screens.active_key, + ); + try testing.expectEqualStrings( + "complete snapshot", + restored.getTitle().?, + ); + + // The advisory extents size scrollback UI immediately even though only + // the resident SCREEN pages exist. The alternate screen's extent is + // entirely resident overlap, so it must match its live row count. + const primary = restored.screens.get(.primary).?; + const alternate = restored.screens.get(.alternate).?; + try testing.expectEqual(3, primary.pages.scrollbar().total); + try testing.expectEqual(4, decoded.history_rows.get(.primary).?); + try testing.expectEqual( + alternate.pages.scrollbar().total - restored.rows, + decoded.history_rows.get(.alternate).?, + ); + + // Each call prepends exactly one history page, newest first. + const first = (try decoder.next(testing.allocator, &restored)).?; + try testing.expectEqualDeep(Decoder.Progress{ + .key = .primary, + .rows = 2, + .remaining = 1, + }, first); + try testing.expectEqual(5, primary.pages.scrollbar().total); + try testing.expectEqual(@as(u21, 'B'), testTopLeftCodepoint(primary)); + + const second = (try decoder.next(testing.allocator, &restored)).?; + try testing.expectEqualDeep(Decoder.Progress{ + .key = .primary, + .rows = 2, + .remaining = 0, + }, second); + try testing.expectEqual(7, primary.pages.scrollbar().total); + try testing.expectEqual(@as(u21, 'A'), testTopLeftCodepoint(primary)); + + // The alternate screen's empty sequence produces no event: the next + // call validates FINISH, and further calls remain null. + try testing.expect(try decoder.next(testing.allocator, &restored) == null); + try testing.expect(try decoder.next(testing.allocator, &restored) == null); + try testing.expectEqualStrings("pty", try source.take(3)); + + // The incremental path restores the same complete terminal as the + // one-shot decoder, byte for byte. + var reencoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer reencoded.deinit(); + try encode( + testing.allocator, + &reencoded.writer, + &restored, + test_encode_options, + ); + try testing.expectEqualStrings(&test_complete_fixture, reencoded.written()); +} + +test "incremental next reports invalid decoder states" { + const testing = std.testing; + var source: std.Io.Reader = .fixed(""); + var decoder: Decoder = .init(&source); + var terminal_value = try Terminal.init( + testing.io, + testing.allocator, + .{ .cols = 1, .rows = 1 }, + ); + defer terminal_value.deinit(testing.allocator); + + try testing.expectError( + error.DecoderNotReady, + decoder.next(testing.allocator, &terminal_value), + ); +} + +test "incremental decode applies history below live PTY output" { + const testing = std.testing; + + var t = try testHistoryTerminal(2); + defer t.deinit(testing.allocator); + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + try encode(testing.allocator, &encoded.writer, &t, test_encode_options); + + var source: std.Io.Reader = .fixed(encoded.written()); + var decoder: Decoder = .init(&source); + var decoded = try decoder.ready( + testing.allocator, + testing.io, + test_decode_options, + ); + defer decoded.deinit(testing.allocator); + var restored = decoded.toOwned(); + defer restored.deinit(testing.allocator); + + // The terminal is interactive at READY: post-cut PTY bytes append to + // the bottom while snapshot history is still pending. + var live = restored.vtStream(); + defer live.deinit(); + live.nextSlice("1\r\n2\r\n3\r\n4\r\n5\r\n"); + const primary = restored.screens.get(.primary).?; + const live_total = primary.pages.scrollbar().total; + try testing.expect(live_total > 3); + + // History still lands above both the snapshot content and the live + // output, preserving complete scrollback ordering. + while (try decoder.next(testing.allocator, &restored)) |progress| { + try testing.expectEqual(TerminalScreenKey.primary, progress.key); + try testing.expectEqual(2, progress.rows); + } + try testing.expectEqual(live_total + 4, primary.pages.scrollbar().total); + try testing.expectEqual(@as(u21, 'A'), testTopLeftCodepoint(primary)); + const newest_history = primary.pages.getTopLeft(.screen).node.next.?; + try testing.expectEqual( + @as(u21, 'B'), + newest_history.page().getRowAndCell(0, 0).cell.codepoint(), + ); + + // The page below the applied history is the snapshot's active page, + // whose top-left cell the live output already overwrote. + try testing.expectEqual( + @as(u21, '1'), + newest_history.next.?.page().getRowAndCell(0, 0).cell.codepoint(), + ); +} + +test "incremental decode discards history for screens changed since READY" { + const testing = std.testing; + + var t = try Terminal.init(testing.io, testing.allocator, .{ + .cols = 2, + .rows = 3, + }); + defer t.deinit(testing.allocator); + _ = try t.switchScreen(.alternate); + try t.printString("alt"); + _ = try t.switchScreen(.primary); + + // The encoder never produces alternate-screen history, but the format + // permits it, so the decoder must stay stream-aligned even when the + // pages can no longer be applied. Order independence lets the discarded + // sequence come first. + var crafted: std.Io.Writer.Allocating = .init(testing.allocator); + defer crafted.deinit(); + var stream: record.Writer = .init(testing.allocator, &crafted.writer); + defer stream.deinit(); + try envelope.encode(stream.writer()); + try terminal.encode(&t, &stream); + try screen.encode(t.screens.get(.primary).?, .primary, &stream); + try screen.encode(t.screens.get(.alternate).?, .alternate, &stream); + try continuation.encode(.ground, &stream); + try checkpoint.encode(.ready, &stream); + { + const payload = stream.begin(.history); + errdefer stream.cancel(); + try (history.Header{ .key = .alternate, .page_count = 1 }).encode( + payload, + ); + try stream.finish(); + } + try page.encode( + t.screens.get(.alternate).?.pages + .getTopLeft(.screen).node.pageAssumeResident(), + &stream, + ); + try history.encode(t.screens.get(.primary).?, .primary, &stream); + try checkpoint.encode(.finish, &stream); + + // An unchanged declared screen accepts crafted history, establishing + // the baseline that the following runs degrade from. + { + var source: std.Io.Reader = .fixed(crafted.written()); + var decoder: Decoder = .init(&source); + var decoded = try decoder.ready( + testing.allocator, + testing.io, + test_decode_options, + ); + defer decoded.deinit(testing.allocator); + const restored = &decoded.terminal.?; + + const progress = (try decoder.next(testing.allocator, restored)).?; + try testing.expectEqual(TerminalScreenKey.alternate, progress.key); + try testing.expect(progress.rows > 0); + try testing.expect(try decoder.next(testing.allocator, restored) == null); + try testing.expectEqual( + @as(usize, 2), + restored.screens.get(.alternate).?.pages.totalPages(), + ); + } + + // A screen removed since READY discards its history. FINISH still + // validates, proving discarded records leave the stream aligned. + { + var source: std.Io.Reader = .fixed(crafted.written()); + var decoder: Decoder = .init(&source); + var decoded = try decoder.ready( + testing.allocator, + testing.io, + test_decode_options, + ); + defer decoded.deinit(testing.allocator); + const restored = &decoded.terminal.?; + restored.screens.remove(testing.allocator, .alternate); + + const progress = (try decoder.next(testing.allocator, restored)).?; + try testing.expectEqualDeep(Decoder.Progress{ + .key = .alternate, + .rows = 0, + .remaining = 0, + }, progress); + try testing.expect(try decoder.next(testing.allocator, restored) == null); + } + + // A screen removed and recreated since READY has a new generation, so + // stale history never mixes into its replacement's content. + { + var source: std.Io.Reader = .fixed(crafted.written()); + var decoder: Decoder = .init(&source); + var decoded = try decoder.ready( + testing.allocator, + testing.io, + test_decode_options, + ); + defer decoded.deinit(testing.allocator); + const restored = &decoded.terminal.?; + restored.screens.remove(testing.allocator, .alternate); + _ = try restored.switchScreen(.alternate); + + const progress = (try decoder.next(testing.allocator, restored)).?; + try testing.expectEqual(TerminalScreenKey.alternate, progress.key); + try testing.expectEqual(0, progress.rows); + try testing.expect(try decoder.next(testing.allocator, restored) == null); + try testing.expectEqual( + @as(usize, 1), + restored.screens.get(.alternate).?.pages.totalPages(), + ); + } +} + +test "incremental decode discards history after a resize" { + const testing = std.testing; + + var t = try testHistoryTerminal(2); + defer t.deinit(testing.allocator); + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + try encode(testing.allocator, &encoded.writer, &t, test_encode_options); + + var source: std.Io.Reader = .fixed(encoded.written()); + var decoder: Decoder = .init(&source); + var decoded = try decoder.ready( + testing.allocator, + testing.io, + test_decode_options, + ); + defer decoded.deinit(testing.allocator); + var restored = decoded.toOwned(); + defer restored.deinit(testing.allocator); + + // Encoded pages assume the READY width. After a live resize reflows the + // restored screen, pending history is consumed but never applied. + try restored.resize(testing.allocator, .{ .cols = 3, .rows = 3 }); + const primary = restored.screens.get(.primary).?; + const resized_total = primary.pages.scrollbar().total; + + var pages: usize = 0; + while (try decoder.next(testing.allocator, &restored)) |progress| { + try testing.expectEqual(TerminalScreenKey.primary, progress.key); + try testing.expectEqual(0, progress.rows); + pages += 1; + } + try testing.expectEqual(2, pages); + try testing.expectEqual(resized_total, primary.pages.scrollbar().total); +} + +test "decode drops history beyond the declared scrollback limits" { + const testing = std.testing; + + // Declare a byte limit the complete history cannot satisfy. The limit + // is validated at page granularity on decode, so the snapshot itself + // remains well formed and fully validated. + var t = try testHistoryTerminal(3); + defer t.deinit(testing.allocator); + t.screens.get(.primary).?.pages.limits.set(.bytes, 1); + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + try encode(testing.allocator, &encoded.writer, &t, test_encode_options); + + // Incremental: the newest page fits the effective floor, the next page + // exceeds it, and one drop discards the remainder of the sequence so no + // ordering gap can form. + { + var source: std.Io.Reader = .fixed(encoded.written()); + var decoder: Decoder = .init(&source); + var decoded = try decoder.ready( + testing.allocator, + testing.io, + test_decode_options, + ); + defer decoded.deinit(testing.allocator); + const restored = &decoded.terminal.?; + + var rows: [3]usize = undefined; + var pages: usize = 0; + while (try decoder.next(testing.allocator, restored)) |progress| { + rows[pages] = progress.rows; + pages += 1; + } + try testing.expectEqual(3, pages); + try testing.expectEqualSlices(usize, &.{ 2, 0, 0 }, &rows); + + const primary = restored.screens.get(.primary).?; + try testing.expectEqual(@as(usize, 2), primary.pages.totalPages()); + try testing.expectEqual(@as(u21, 'C'), testTopLeftCodepoint(primary)); + } + + // The one-shot decoder degrades identically instead of rejecting the + // snapshot. + { + var source: std.Io.Reader = .fixed(encoded.written()); + var decoded = try decode( + testing.allocator, + testing.io, + &source, + test_decode_options, + ); + defer decoded.deinit(testing.allocator); + const primary = decoded.terminal.?.screens.get(.primary).?; + try testing.expectEqual(@as(usize, 2), primary.pages.totalPages()); + try testing.expectEqual(@as(u21, 'C'), testTopLeftCodepoint(primary)); + } +} + +test "incremental decode failure leaves applied history usable" { + const testing = std.testing; + + var t = try testHistoryTerminal(2); + defer t.deinit(testing.allocator); + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + try encode(testing.allocator, &encoded.writer, &t, test_encode_options); + + // Truncate FINISH. READY and both history pages decode, then the + // failure surfaces on the call that would have completed the snapshot. + var source: std.Io.Reader = .fixed( + encoded.written()[0 .. encoded.written().len - 1], + ); + var decoder: Decoder = .init(&source); + var decoded = try decoder.ready( + testing.allocator, + testing.io, + test_decode_options, + ); + defer decoded.deinit(testing.allocator); + var restored = decoded.toOwned(); + defer restored.deinit(testing.allocator); + + for (0..2) |_| { + const progress = (try decoder.next(testing.allocator, &restored)).?; + try testing.expectEqual(2, progress.rows); + } + try testing.expectError( + error.EndOfStream, + decoder.next(testing.allocator, &restored), + ); + try testing.expectError( + error.DecoderFailed, + decoder.next(testing.allocator, &restored), + ); + + // The transferred terminal was never owned by the decoder: it remains + // valid with the contiguous history prefix that did apply; only the + // incomplete snapshot stream is lost. + const primary = restored.screens.get(.primary).?; + try testing.expectEqual(@as(usize, 3), primary.pages.totalPages()); + try testing.expectEqual(@as(u21, 'A'), testTopLeftCodepoint(primary)); + primary.pages.assertIntegrity(); + primary.assertIntegrity(); +} diff --git a/src/terminal/snapshot/style.zig b/src/terminal/snapshot/style.zig index 6df8ba03c..176190b75 100644 --- a/src/terminal/snapshot/style.zig +++ b/src/terminal/snapshot/style.zig @@ -172,6 +172,27 @@ pub fn decodeOrDiscard( return decode(&source); } +/// Decode one complete entry, returning null for invalid semantic contents. +/// +/// Reader errors remain structural and are always propagated. This is the +/// lenient entry point for enclosing codecs which can safely replace an +/// invalid fixed-size style without treating truncation as invalid styling. +pub fn decodeOrNull( + reader: *std.Io.Reader, +) std.Io.Reader.Error!?terminal_style.Style { + return decodeOrDiscard(reader) catch |err| switch (err) { + error.ReadFailed => return error.ReadFailed, + error.EndOfStream => return error.EndOfStream, + + error.InvalidColorKind, + error.InvalidColor, + error.InvalidUnderline, + error.InvalidFlags, + error.InvalidReserved, + => null, + }; +} + fn encodeColor( value: terminal_style.Style.Color, writer: *std.Io.Writer, @@ -383,6 +404,16 @@ test "decodeOrDiscard preserves the next entry boundary" { try std.testing.expectEqual(@as(u8, 0xFF), try reader.takeByte()); } +test "decodeOrNull distinguishes semantic errors from truncation" { + var invalid: [len]u8 = @splat(0); + invalid[0] = 3; + var invalid_reader: std.Io.Reader = .fixed(&invalid); + try std.testing.expectEqual(null, try decodeOrNull(&invalid_reader)); + + var truncated: std.Io.Reader = .fixed(invalid[0 .. len - 1]); + try std.testing.expectError(error.EndOfStream, decodeOrNull(&truncated)); +} + test "reject every truncation" { const fixture = [_]u8{0} ** len; for (0..len) |fixture_len| { diff --git a/src/terminal/snapshot/testdata/checkpoint-ready-v1.hex b/src/terminal/snapshot/testdata/checkpoint-ready-v1.hex index 51381196c..3ff7c5d02 100644 --- a/src/terminal/snapshot/testdata/checkpoint-ready-v1.hex +++ b/src/terminal/snapshot/testdata/checkpoint-ready-v1.hex @@ -6,12 +6,5 @@ # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. -# prefix covered by READY -61 62 63 - -# READY record header: tag, payload length, CRC32C -05 00 20 00 00 00 90 5b 2d 06 - -# BLAKE3-256 digest of the prefix -64 37 b3 ac 38 46 51 33 ff b6 3b 75 27 3a 8d b5 -48 c5 58 46 5d 79 db 03 fd 35 9c 6c d5 bd 9d 85 +# offset 0x00000000: encoded bytes +61 62 63 05 00 00 00 00 00 e4 20 ef 0a # 0x00000000 diff --git a/src/terminal/snapshot/testdata/complete-v1.hex b/src/terminal/snapshot/testdata/complete-v1.hex index 7731a9c9d..02df1fd05 100644 --- a/src/terminal/snapshot/testdata/complete-v1.hex +++ b/src/terminal/snapshot/testdata/complete-v1.hex @@ -78,62 +78,43 @@ ee ee 80 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000037a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000003ec 00 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000003fc -# offset 0x0000040c: page record, payload 119 bytes -03 00 77 00 00 00 48 b7 91 cb 02 00 03 00 00 00 # 0x0000040c -00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 00 # 0x0000041c -00 00 00 00 00 00 00 43 00 00 00 00 00 00 00 00 # 0x0000042c -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000043c -00 00 00 00 00 00 00 00 44 00 00 00 00 00 00 00 # 0x0000044c -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000045c -00 00 00 00 00 00 00 00 00 45 00 00 00 00 00 00 # 0x0000046c -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000047c -00 # 0x0000048c +# offset 0x0000040c: page record, payload 36 bytes +03 00 24 00 00 00 a2 4f 26 d1 02 00 03 00 00 00 # 0x0000040c +00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 01 # 0x0000041c +00 43 00 01 00 44 00 01 00 45 00 00 00 00 # 0x0000042c -# offset 0x0000048d: screen record, payload 54 bytes -02 00 36 00 00 00 ce 1f 9f 08 01 00 01 00 00 00 # 0x0000048d -00 00 00 00 00 00 01 00 02 00 01 00 00 00 00 00 # 0x0000049d -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000004ad -00 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000004bd +# offset 0x0000043a: screen record, payload 54 bytes +02 00 36 00 00 00 ce 1f 9f 08 01 00 01 00 00 00 # 0x0000043a +00 00 00 00 00 00 01 00 02 00 01 00 00 00 00 00 # 0x0000044a +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000045a +00 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000046a -# offset 0x000004cd: page record, payload 119 bytes -03 00 77 00 00 00 84 a6 e9 08 02 00 03 00 00 00 # 0x000004cd -00 00 80 00 c0 00 00 02 00 00 00 08 00 00 03 00 # 0x000004dd -00 00 00 00 00 00 00 72 00 00 00 00 00 00 00 00 # 0x000004ed -00 00 00 00 00 00 00 6e 00 00 00 00 00 00 00 03 # 0x000004fd -00 00 00 00 00 00 00 00 61 00 00 00 00 00 00 00 # 0x0000050d -00 00 00 00 00 00 00 00 74 00 00 00 00 00 00 00 # 0x0000051d -03 00 00 00 00 00 00 00 00 65 00 00 00 00 00 00 # 0x0000052d -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000053d -00 # 0x0000054d +# offset 0x0000047a: page record, payload 38 bytes +03 00 26 00 00 00 45 61 97 32 02 00 03 00 00 00 # 0x0000047a +00 00 80 00 c0 00 00 02 00 00 00 08 00 00 03 02 # 0x0000048a +00 72 6e 03 02 00 61 74 03 01 00 65 00 00 00 00 # 0x0000049a -# offset 0x0000054e: ready record, payload 32 bytes -05 00 20 00 00 00 43 0d 7b 3a 84 06 56 ba 02 59 # 0x0000054e -04 24 20 59 96 92 14 ba 76 53 80 40 09 12 85 a7 # 0x0000055e -f2 b5 b8 f9 63 98 e8 dc 9a 39 # 0x0000056e +# offset 0x000004aa: continuation record, payload 0 bytes +07 00 00 00 00 00 27 80 63 d1 # 0x000004aa -# offset 0x00000578: history record, payload 6 bytes -04 00 06 00 00 00 20 32 ed e1 00 00 02 00 00 00 # 0x00000578 +# offset 0x000004b4: ready record, payload 0 bytes +05 00 00 00 00 00 e4 20 ef 0a # 0x000004b4 -# offset 0x00000588: page record, payload 86 bytes -03 00 56 00 00 00 52 3b 6e 8d 02 00 02 00 00 00 # 0x00000588 -00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 00 # 0x00000598 -00 00 00 00 00 00 00 42 00 00 00 00 00 00 00 00 # 0x000005a8 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005b8 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005c8 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005d8 +# offset 0x000004be: history record, payload 6 bytes +04 00 06 00 00 00 20 32 ed e1 00 00 02 00 00 00 # 0x000004be -# offset 0x000005e8: page record, payload 86 bytes -03 00 56 00 00 00 fb bc 15 06 02 00 02 00 00 00 # 0x000005e8 -00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 00 # 0x000005f8 -00 00 00 00 00 00 00 41 00 00 00 00 00 00 00 00 # 0x00000608 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000618 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000628 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000638 +# offset 0x000004ce: page record, payload 31 bytes +03 00 1f 00 00 00 4a ed 2f c2 02 00 02 00 00 00 # 0x000004ce +00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 01 # 0x000004de +00 42 00 00 00 00 00 00 00 # 0x000004ee -# offset 0x00000648: history record, payload 6 bytes -04 00 06 00 00 00 b8 7a ba b1 01 00 00 00 00 00 # 0x00000648 +# offset 0x000004f7: page record, payload 31 bytes +03 00 1f 00 00 00 23 6a 6b 19 02 00 02 00 00 00 # 0x000004f7 +00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 01 # 0x00000507 +00 41 00 00 00 00 00 00 00 # 0x00000517 -# offset 0x00000658: finish record, payload 32 bytes -06 00 20 00 00 00 8d d6 46 7a ac 02 c8 09 9d 1a # 0x00000658 -5a 00 ce 47 5e d7 b0 ae 54 10 bb 0d f1 35 7c ab # 0x00000668 -92 fd ae d3 5e 1a 77 1e 40 7f # 0x00000678 +# offset 0x00000520: history record, payload 6 bytes +04 00 06 00 00 00 b8 7a ba b1 01 00 00 00 00 00 # 0x00000520 + +# offset 0x00000530: finish record, payload 0 bytes +06 00 00 00 00 00 3e eb 53 3e # 0x00000530 diff --git a/src/terminal/snapshot/testdata/continuation-apc-v1.hex b/src/terminal/snapshot/testdata/continuation-apc-v1.hex new file mode 100644 index 000000000..4e3054bbe --- /dev/null +++ b/src/terminal/snapshot/testdata/continuation-apc-v1.hex @@ -0,0 +1,8 @@ +# Ghostty snapshot fixture +# Kaitai type: continuation_record +# Kaitai params: +# Kaitai offset: 0 +# Wire version: 1 + +# Incomplete Kitty APC payload. +07 00 07 00 00 00 bd ff a8 ff 1b 5f 47 64 61 74 61 diff --git a/src/terminal/snapshot/testdata/continuation-csi-v1.hex b/src/terminal/snapshot/testdata/continuation-csi-v1.hex new file mode 100644 index 000000000..b83aceca2 --- /dev/null +++ b/src/terminal/snapshot/testdata/continuation-csi-v1.hex @@ -0,0 +1,8 @@ +# Ghostty snapshot fixture +# Kaitai type: continuation_record +# Kaitai params: +# Kaitai offset: 0 +# Wire version: 1 + +# Incomplete SGR CSI. +07 00 04 00 00 00 30 d5 20 17 1b 5b 33 31 diff --git a/src/terminal/snapshot/testdata/continuation-dcs-v1.hex b/src/terminal/snapshot/testdata/continuation-dcs-v1.hex new file mode 100644 index 000000000..e8e3ebda2 --- /dev/null +++ b/src/terminal/snapshot/testdata/continuation-dcs-v1.hex @@ -0,0 +1,8 @@ +# Ghostty snapshot fixture +# Kaitai type: continuation_record +# Kaitai params: +# Kaitai offset: 0 +# Wire version: 1 + +# Incomplete supported DCS payload. +07 00 07 00 00 00 63 ae 0a 1d 1b 50 71 64 61 74 61 diff --git a/src/terminal/snapshot/testdata/continuation-esc-v1.hex b/src/terminal/snapshot/testdata/continuation-esc-v1.hex new file mode 100644 index 000000000..0ca1d5b17 --- /dev/null +++ b/src/terminal/snapshot/testdata/continuation-esc-v1.hex @@ -0,0 +1,8 @@ +# Ghostty snapshot fixture +# Kaitai type: continuation_record +# Kaitai params: +# Kaitai offset: 0 +# Wire version: 1 + +# Bare ESC leaves the VT parser unfinished. +07 00 01 00 00 00 1c d9 1d 17 1b diff --git a/src/terminal/snapshot/testdata/continuation-ground-v1.hex b/src/terminal/snapshot/testdata/continuation-ground-v1.hex new file mode 100644 index 000000000..42463c358 --- /dev/null +++ b/src/terminal/snapshot/testdata/continuation-ground-v1.hex @@ -0,0 +1,8 @@ +# Ghostty snapshot fixture +# Kaitai type: continuation_record +# Kaitai params: +# Kaitai offset: 0 +# Wire version: 1 + +# Empty payload is the explicit ground-state assertion. +07 00 00 00 00 00 27 80 63 d1 diff --git a/src/terminal/snapshot/testdata/continuation-osc-v1.hex b/src/terminal/snapshot/testdata/continuation-osc-v1.hex new file mode 100644 index 000000000..10d6aef44 --- /dev/null +++ b/src/terminal/snapshot/testdata/continuation-osc-v1.hex @@ -0,0 +1,8 @@ +# Ghostty snapshot fixture +# Kaitai type: continuation_record +# Kaitai params: +# Kaitai offset: 0 +# Wire version: 1 + +# Incomplete title OSC. +07 00 09 00 00 00 39 7d 6e 08 1b 5d 32 3b 74 69 74 6c 65 diff --git a/src/terminal/snapshot/testdata/continuation-utf8-v1.hex b/src/terminal/snapshot/testdata/continuation-utf8-v1.hex new file mode 100644 index 000000000..dc07c803e --- /dev/null +++ b/src/terminal/snapshot/testdata/continuation-utf8-v1.hex @@ -0,0 +1,8 @@ +# Ghostty snapshot fixture +# Kaitai type: continuation_record +# Kaitai params: +# Kaitai offset: 0 +# Wire version: 1 + +# Partial four-byte UTF-8 codepoint. +07 00 03 00 00 00 b8 07 2a 22 f0 9f 98 diff --git a/src/terminal/snapshot/testdata/grid-v1.hex b/src/terminal/snapshot/testdata/grid-v1.hex index 3389a876f..b9a74af32 100644 --- a/src/terminal/snapshot/testdata/grid-v1.hex +++ b/src/terminal/snapshot/testdata/grid-v1.hex @@ -1,32 +1,15 @@ # Ghostty snapshot fixture # Kaitai type: grid -# Kaitai params: 2 3 +# Kaitai params: 4 3 # Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. -# row 0: prompt -04 - -# row 0, cell 0: protected prompt, wide "A" -00 01 05 00 00 00 00 00 41 00 00 00 00 00 00 00 - -# row 0, cell 1: input spacer tail -00 02 02 00 00 00 00 00 00 00 00 00 00 00 00 00 - -# row 0, cell 2: "x" with two grapheme suffixes -00 00 00 00 00 00 00 00 78 00 00 00 02 00 00 00 -01 03 00 00 02 03 00 00 - -# row 1: wrapped prompt continuation -0b - -# row 1, cell 0: palette background 7 -01 00 00 00 00 00 00 00 07 00 00 00 00 00 00 00 - -# row 1, cell 1: protected RGB background -02 00 01 00 00 00 00 00 aa bb cc 00 00 00 00 00 - -# row 1, cell 2: spacer head -00 03 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +# offset 0x00000000: encoded bytes +34 03 00 04 01 00 00 00 94 00 00 00 00 00 00 00 # 0x00000000 +48 00 00 e1 01 00 00 00 00 00 00 3b 03 00 1e 00 # 0x00000010 +00 00 00 00 00 00 ab ee 32 03 00 10 00 00 00 00 # 0x00000020 +00 00 00 0c 00 00 00 03 00 68 00 69 10 02 00 16 # 0x00000030 +04 21 00 01 00 00 00 00 00 02 00 02 00 01 03 00 # 0x00000040 +00 02 03 00 00 # 0x00000050 diff --git a/src/terminal/snapshot/testdata/page-empty-record-v1.hex b/src/terminal/snapshot/testdata/page-empty-record-v1.hex index 4b759b704..cda4f1d91 100644 --- a/src/terminal/snapshot/testdata/page-empty-record-v1.hex +++ b/src/terminal/snapshot/testdata/page-empty-record-v1.hex @@ -7,6 +7,6 @@ # On mismatch, the candidate is copied to the repository root. # offset 0x00000000: encoded bytes -03 00 25 00 00 00 8c 05 d6 d3 01 00 01 00 00 00 # 0x00000000 +03 00 1b 00 00 00 5e 0d 0a d4 01 00 01 00 00 00 # 0x00000000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000010 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000020 +00 00 00 00 00 # 0x00000020 diff --git a/src/terminal/snapshot/testdata/page-v1.hex b/src/terminal/snapshot/testdata/page-v1.hex index dfc53eeaa..78018ebb6 100644 --- a/src/terminal/snapshot/testdata/page-v1.hex +++ b/src/terminal/snapshot/testdata/page-v1.hex @@ -15,10 +15,8 @@ 00 00 03 00 00 00 00 00 01 2a 00 00 00 00 00 00 # 0x00000024 00 00 00 00 01 00 02 01 00 00 00 61 05 00 00 00 # 0x00000034 61 6c 70 68 61 03 00 01 04 03 02 01 04 00 00 00 # 0x00000044 -62 65 74 61 04 00 01 05 00 01 00 01 00 41 00 00 # 0x00000054 -00 00 00 00 00 00 02 02 00 03 00 03 00 00 00 00 # 0x00000064 -00 00 00 00 00 01 00 00 00 01 00 01 00 07 00 00 # 0x00000074 -00 00 00 00 00 0b 00 00 00 00 00 00 00 00 78 00 # 0x00000084 -00 00 02 00 00 00 01 03 00 00 02 03 00 00 02 00 # 0x00000094 -01 00 00 00 00 00 aa bb cc 00 00 00 00 00 00 03 # 0x000000a4 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000000b4 +62 65 74 61 34 03 00 04 01 00 04 00 b4 01 00 00 # 0x00000054 +00 00 0c 00 68 03 00 1e 00 00 04 00 20 01 00 3b # 0x00000064 +03 00 e1 01 00 00 00 00 00 00 ab ee 32 03 00 10 # 0x00000074 +00 00 00 00 00 00 00 0c 00 00 01 00 00 00 01 00 # 0x00000084 +00 00 02 00 01 03 00 00 02 03 00 00 # 0x00000094 diff --git a/src/terminal/snapshot/verify-kaitai.py b/src/terminal/snapshot/verify-kaitai.py index fb3c750fe..65df64c93 100755 --- a/src/terminal/snapshot/verify-kaitai.py +++ b/src/terminal/snapshot/verify-kaitai.py @@ -18,8 +18,8 @@ https://ide.kaitai.io/. The generated Python parser exists only in a temporary directory. This keeps snapshot.ksy as the source of truth and ensures this check cannot accidentally pass against stale generated code. After structural parsing, the script checks -record CRC32C values, checkpoint BLAKE3 digests, and cross-record invariants -that portable Kaitai expressions cannot represent. +record CRC32C values and cross-record invariants that portable Kaitai +expressions cannot represent. """ from __future__ import annotations @@ -37,7 +37,6 @@ from pathlib import Path from typing import Any try: - from blake3 import blake3 from kaitaistruct import KaitaiStream except ImportError as error: raise SystemExit( @@ -159,7 +158,7 @@ def crc32c(data: bytes) -> int: def validate_record(record: Any, data: bytes, offset: int) -> int: """Validate one parsed record's source bytes and CRC32C.""" - payload = record._raw_payload + payload = getattr(record, "_raw_payload", record.payload) header = record.header if header.payload_length != len(payload): raise ValueError( @@ -189,11 +188,12 @@ def validate_record(record: Any, data: bytes, offset: int) -> int: def all_snapshot_records(snapshot: Any) -> list[Any]: - """Return complete-snapshot records in their authenticated wire order.""" + """Return complete-snapshot records in wire order.""" records = [snapshot.terminal] for sequence in snapshot.screens: records.append(sequence.screen) records.extend(sequence.pages) + records.append(snapshot.continuation) records.append(snapshot.ready) for sequence in snapshot.histories: records.append(sequence.history) @@ -203,7 +203,7 @@ def all_snapshot_records(snapshot: Any) -> list[Any]: def validate_complete_snapshot(snapshot: Any, data: bytes) -> None: - """Validate ordering relationships, record CRCs, and checkpoints.""" + """Validate ordering relationships, record CRCs, and markers.""" screen_keys = [ sequence.screen.payload.header.key for sequence in snapshot.screens ] @@ -281,14 +281,6 @@ def validate_complete_snapshot(snapshot: Any, data: bytes) -> None: offset = SNAPSHOT_ENVELOPE_SIZE for record in all_snapshot_records(snapshot): - if record is snapshot.ready: - expected = blake3(data[:offset]).digest() - if record.payload.prefix_digest != expected: - raise ValueError("READY BLAKE3-256 digest does not match") - elif record is snapshot.finish: - expected = blake3(data[:offset]).digest() - if record.payload.prefix_digest != expected: - raise ValueError("FINISH BLAKE3-256 digest does not match") offset = validate_record(record, data, offset) if offset != len(data): @@ -398,15 +390,6 @@ def main() -> int: raise ValueError( f"{fixture_label}: record has trailing data" ) - if hasattr(parsed.payload, "prefix_digest"): - expected = blake3( - fixture.data[:fixture.offset] - ).digest() - if parsed.payload.prefix_digest != expected: - raise ValueError( - f"{fixture_label}: checkpoint digest does not match" - ) - print(f"ok {fixture_label}") print(f"validated {len(fixture_paths)} snapshot fixtures") diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 791d0c55d..729b6fc04 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -16,6 +16,7 @@ const kitty = @import("kitty.zig"); const modes = @import("modes.zig"); const osc = @import("osc.zig"); const sgr = @import("sgr.zig"); +const continuationpkg = @import("stream_continuation.zig"); const UTF8Decoder = @import("UTF8Decoder.zig"); const MouseShape = @import("mouse.zig").Shape; @@ -109,7 +110,7 @@ pub const Action = union(Key) { dcs_put: u8, dcs_unhook, apc_start, - apc_end, + apc_end: ApcEnd, apc_put: u8, apc_put_slice: ApcPutSlice, end_hyperlink, @@ -290,6 +291,11 @@ pub const Action = union(Key) { } }; + pub const ApcEnd = extern struct { + /// False when CAN, SUB, or another aborting transition ended the APC. + terminated: bool, + }; + pub const InvokeCharset = lib.Struct(lib.target, struct { bank: charsets.ActiveSlot, charset: charsets.Slots, @@ -476,46 +482,174 @@ pub fn Stream(comptime H: type) type { handler: Handler, parser: Parser, utf8decoder: UTF8Decoder, + continuation: ?continuationpkg.Tracker, - /// Initialize an allocation-free stream. This will preallocate various - /// sizes as necessary and anything over that will be dropped. If you - /// want to support more dynamic behavior use initAlloc instead. + pub const Options = struct { + /// The handler initial value, must be set. + handler: Handler, + + /// Allocator to use. If this is not set then the stream + /// will be fully allocation free. There are some operations + /// that will be dropped in this case such as OSC 52 clipboard + /// ops. + allocator: ?Allocator = null, + + /// Maximum size in bytes of the continuation suffix. If this is + /// null or zero then continuation tracking is disabled. This is + /// only applied when `allocator` is non-null; without an allocator + /// continuation tracking is disabled. Feeding this continuation + /// suffix into an equivalent stream at ground reconstructs the + /// unfinished state without repeating committed terminal effects. + /// Continuation tracking is only supported by TerminalStream. + continuation_max_bytes: ?usize = null, + }; + + /// Initialize a stream. Without an allocator, operations that require + /// heap allocation are dropped. /// /// As a concrete example of something that requires heap allocation, /// consider OSC 52 (clipboard operations) which can be arbitrarily /// large. /// - /// If you want to limit allocation size, use an allocator with - /// a size limit with initAlloc. - /// /// This takes ownership of the handler and will call deinit /// when the stream is deinitialized. - pub fn init(h: Handler) Self { + pub fn init(options: Options) Self { + // Initialize the parser + var parser: Parser = .init(); + if (options.allocator) |alloc| parser.osc_parser.alloc = alloc; + + // Initialize the continuation tracker if one is requested. + var tracker: ?continuationpkg.Tracker = null; + if (options.allocator) |alloc| { + if (options.continuation_max_bytes) |max_bytes| { + if (max_bytes > 0) tracker = .init(alloc, max_bytes); + } + } + return .{ - .handler = h, - .parser = .init(), + .handler = options.handler, + .parser = parser, .utf8decoder = .{}, + .continuation = tracker, }; } - /// Initialize the stream that supports heap allocation as necessary. - pub fn initAlloc(alloc: Allocator, h: Handler) Self { - var self: Self = .init(h); - self.parser.osc_parser.alloc = alloc; - return self; - } - pub fn deinit(self: *Self) void { + if (self.continuation) |*tracker| tracker.deinit(); self.parser.deinit(); self.handler.deinit(); } + /// Write the current continuation suffix directly to a caller-owned + /// writer. The caller must pause and serialize access to this Stream. + pub fn writeContinuation( + self: *const Self, + writer: *std.Io.Writer, + ) (std.Io.Writer.Error || error{ + ContinuationDisabled, + ContinuationUnavailable, + })!void { + const tracker = self.continuation orelse + return error.ContinuationDisabled; + if (tracker.broken) + return error.ContinuationUnavailable; + try tracker.write(writer); + } + + /// True when no continuation suffix is needed to reproduce the + /// stream's current parsing state. + pub inline fn ground(self: *const Self) bool { + // Parser ground alone is not sufficient because the UTF-8 + // decoder may have some state. + return self.parser.state == .ground and self.utf8decoder.state == 0; + } + + /// Update the continuation suffix after one complete feed call. + /// Must only be called when tracking is enabled. + fn trackContinuation(self: *Self, input: []const u8) void { + const tracker = &self.continuation.?; + + // If we're in a ground state, we have no continuation suffix + // to track by definition. + if (self.ground()) { + tracker.reset(); + return; + } + + // Retain the part of this feed needed to replay the unfinished + // state. When the parser is grounded here, the feed must have + // ended inside a UTF-8 codepoint instead, because the ground + // check above covers both state machines. + tracker.append( + if (self.parser.state != .ground) .vt else .utf8, + input, + ); + } + /// Process a string of characters. pub inline fn nextSlice(self: *Self, input: []const u8) void { + self.nextSliceUntracked(input); + + // Continuation tracking is opt-in and this branch predicts + // perfectly, so disabled streams pay nothing else here. + if (self.continuation != null) self.trackContinuation(input); + } + + /// Process a string of characters, but only the shortest prefix + /// needed to reach the ground state. + /// + /// The ground state is when the stream isn't in the middle of any + /// type of sequence: UTF-8, ESC, CSI, OSC, etc. It is the stateless + /// point of the stream. + /// + /// If the stream is already at ground then this consumes nothing + /// and returns zero. A non-null return is the number of bytes consumed + /// before reaching ground, including the byte that reaches it. A null + /// return means the full slice was consumed without reaching ground. + /// + /// This is anywhere from 1% to 5% slower than nextSlice, depending + /// on the types of inputs provided (e.g. OSC vs APC, whether it + /// ever reaches ground, etc.). + pub inline fn nextSliceUntilGround( + self: *Self, + input: []const u8, + ) ?usize { + const consumed = self.nextSliceUntilGroundUntracked(input); + const consumed_len = consumed orelse input.len; + if (self.continuation != null and consumed_len > 0) { + self.trackContinuation(input[0..consumed_len]); + } + + return consumed; + } + + inline fn nextSliceUntilGroundUntracked( + self: *Self, + input: []const u8, + ) ?usize { + if (self.ground()) return 0; + + // Process UTF-8 if we're within that state. + var offset: usize = 0; + while (self.utf8decoder.state != 0) { + if (offset >= input.len) return null; + self.nextUtf8(input[offset]); + offset += 1; + } + + // Process non-UTF-8 + if (self.parser.state != .ground) { + offset += self.consumeUntilGround(input[offset..]); + } + + return if (self.ground()) offset else null; + } + + inline fn nextSliceUntracked(self: *Self, input: []const u8) void { // Disable SIMD optimizations if build requests it or if our // manual debug mode is on. if (comptime debug or !build_options.simd) { - for (input) |c| self.next(c); + for (input) |c| self.nextUntracked(c); return; } @@ -687,8 +821,29 @@ pub fn Stream(comptime H: type) type { if (self.parser.state == .sos_pm_apc_string) { offset += self.consumeApcString(input[offset..]); if (offset >= input.len) return input.len; - // The next byte exits the string state; let - // nextNonUtf8 below handle it. + + // Fast-path normal string termination. This matches + // Parser.next's exit and entry actions while avoiding + // the generic action loop for every completed APC. + switch (input[offset]) { + std.ascii.control_code.esc => { + self.parser.clear(); + self.parser.state = .escape; + self.handler.vt(.apc_end, .{ .terminated = true }); + offset += 1; + continue; + }, + 0x9C => { + self.parser.state = .ground; + self.handler.vt(.apc_end, .{ .terminated = true }); + offset += 1; + continue; + }, + else => {}, + } + + // Aborting transitions need the scalar path so the + // handler can distinguish them from terminators. } } @@ -848,6 +1003,11 @@ pub fn Stream(comptime H: type) type { /// operation that can't use SIMD. Prefer nextSlice if you can and /// try to get multiple bytes at once. pub inline fn next(self: *Self, c: u8) void { + self.nextUntracked(c); + if (self.continuation != null) self.trackContinuation(&.{c}); + } + + inline fn nextUntracked(self: *Self, c: u8) void { // The scalar path can be responsible for decoding UTF-8. if (self.parser.state == .ground) { self.nextUtf8(c); @@ -893,7 +1053,7 @@ pub fn Stream(comptime H: type) type { // We need to increase the eval branch limit because a lot of // tests end up running almost completely at comptime due to // a chain of inline functions. - @setEvalBranchQuota(100_000); + @setEvalBranchQuota(200_000); // C0 control if (c <= 0xF) { @@ -1025,7 +1185,9 @@ pub fn Stream(comptime H: type) type { .dcs_unhook => self.handler.vt(.dcs_unhook, {}), .apc_start => self.handler.vt(.apc_start, {}), .apc_put => |code| self.handler.vt(.apc_put, code), - .apc_end => self.handler.vt(.apc_end, {}), + .apc_end => self.handler.vt(.apc_end, .{ + .terminated = c == std.ascii.control_code.esc or c == 0x9C, + }), } } } @@ -2802,7 +2964,7 @@ test "stream: print" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.next('x'); try testing.expectEqual(@as(u21, 'x'), s.handler.c.?); } @@ -2824,7 +2986,7 @@ test "simd: print invalid utf-8" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice(&.{0xFF}); try testing.expectEqual(@as(u21, 0xFFFD), s.handler.c.?); } @@ -2846,7 +3008,7 @@ test "simd: complete incomplete utf-8" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice(&.{0xE0}); // 3 byte try testing.expect(s.handler.c == null); s.nextSlice(&.{0xA0}); // still incomplete @@ -2871,7 +3033,7 @@ test "stream: cursor right (CUF)" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1B[C"); try testing.expectEqual(@as(u16, 1), s.handler.amount); @@ -2904,7 +3066,7 @@ test "stream: dec set mode (SM) and reset mode (RM)" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1B[?6h"); try testing.expectEqual(@as(modes.Mode, .origin), s.handler.mode); @@ -2933,7 +3095,7 @@ test "stream: ansi set mode (SM) and reset mode (RM)" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1B[4h"); try testing.expectEqual(@as(modes.Mode, .insert), s.handler.mode.?); @@ -2964,7 +3126,7 @@ test "stream: ansi set mode (SM) and reset mode (RM) with unknown value" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1B[6h"); try testing.expect(s.handler.mode == null); @@ -2990,7 +3152,7 @@ test "stream: restore mode" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); for ("\x1B[?42r") |c| s.next(c); try testing.expect(!s.handler.called); } @@ -3012,7 +3174,7 @@ test "stream: pop kitty keyboard with no params defaults to 1" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); for ("\x1B[2s"); try testing.expect(s.handler.escape == null); @@ -3340,13 +3502,13 @@ test "stream: change window title with invalid utf-8" { }; { - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b]2;abc\x1b\\"); try testing.expect(s.handler.seen); } { - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b]2;abc\xc0\x1b\\"); try testing.expect(!s.handler.seen); } @@ -3370,7 +3532,7 @@ test "stream: insert characters" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); for ("\x1B[42@") |c| s.next(c); try testing.expect(s.handler.called); @@ -3396,7 +3558,7 @@ test "stream: insert characters explicit zero clamps to 1" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); for ("\x1B[0@") |c| s.next(c); try testing.expectEqual(@as(usize, 1), s.handler.value.?); } @@ -3420,7 +3582,7 @@ test "stream: SCOSC" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); for ("\x1B[s") |c| s.next(c); try testing.expect(s.handler.called); } @@ -3443,7 +3605,7 @@ test "stream: SCORC" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); for ("\x1B[u") |c| s.next(c); try testing.expect(s.handler.called); } @@ -3464,7 +3626,7 @@ test "stream: too many csi params" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1B[1;1;1;1;1;1;1;1;1;1;1;1;1;1;1;1;1C"); } @@ -3481,7 +3643,7 @@ test "stream: csi param too long" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1B[1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111C"); } @@ -3501,7 +3663,7 @@ test "stream: send report with CSI t" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[14t"); try testing.expectEqual(csi.SizeReportStyle.csi_14_t, s.handler.style); @@ -3535,7 +3697,7 @@ test "stream: invalid CSI t" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[19t"); try testing.expectEqual(null, s.handler.style); @@ -3557,7 +3719,7 @@ test "stream: CSI t push title" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[22;0t"); try testing.expectEqual(@as(u16, 0), s.handler.index.?); @@ -3579,7 +3741,7 @@ test "stream: CSI t push title with explicit window" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[22;2t"); try testing.expectEqual(@as(u16, 0), s.handler.index.?); @@ -3601,7 +3763,7 @@ test "stream: CSI t push title with explicit icon" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[22;1t"); try testing.expectEqual(null, s.handler.index); @@ -3623,7 +3785,7 @@ test "stream: CSI t push title with index" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[22;0;5t"); try testing.expectEqual(@as(u16, 5), s.handler.index.?); @@ -3645,7 +3807,7 @@ test "stream: CSI t pop title" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[23;0t"); try testing.expectEqual(@as(u16, 0), s.handler.index.?); @@ -3667,7 +3829,7 @@ test "stream: CSI t pop title with explicit window" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[23;2t"); try testing.expectEqual(@as(u16, 0), s.handler.index.?); @@ -3689,7 +3851,7 @@ test "stream: CSI t pop title with explicit icon" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[23;1t"); try testing.expectEqual(null, s.handler.index); @@ -3711,7 +3873,7 @@ test "stream: CSI t pop title with index" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[23;0;5t"); try testing.expectEqual(@as(u16, 5), s.handler.index.?); @@ -3731,7 +3893,7 @@ test "stream CSI W clear tab stops" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[2W"); try testing.expectEqual(Action.Key.tab_clear_current, s.handler.action.?); @@ -3754,7 +3916,7 @@ test "stream CSI W tab set" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[W"); try testing.expectEqual(Action.Key.tab_set, s.handler.action.?); @@ -3786,7 +3948,7 @@ test "stream CSI ? W reset tab stops" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); s.nextSlice("\x1b[?2W"); try testing.expect(s.handler.action == null); @@ -3820,7 +3982,7 @@ test "stream: SGR with 17+ parameters for underline color" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); // Kakoune-style SGR with underline color as 17th parameter // This tests the fix where param 17 was being dropped @@ -3848,7 +4010,7 @@ test "stream: tab clear with overflowing param" { } }; - var s: Stream(H) = .init(.{}); + var s: Stream(H) = .init(.{ .handler = .{} }); // This is the exact input from the fuzz crash (minus the mode byte): // CSI with a huge numeric param that saturates to 65535, followed by 'g'. s.nextSlice("\x1b[388888888888888888888888888888888888g\x1b[0m"); @@ -3888,7 +4050,7 @@ const ApcTestHandler = struct { }; test "stream: apc bulk slice" { - var s: Stream(ApcTestHandler) = .init(.{}); + var s: Stream(ApcTestHandler) = .init(.{ .handler = .{} }); s.nextSlice("\x1b_Gf=24,s=10,v=20;aGVsbG8=\x1b\\"); try testing.expectEqual(@as(usize, 1), s.handler.started); @@ -3905,8 +4067,20 @@ test "stream: apc bulk slice" { } } +test "stream: apc bulk slice C1 ST" { + var s: Stream(ApcTestHandler) = .init(.{ .handler = .{} }); + s.nextSlice("\x1b_Gpayload\x9c"); + + try testing.expectEqual(@as(usize, 1), s.handler.started); + try testing.expectEqual(@as(usize, 1), s.handler.ended); + try testing.expectEqualStrings( + "Gpayload", + s.handler.buf[0..s.handler.len], + ); +} + test "stream: apc bulk slice split across inputs" { - var s: Stream(ApcTestHandler) = .init(.{}); + var s: Stream(ApcTestHandler) = .init(.{ .handler = .{} }); s.nextSlice("\x1b_Gf=24,s=10"); s.nextSlice(",v=20;aGVs"); s.nextSlice("bG8=\x1b\\"); @@ -3920,7 +4094,7 @@ test "stream: apc bulk slice split across inputs" { } test "stream: apc bulk slice keeps C0 bytes as data" { - var s: Stream(ApcTestHandler) = .init(.{}); + var s: Stream(ApcTestHandler) = .init(.{ .handler = .{} }); // BEL does not terminate an APC string; it is payload data. s.nextSlice("\x1b_Gx\x07y\x1b\\"); @@ -3929,7 +4103,7 @@ test "stream: apc bulk slice keeps C0 bytes as data" { } test "stream: apc aborted by CAN" { - var s: Stream(ApcTestHandler) = .init(.{}); + var s: Stream(ApcTestHandler) = .init(.{ .handler = .{} }); // CAN (0x18) aborts the APC string via the anywhere => ground // transition. Exiting the sos_pm_apc_string state emits apc_end, // and the trailing bytes are printed, not treated as APC data. @@ -3944,7 +4118,7 @@ test "stream: apc aborted by CAN" { } test "stream: apc scalar path matches" { - var s: Stream(ApcTestHandler) = .init(.{}); + var s: Stream(ApcTestHandler) = .init(.{ .handler = .{} }); for ("\x1b_Gf=24;aGVsbG8=\x1b\\") |c| s.next(c); try testing.expectEqual(@as(usize, 1), s.handler.started); @@ -3967,9 +4141,9 @@ test "stream: apc vector boundaries match scalar path" { input[4 + position] = '\\'; const bytes = input[0 .. 5 + position]; - var bulk: Stream(ApcTestHandler) = .init(.{}); + var bulk: Stream(ApcTestHandler) = .init(.{ .handler = .{} }); bulk.nextSlice(bytes); - var scalar: Stream(ApcTestHandler) = .init(.{}); + var scalar: Stream(ApcTestHandler) = .init(.{ .handler = .{} }); for (bytes) |byte| scalar.next(byte); try testing.expectEqual(scalar.handler.started, bulk.handler.started); @@ -3980,3 +4154,720 @@ test "stream: apc vector boundaries match scalar path" { ); }; } + +const ContinuationTestHandler = struct { + committed: usize = 0, + apc_active: bool = false, + apc_buf: [256]u8 = undefined, + apc_len: usize = 0, + dcs_active: bool = false, + + pub fn deinit(_: *@This()) void {} + + pub fn vt( + self: *@This(), + comptime action: Action.Tag, + value: Action.Value(action), + ) void { + switch (action) { + .apc_start => self.apc_active = true, + .apc_put => { + self.apc_buf[self.apc_len] = value; + self.apc_len += 1; + }, + .apc_put_slice => { + @memcpy( + self.apc_buf[self.apc_len..][0..value.bytes.len], + value.bytes, + ); + self.apc_len += value.bytes.len; + }, + .dcs_hook => self.dcs_active = true, + .dcs_put => {}, + .apc_end => { + self.apc_active = false; + self.apc_len = 0; + self.committed += 1; + }, + .dcs_unhook => { + self.dcs_active = false; + self.committed += 1; + }, + .print => self.committed += 1, + .print_slice => self.committed += value.cps.len, + .print_repeat => self.committed += value, + else => self.committed += 1, + } + } +}; + +const ContinuationNullHandler = struct { + pub fn deinit(_: *@This()) void {} + + pub fn vt( + _: *@This(), + comptime _: Action.Tag, + _: anytype, + ) void {} +}; + +test "stream: nextSliceUntilGround stops at the earliest boundary" { + const S = Stream(ContinuationTestHandler); + var stream: S = .init(.{ .handler = .{} }); + defer stream.deinit(); + + stream.nextSlice("\x1b[31"); + try testing.expect(!stream.ground()); + stream.handler.committed = 0; + + const input = "mABC\x1b["; + const consumed = stream.nextSliceUntilGround(input).?; + try testing.expectEqual(@as(usize, 1), consumed); + try testing.expect(stream.ground()); + try testing.expectEqual(@as(usize, 1), stream.handler.committed); + + // The suffix was not inspected by the handler and can be processed after + // the caller performs work at the boundary. + stream.nextSlice(input[consumed..]); + try testing.expect(!stream.ground()); + try testing.expectEqual(Parser.State.csi_entry, stream.parser.state); + try testing.expectEqual(@as(usize, 4), stream.handler.committed); +} + +test "stream: nextSliceUntilGround consumes all input without a boundary" { + const S = Stream(ContinuationNullHandler); + var stream: S = .init(.{ .handler = .{} }); + defer stream.deinit(); + + try testing.expectEqual( + @as(?usize, 0), + stream.nextSliceUntilGround("unprocessed"), + ); + try testing.expect(stream.ground()); + + stream.nextSlice("\x1b["); + try testing.expectEqual( + @as(?usize, null), + stream.nextSliceUntilGround("123"), + ); + try testing.expect(!stream.ground()); + + // A boundary on the final byte is distinguishable from exhausting the + // input while still pending. + try testing.expectEqual( + @as(?usize, 1), + stream.nextSliceUntilGround("m"), + ); + try testing.expect(stream.ground()); +} + +test "stream: nextSliceUntilGround handles UTF-8 boundaries" { + const S = Stream(ContinuationTestHandler); + + // A completed codepoint is committed synchronously, and the printable + // suffix remains untouched. + var valid: S = .init(.{ .handler = .{} }); + defer valid.deinit(); + valid.nextSlice(&.{0xF0}); + const valid_input = [_]u8{ 0x9F, 0x98, 0x84, 'X' }; + try testing.expectEqual( + @as(?usize, 3), + valid.nextSliceUntilGround(&valid_input), + ); + try testing.expect(valid.ground()); + try testing.expectEqual(@as(usize, 1), valid.handler.committed); + + // A malformed continuation emits the replacement codepoint and retries + // the same byte. Ground is observed after that complete byte operation. + var malformed: S = .init(.{ .handler = .{} }); + defer malformed.deinit(); + malformed.nextSlice(&.{ 0xE0, 0xA0 }); + try testing.expectEqual( + @as(?usize, 1), + malformed.nextSliceUntilGround("A!"), + ); + try testing.expect(malformed.ground()); + try testing.expectEqual(@as(usize, 2), malformed.handler.committed); + + // If the retried byte is ESC, the stream has begun VT state at the end of + // that byte and must continue to the following ground boundary. + var retry_escape: S = .init(.{ .handler = .{} }); + defer retry_escape.deinit(); + retry_escape.nextSlice(&.{ 0xE0, 0xA0 }); + try testing.expectEqual( + @as(?usize, 3), + retry_escape.nextSliceUntilGround("\x1b[mX"), + ); + try testing.expect(retry_escape.ground()); +} + +test "stream: nextSliceUntilGround handles aborts and bulk strings" { + const S = Stream(ContinuationTestHandler); + + var aborted: S = .init(.{ .handler = .{} }); + defer aborted.deinit(); + aborted.nextSlice("\x1b[123"); + try testing.expectEqual( + @as(?usize, 1), + aborted.nextSliceUntilGround(&.{ 0x18, 'X' }), + ); + try testing.expect(aborted.ground()); + + var apc: S = .init(.{ .handler = .{} }); + defer apc.deinit(); + apc.nextSlice("\x1b_Gseed"); + var input: [131]u8 = undefined; + @memset(input[0..128], 'a'); + input[128..130].* = "\x1b\\".*; + input[130] = 'X'; + try testing.expectEqual( + @as(?usize, 130), + apc.nextSliceUntilGround(&input), + ); + try testing.expect(apc.ground()); +} + +test "stream: nextSliceUntilGround tracks only the consumed prefix" { + const S = Stream(ContinuationNullHandler); + var stream: S = .init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 64, + }); + defer stream.deinit(); + + stream.nextSlice("\x1b[31"); + try testing.expectEqual( + @as(?usize, 1), + stream.nextSliceUntilGround("mX\x1b["), + ); + + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try stream.writeContinuation(&writer); + try testing.expectEqual(@as(usize, 0), writer.buffered().len); + + stream.nextSlice("\x1b["); + try testing.expectEqual( + @as(?usize, null), + stream.nextSliceUntilGround("123"), + ); + var pending_writer: std.Io.Writer = .fixed(&buf); + try stream.writeContinuation(&pending_writer); + try testing.expectEqualStrings("\x1b[123", pending_writer.buffered()); +} + +test "stream: continuation lifecycle" { + const S = Stream(ContinuationTestHandler); + + var disabled: S = .init(.{ .handler = .{} }); + defer disabled.deinit(); + var disabled_buf: [1]u8 = undefined; + var disabled_writer: std.Io.Writer = .fixed(&disabled_buf); + try testing.expectError( + error.ContinuationDisabled, + disabled.writeContinuation(&disabled_writer), + ); + + var zero_capacity: S = .init(.{ + .handler = .{}, + .continuation_max_bytes = 0, + }); + defer zero_capacity.deinit(); + var zero_capacity_buf: [1]u8 = undefined; + var zero_capacity_writer: std.Io.Writer = .fixed(&zero_capacity_buf); + try testing.expectError( + error.ContinuationDisabled, + zero_capacity.writeContinuation(&zero_capacity_writer), + ); + var no_allocator: S = .init(.{ + .handler = .{}, + .continuation_max_bytes = 64, + }); + defer no_allocator.deinit(); + var no_allocator_buf: [1]u8 = undefined; + var no_allocator_writer: std.Io.Writer = .fixed(&no_allocator_buf); + try testing.expectError( + error.ContinuationDisabled, + no_allocator.writeContinuation(&no_allocator_writer), + ); + + var tracked = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 64, + }); + defer tracked.deinit(); + + tracked.nextSlice("complete input"); + var empty_buf: [1]u8 = undefined; + var empty_writer: std.Io.Writer = .fixed(&empty_buf); + try tracked.writeContinuation(&empty_writer); + try testing.expectEqual(@as(usize, 0), empty_writer.end); + + tracked.nextSlice("\x1b["); + var short_buf: [1]u8 = undefined; + var short_writer: std.Io.Writer = .fixed(&short_buf); + try testing.expectError( + error.WriteFailed, + tracked.writeContinuation(&short_writer), + ); + + var failing = testing.FailingAllocator.init(testing.allocator, .{ + .fail_index = 0, + }); + var failing_stream = Stream(ContinuationNullHandler).init(.{ + .allocator = failing.allocator(), + .handler = .{}, + .continuation_max_bytes = 64, + }); + defer failing_stream.deinit(); + failing_stream.nextSlice("\x1b["); + var unavailable_buf: [1]u8 = undefined; + var unavailable_writer: std.Io.Writer = .fixed(&unavailable_buf); + try testing.expectError( + error.ContinuationUnavailable, + failing_stream.writeContinuation(&unavailable_writer), + ); +} + +test "stream: continuation suffixes are replay safe" { + const Case = struct { + input: []const u8, + expected: []const u8, + }; + const cases = [_]Case{ + .{ .input = "text\x1b", .expected = "\x1b" }, + .{ .input = "text\x1b[12;", .expected = "\x1b[12;" }, + .{ .input = "text\x1b[1\x07;2", .expected = "\x1b[1;2" }, + .{ .input = "text\x1b]2;hello", .expected = "\x1b]2;hello" }, + .{ .input = "text\x1b_Gabc", .expected = "\x1b_Gabc" }, + .{ .input = "text\x1bP+qabc", .expected = "\x1bP+qabc" }, + .{ .input = "text\xE0\xA0\xF0", .expected = "\xF0" }, + .{ .input = "text\x1b[12\x1b", .expected = "\x1b" }, + .{ + .input = "text\x1b[12\x9D2;title", + .expected = "\x1b[12\x9D2;title", + }, + }; + + const S = Stream(ContinuationTestHandler); + for (cases) |case| { + var stream = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer stream.deinit(); + stream.nextSlice(case.input); + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try stream.writeContinuation(&writer); + try testing.expectEqualStrings(case.expected, writer.buffered()); + } +} + +test "stream: continuation reconstructs every unfinished VT state" { + const Case = struct { + input: []const u8, + state: Parser.State, + }; + const cases = [_]Case{ + .{ .input = "\x1b", .state = .escape }, + .{ .input = "\x1b(", .state = .escape_intermediate }, + .{ .input = "\x1b[", .state = .csi_entry }, + .{ .input = "\x1b[1", .state = .csi_param }, + .{ .input = "\x1b[1$", .state = .csi_intermediate }, + .{ .input = "\x1b[:", .state = .csi_ignore }, + .{ .input = "\x1bP", .state = .dcs_entry }, + .{ .input = "\x1bP1", .state = .dcs_param }, + .{ .input = "\x1bP1$", .state = .dcs_intermediate }, + .{ .input = "\x1bP1q", .state = .dcs_passthrough }, + .{ .input = "\x1bP:", .state = .dcs_ignore }, + .{ .input = "\x1b]2;title", .state = .osc_string }, + .{ .input = "\x1b_Gpayload", .state = .sos_pm_apc_string }, + }; + const S = Stream(ContinuationTestHandler); + + for (cases) |case| { + var source = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer source.deinit(); + source.nextSlice(case.input); + try testing.expectEqual(case.state, source.parser.state); + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try source.writeContinuation(&writer); + + var restored = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer restored.deinit(); + restored.nextSlice(writer.buffered()); + try testing.expectEqual(@as(usize, 0), restored.handler.committed); + try testing.expectEqual(source.parser.state, restored.parser.state); + try testing.expectEqual(source.utf8decoder.state, restored.utf8decoder.state); + } + + // Parser ground is still unfinished while the UTF-8 decoder is waiting + // for the remaining bytes of a codepoint. + var utf8 = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 4, + }); + defer utf8.deinit(); + utf8.next(0xF0); + try testing.expectEqual(Parser.State.ground, utf8.parser.state); + try testing.expect(utf8.utf8decoder.state != 0); + var utf8_buf: [4]u8 = undefined; + var utf8_writer: std.Io.Writer = .fixed(&utf8_buf); + try utf8.writeContinuation(&utf8_writer); + try testing.expectEqualSlices(u8, &.{0xF0}, utf8_writer.buffered()); +} + +test "stream: continuation is chunking-independent and idempotent" { + const S = Stream(ContinuationTestHandler); + const input = "committed\x1b[1\x07;2"; + + var bulk = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer bulk.deinit(); + bulk.nextSlice(input); + + var scalar = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer scalar.deinit(); + for (input) |c| scalar.next(c); + + var bulk_buf: [1024]u8 = undefined; + var bulk_writer: std.Io.Writer = .fixed(&bulk_buf); + try bulk.writeContinuation(&bulk_writer); + var scalar_buf: [1024]u8 = undefined; + var scalar_writer: std.Io.Writer = .fixed(&scalar_buf); + try scalar.writeContinuation(&scalar_writer); + try testing.expectEqualStrings( + bulk_writer.buffered(), + scalar_writer.buffered(), + ); + + var restored = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer restored.deinit(); + restored.nextSlice(bulk_writer.buffered()); + try testing.expectEqual(@as(usize, 0), restored.handler.committed); + + var restored_buf: [1024]u8 = undefined; + var restored_writer: std.Io.Writer = .fixed(&restored_buf); + try restored.writeContinuation(&restored_writer); + try testing.expectEqualStrings( + bulk_writer.buffered(), + restored_writer.buffered(), + ); + + bulk.handler.committed = 0; + restored.handler.committed = 0; + bulk.nextSlice("mZ"); + restored.next('m'); + restored.next('Z'); + try testing.expectEqual(bulk.handler.committed, restored.handler.committed); +} + +test "stream: continuation rebuilds APC handler input" { + const S = Stream(ContinuationTestHandler); + var source = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer source.deinit(); + source.nextSlice("committed\x1b_Gabc"); + + var continuation: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&continuation); + try source.writeContinuation(&writer); + + var restored = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer restored.deinit(); + restored.nextSlice(writer.buffered()); + try testing.expectEqual(@as(usize, 0), restored.handler.committed); + try testing.expect(restored.handler.apc_active); + try testing.expectEqualStrings( + source.handler.apc_buf[0..source.handler.apc_len], + restored.handler.apc_buf[0..restored.handler.apc_len], + ); + + source.handler.committed = 0; + restored.handler.committed = 0; + source.nextSlice("\x1b\\"); + restored.nextSlice("\x1b\\"); + try testing.expectEqual(source.handler.committed, restored.handler.committed); + try testing.expect(!source.handler.apc_active); + try testing.expect(!restored.handler.apc_active); +} + +test "stream: continuation cap and recovery" { + const S = Stream(ContinuationTestHandler); + + // The raw feed exceeds the cap, but only the unfinished three-byte + // CSI suffix is retained. + var seeded = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 4, + }); + defer seeded.deinit(); + seeded.nextSlice("committed text\x1b[1"); + var seeded_buf: [4]u8 = undefined; + var seeded_writer: std.Io.Writer = .fixed(&seeded_buf); + try seeded.writeContinuation(&seeded_writer); + try testing.expectEqualStrings("\x1b[1", seeded_writer.buffered()); + + var exceeded = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 4, + }); + defer exceeded.deinit(); + exceeded.nextSlice("\x1b[123"); + var unavailable_buf: [1]u8 = undefined; + var unavailable_writer: std.Io.Writer = .fixed(&unavailable_buf); + try testing.expectError( + error.ContinuationUnavailable, + exceeded.writeContinuation(&unavailable_writer), + ); + + // Completing the CSI reaches ground and recovers without rebuilding the + // Stream. A later unfinished sequence is tracked normally. + exceeded.nextSlice("mtext\x1b["); + var recovered_buf: [4]u8 = undefined; + var recovered_writer: std.Io.Writer = .fixed(&recovered_buf); + try exceeded.writeContinuation(&recovered_writer); + try testing.expectEqualStrings("\x1b[", recovered_writer.buffered()); + + // A fresh ESC seed also recovers broken tracking even when the stream + // never reaches ground: the ESC abandons the previous unfinished + // state and everything after it is retained. + exceeded.nextSlice("\x1b[123"); + var reexceeded_buf: [1]u8 = undefined; + var reexceeded_writer: std.Io.Writer = .fixed(&reexceeded_buf); + try testing.expectError( + error.ContinuationUnavailable, + exceeded.writeContinuation(&reexceeded_writer), + ); + exceeded.nextSlice("\x1b]0;"); + var seed_buf: [4]u8 = undefined; + var seed_writer: std.Io.Writer = .fixed(&seed_buf); + try exceeded.writeContinuation(&seed_writer); + try testing.expectEqualStrings("\x1b]0;", seed_writer.buffered()); +} + +test "stream: continuation spans multiple bulk feeds" { + const S = Stream(ContinuationTestHandler); + + // An unfinished APC grows across feeds that contain no new seed. + var apc = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer apc.deinit(); + apc.nextSlice("text\x1b_Gab"); + apc.nextSlice("cd"); + apc.nextSlice("ef"); + var apc_buf: [16]u8 = undefined; + var apc_writer: std.Io.Writer = .fixed(&apc_buf); + try apc.writeContinuation(&apc_writer); + try testing.expectEqualStrings("\x1b_Gabcdef", apc_writer.buffered()); + + // An incomplete UTF-8 sequence grows across feeds of its + // continuation bytes. + var utf8 = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 1024, + }); + defer utf8.deinit(); + utf8.nextSlice("text\xF0"); + utf8.nextSlice("\x9F"); + var utf8_buf: [4]u8 = undefined; + var utf8_writer: std.Io.Writer = .fixed(&utf8_buf); + try utf8.writeContinuation(&utf8_writer); + try testing.expectEqualSlices(u8, "\xF0\x9F", utf8_writer.buffered()); + + // A later feed with its own seed drops everything retained earlier. + utf8.nextSlice("\x98\x84 done \x1b[38;5"); + var seed_buf: [8]u8 = undefined; + var seed_writer: std.Io.Writer = .fixed(&seed_buf); + try utf8.writeContinuation(&seed_writer); + try testing.expectEqualStrings("\x1b[38;5", seed_writer.buffered()); +} + +test "stream: continuation exact cap and large unfinished string" { + const S = Stream(ContinuationNullHandler); + + var exact = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 5, + }); + defer exact.deinit(); + exact.nextSlice("\x1b[123"); + var exact_buf: [5]u8 = undefined; + var exact_writer: std.Io.Writer = .fixed(&exact_buf); + try exact.writeContinuation(&exact_writer); + try testing.expectEqualStrings("\x1b[123", exact_writer.buffered()); + + const payload_len = 12 * 1024; + const input = try testing.allocator.alloc(u8, payload_len); + defer testing.allocator.free(input); + input[0..3].* = "\x1b_G".*; + @memset(input[3..], 'A'); + + var large = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = payload_len, + }); + defer large.deinit(); + large.nextSlice(input); + const large_buf = try testing.allocator.alloc(u8, payload_len); + defer testing.allocator.free(large_buf); + var large_writer: std.Io.Writer = .fixed(large_buf); + try large.writeContinuation(&large_writer); + try testing.expectEqualSlices(u8, input, large_writer.buffered()); +} + +test "stream: continuation allocation failure recovers" { + var failing = testing.FailingAllocator.init(testing.allocator, .{}); + const alloc = failing.allocator(); + const S = Stream(ContinuationNullHandler); + var stream = S.init(.{ + .allocator = alloc, + .handler = .{}, + .continuation_max_bytes = 16 * 1024, + }); + defer stream.deinit(); + + var input = try testing.allocator.alloc(u8, 12 * 1024); + defer testing.allocator.free(input); + input[0..2].* = "\x1b[".*; + @memset(input[2..], '1'); + + failing.fail_index = failing.alloc_index; + stream.nextSlice(input); + var unavailable_buf: [1]u8 = undefined; + var unavailable_writer: std.Io.Writer = .fixed(&unavailable_buf); + try testing.expectError( + error.ContinuationUnavailable, + stream.writeContinuation(&unavailable_writer), + ); + + failing.fail_index = std.math.maxInt(usize); + stream.next('m'); + var recovered_buf: [1]u8 = undefined; + var recovered_writer: std.Io.Writer = .fixed(&recovered_buf); + try stream.writeContinuation(&recovered_writer); + try testing.expectEqual(@as(usize, 0), recovered_writer.end); +} + +test "stream: continuation every-byte cuts preserve future behavior" { + const corpora = [_][]const u8{ + "plain \xF0\x9F\x98\x84 utf8", + "bad \xE0\xA0\xF0\x9F\x98\x84 utf8", + "\x1b[1\x07;2mstyled\x1b[0m", + "\x1b]2;window title\x1b\\text", + "\x1bP$qm\x1b\\text", + "\x1b_Ga=q;payload\x1b\\text", + "\x1b_25a1;s\x1b\\text", + "\x1b]2;first\x1b\\\x1b_Gsecond", + "\x1b[12\x9D2;title\x1b\\text", + "\x1b[12\x18text\x1b[1\x1Atext", + }; + const S = Stream(ContinuationTestHandler); + + for (corpora) |corpus| for (0..corpus.len + 1) |cut| { + var source = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 64 * 1024, + }); + defer source.deinit(); + source.nextSlice(corpus[0..cut]); + + var continuation: [64 * 1024]u8 = undefined; + var continuation_writer: std.Io.Writer = .fixed(&continuation); + try source.writeContinuation(&continuation_writer); + + var restored = S.init(.{ + .allocator = testing.allocator, + .handler = .{}, + .continuation_max_bytes = 64 * 1024, + }); + defer restored.deinit(); + restored.nextSlice(continuation_writer.buffered()); + try testing.expectEqual(@as(usize, 0), restored.handler.committed); + try testing.expectEqual(source.handler.apc_active, restored.handler.apc_active); + try testing.expectEqual(source.handler.dcs_active, restored.handler.dcs_active); + if (source.handler.apc_active) { + try testing.expectEqualStrings( + source.handler.apc_buf[0..source.handler.apc_len], + restored.handler.apc_buf[0..restored.handler.apc_len], + ); + } + + var reexport: [64 * 1024]u8 = undefined; + var reexport_writer: std.Io.Writer = .fixed(&reexport); + try restored.writeContinuation(&reexport_writer); + try testing.expectEqualStrings( + continuation_writer.buffered(), + reexport_writer.buffered(), + ); + + source.handler.committed = 0; + restored.handler.committed = 0; + source.nextSlice(corpus[cut..]); + var offset = cut; + var partition = cut +% corpus.len +% 1; + while (offset < corpus.len) { + partition = partition *% 1664525 +% 1013904223; + const len = @min(1 + partition % 7, corpus.len - offset); + restored.nextSlice(corpus[offset..][0..len]); + offset += len; + } + try testing.expectEqual(source.handler.committed, restored.handler.committed); + try testing.expectEqual(source.handler.apc_active, restored.handler.apc_active); + try testing.expectEqual(source.handler.dcs_active, restored.handler.dcs_active); + + var source_final: [64]u8 = undefined; + var source_final_writer: std.Io.Writer = .fixed(&source_final); + try source.writeContinuation(&source_final_writer); + var restored_final: [64]u8 = undefined; + var restored_final_writer: std.Io.Writer = .fixed(&restored_final); + try restored.writeContinuation(&restored_final_writer); + try testing.expectEqualStrings( + source_final_writer.buffered(), + restored_final_writer.buffered(), + ); + }; +} diff --git a/src/terminal/stream_continuation.zig b/src/terminal/stream_continuation.zig new file mode 100644 index 000000000..bd6b8f0f5 --- /dev/null +++ b/src/terminal/stream_continuation.zig @@ -0,0 +1,606 @@ +const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const Parser = @import("Parser.zig"); +const UTF8Decoder = @import("UTF8Decoder.zig"); + +/// Errors possible while validating a snapshot continuation. +pub const ValidateError = error{ + /// Nonempty input left both the VT parser and UTF-8 decoder at ground. + /// + /// A continuation exists only to reconstruct state that was unfinished at + /// the snapshot cut. Input that returns to ground is a complete PTY + /// fragment, not a continuation. Replaying it would repeat work already + /// represented by the Terminal snapshot. For example, `ESC [ 3 1 m` + /// completes an SGR command and must not be stored as a continuation. + NoPendingState, + + /// The input does not begin at its effective replay start. + /// + /// A later ESC supersedes earlier VT parser state, and a pending UTF-8 + /// codepoint begins at its lead byte. Any prefix before that effective + /// start is unnecessary and would disappear when the restored Stream + /// exports its continuation again. Rejecting the prefix preserves the + /// byte-identical re-export invariant and prevents unrelated prior input + /// from being replayed. + NonCanonicalContinuation, + + /// Replaying the input would perform handler-visible work. + /// + /// The Terminal snapshot already contains every mutation committed before + /// its capture cut. A continuation may rebuild unfinished parser or + /// builder state, but it must not mutate the Terminal or repeat an external + /// effect while doing so. For example, BEL inside an unfinished CSI would + /// ring again even though the CSI itself remains pending. + ReplayWouldCommit, +}; + +/// Validate continuation bytes. +/// +/// Ensure that: +/// +/// - replay ends with either VT or UTF-8 state unfinished +/// - byte zero is the effective start needed to reconstruct that state +/// - replay commits no Terminal mutation or external handler effect +pub fn validate(bytes: []const u8) ValidateError!void { + // Empty explicitly requests ground state and needs no replay. + if (bytes.len == 0) return; + + // We need the final parser and decoder states to classify the input, so a + // committed byte cannot return early. Remember it while scanning the rest. + var scanner: BoundaryScanner = .init(); + var committed_work = false; + for (bytes) |byte| { + if (scanner.next(byte) != .uncommitted) committed_work = true; + } + + // Nonempty continuation bytes must leave state that future input needs. + if (scanner.ground()) return error.NoPendingState; + + // The tracker exports the minimal suffix beginning at the effective replay + // start. A different prefix would not survive byte-identical re-export. + const replay_start = if (scanner.parser.state != .ground) + findVTReplayStart(bytes) + else + findUtf8ReplayStart(bytes); + if (replay_start != 0) return error.NonCanonicalContinuation; + + // At this point the input is unfinished and minimal, but replay must also + // be inert with respect to the already-restored Terminal and its effects. + if (committed_work) return error.ReplayWouldCommit; +} + +/// Retains the input needed to reconstruct unfinished Stream parser state. +/// +/// A feed is one chunk of bytes given to a Stream. It can end in the middle of +/// a VT sequence or a UTF-8 codepoint. To continue in another Stream, that +/// Stream starts from ground (with no sequence in progress) and reads the +/// saved bytes again. The first saved byte is called the replay start. +/// +/// Stream updates this tracker once per feed based on where that replay start +/// is: +/// +/// - Ground (parser and UTF-8 decoder): `reset`, no suffix is needed. +/// - Anything unfinished: `append`, which replaces the suffix with the +/// bytes from the replay start onward when that start is inside this +/// feed, and otherwise extends the suffix with this whole feed because +/// the unfinished state began in an earlier one. +/// +/// This keeps the retained bytes minimal: they always begin at the replay +/// start, so the feed path never needs to parse or trim old input. The suffix +/// may still contain a byte whose visible terminal effect already happened, +/// such as BEL inside an unfinished CSI sequence. `write` leaves out those +/// bytes so replay does not perform the same effect twice. +/// +/// If the suffix exceeds `max_bytes`, or retaining it fails, `broken` +/// is set until a later feed ends at ground (`reset`) or contains a new +/// replay start (`replace`), both of which need nothing that was lost. +/// +/// Replay semantics are guaranteed for the standard TerminalStream handler. +/// Custom handlers, including handlers that intercept vtRaw, are unsupported. +pub const Tracker = struct { + const initial_capacity = 4096; + + alloc: Allocator, + max_bytes: usize, + bytes: std.ArrayList(u8) = .empty, + broken: bool = false, + + /// Initialize a tracker. + pub fn init(alloc: Allocator, max_bytes: usize) Tracker { + var result: Tracker = .{ + .alloc = alloc, + .max_bytes = max_bytes, + }; + result.bytes.ensureTotalCapacity( + alloc, + @min(max_bytes, initial_capacity), + ) catch { + // We ignore memory errors here. They'll mark the tracker + // as broken in a future append. + + }; + return result; + } + + pub fn deinit(self: *Tracker) void { + self.bytes.deinit(self.alloc); + } + + /// Clear the current continuation suffix and broken state while keeping + /// its allocation. Stream calls this after reaching ground because no + /// earlier input is needed to reconstruct the next unfinished state. + pub fn reset(self: *Tracker) void { + self.broken = false; + self.bytes.clearRetainingCapacity(); + } + + /// Which state machine is unfinished at the end of a feed. When the VT + /// parser is outside ground the unfinished state is an escape sequence. + /// When it is grounded, only the UTF-8 decoder can be unfinished, with + /// an incomplete codepoint ending the feed. + pub const Pending = enum { vt, utf8 }; + + /// Retain the part of `input` needed to replay a feed that ended with + /// `pending` state unfinished. + /// + /// When the input contains the replay start for that state, it replaces + /// the retained suffix outright because nothing earlier is needed. The + /// new suffix is complete on its own, so this also repairs a broken + /// tracker. Otherwise the unfinished state began in an earlier feed and + /// this whole input extends the current suffix. + pub fn append(self: *Tracker, pending: Pending, input: []const u8) void { + const start = switch (pending) { + .vt => findVTReplayStart(input), + .utf8 => findUtf8ReplayStart(input), + } orelse { + self.extend(input); + return; + }; + self.replace(input[start..]); + } + + /// Replace the continuation suffix with one that has a new replay start. + fn replace(self: *Tracker, suffix: []const u8) void { + self.bytes.clearRetainingCapacity(); + if (suffix.len > self.max_bytes) { + self.markBroken(); + return; + } + self.bytes.appendSlice(self.alloc, suffix) catch { + self.markBroken(); + return; + }; + self.broken = false; + } + + /// Extend the continuation suffix with a fragment that contains no replay + /// start of its own. The stream stayed unfinished for the whole fragment, + /// so the existing suffix plus this fragment reproduces the current state. + fn extend(self: *Tracker, fragment: []const u8) void { + if (self.broken) return; + if (fragment.len > self.max_bytes -| self.bytes.items.len) { + self.markBroken(); + return; + } + self.bytes.appendSlice(self.alloc, fragment) catch self.markBroken(); + } + + /// Write the replay-safe continuation suffix to `writer`. The retained + /// bytes already begin at the replay start; this omits only bytes that + /// would repeat committed terminal effects without contributing to the + /// unfinished parser state (e.g. a BEL inside an unfinished CSI). The + /// tracker must not be broken. + pub fn write( + self: *const Tracker, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + var scanner: BoundaryScanner = .init(); + for (self.bytes.items) |c| { + if (scanner.next(c) == .omittable) continue; + try writer.writeByte(c); + } + } + + fn markBroken(self: *Tracker) void { + self.broken = true; + self.bytes.clearRetainingCapacity(); + } +}; + +/// Find where replay must begin when a feed ends inside a VT sequence. +/// +/// VT parsers treat ESC specially: it abandons the previous parser state and +/// starts a fresh escape state no matter what was being parsed. Because of +/// that rule, feeding the bytes from the last ESC onward into a grounded +/// Stream recreates the unfinished state at the end of `input`. +/// +/// The caller must only use this when the VT parser ended outside ground. +/// The returned value is the index of the last ESC in `input`. A null result +/// means the sequence began in an earlier feed, so this entire input must be +/// appended to the continuation bytes already retained. +fn findVTReplayStart(input: []const u8) ?usize { + const esc: u8 = 0x1B; + var rem: usize = input.len; + + // Escape sequences are short, so when the stream ends inside one the + // replay start is usually within the last few bytes. Scan backward in + // vector chunks; pure payload inputs (no ESC at all) still scan quickly. + if (comptime std.simd.suggestVectorLength(u8)) |lanes| { + const V = @Vector(lanes, u8); + const Bits = std.meta.Int(.unsigned, lanes); + const needle: V = @splat(esc); + + // Process several vectors per iteration so long inputs without ESC + // (e.g. huge string payloads) stay memory-bound rather than + // loop-bound. On a match, rescan the group precisely. + const unroll = 4; + while (rem >= lanes * unroll) { + const start = rem - lanes * unroll; + var match: @Vector(lanes, bool) = @splat(false); + inline for (0..unroll) |block| { + const v: V = input[start + block * lanes ..][0..lanes].*; + match = match | (v == needle); + } + if (@reduce(.Or, match)) { + inline for (0..unroll) |i| { + const block = unroll - 1 - i; + const v: V = input[start + block * lanes ..][0..lanes].*; + const bits: Bits = @bitCast(v == needle); + if (bits != 0) { + return start + block * lanes + (lanes - 1 - @clz(bits)); + } + } + unreachable; + } + rem = start; + } + + while (rem >= lanes) { + const start = rem - lanes; + const v: V = input[start..][0..lanes].*; + const bits: Bits = @bitCast(v == needle); + if (bits != 0) return start + (lanes - 1 - @clz(bits)); + rem = start; + } + } + + while (rem > 0) { + rem -= 1; + if (input[rem] == esc) return rem; + } + return null; +} + +/// Find where replay must begin when a feed ends inside a UTF-8 codepoint. +/// +/// A UTF-8 codepoint starts with a lead byte and may have up to three +/// continuation bytes. If the lead byte is in this input, replay must begin +/// there so the decoder sees the complete partial codepoint again. Since an +/// incomplete codepoint can contain at most three bytes, only the final three +/// input bytes need to be searched. +/// +/// The caller must only use this when the VT parser is at ground and the +/// UTF-8 decoder ended mid-codepoint. The returned value is the lead byte's +/// index. A null result means the lead byte was in an earlier feed, so this +/// entire input must be appended to the continuation bytes already retained. +fn findUtf8ReplayStart(input: []const u8) ?usize { + const max_pending = 3; + var idx = input.len; + while (idx > 0 and input.len - idx < max_pending) { + idx -= 1; + if (input[idx] >= 0xC0) return idx; + } + return null; +} + +/// Classifies bytes of a continuation suffix for export. +/// +/// The retained suffix reconstructs unfinished state exactly, but it can +/// contain bytes whose handler-visible work already committed the first +/// time (e.g. a C0 control executed inside an unfinished CSI sequence). +/// This implements a minimal VT stream processor (to avoid circular imports +/// with stream.zig) so `Tracker.write` can omit those bytes and replay +/// never repeats a terminal effect. It only runs at export time, never on +/// the feed path. +/// +/// This is allocation-free. +const BoundaryScanner = struct { + parser: Parser, + utf8decoder: UTF8Decoder, + + /// Initialize both state machines at runtime. Parser initialization may + /// inspect the runtime environment, so it cannot be a struct field default + /// that Zig requires to be comptime-known. + fn init() BoundaryScanner { + return .{ + .parser = .init(), + .utf8decoder = .{}, + }; + } + + /// How one byte affects committed work and the continuation suffix. + const Effect = enum { + /// The byte commits no handler-visible work. It must remain because it + /// may still build the unfinished VT, UTF-8, APC, or DCS state. + uncommitted, + + /// The byte commits handler-visible work but also changes a parser + /// state tag or reaches ground. It cannot be omitted in isolation; + /// boundary analysis decides whether the surrounding prefix is safe. + committed, + + /// The byte commits handler-visible work without changing either + /// state-machine tag or reaching ground. It can be omitted without + /// changing the unfinished state, avoiding a duplicate effect. + omittable, + }; + + /// True only when neither state machine needs prior bytes to continue. + fn ground(self: *const BoundaryScanner) bool { + return self.parser.state == .ground and self.utf8decoder.state == 0; + } + + /// Consume one byte using the same scalar UTF-8 retry and VT transitions + /// as Stream, then classify its effect on continuation construction. + fn next(self: *BoundaryScanner, c: u8) Effect { + // Preserve the state tags so we can recognize committed controls that + // do not contribute to the unfinished sequence. + const parser_state = self.parser.state; + const utf8_state = self.utf8decoder.state; + + // A byte is committed when replaying it would repeat handler-visible + // work already captured outside the continuation suffix. Actions that + // only build unfinished APC or DCS state are not committed because + // replay must reconstruct that state. + const committed = committed: { + if (self.parser.state == .ground) { + // Match Stream's scalar UTF-8 path, including retrying a byte that + // follows a malformed sequence. + var committed = false; + const res = self.utf8decoder.next(c); + if (res[0]) |cp| committed = self.codepoint(cp) or committed; + if (!res[1]) { + const retry = self.utf8decoder.next(c); + assert(retry[1]); + if (retry[0]) |cp| committed = self.codepoint(cp) or committed; + } + + break :committed committed; + } + + const actions = self.parser.next(c); + for (actions) |action_opt| { + const action = action_opt orelse continue; + switch (action) { + // These actions only build standard handler state. Their + // matching end/unhook actions are committed work. + .dcs_hook, + .dcs_put, + .apc_start, + .apc_put, + => {}, + else => break :committed true, + } + } + + break :committed false; + }; + + if (!committed) return .uncommitted; + + // A committed byte is independently omittable only while the stream + // remains unfinished and neither state-machine tag changes. + const state_changed = parser_state != self.parser.state or + utf8_state != self.utf8decoder.state; + return if (!state_changed and !self.ground()) + .omittable + else + .committed; + } + + /// Apply the Stream.handleCodepoint behavior relevant to replay. Returns + /// whether the accepted codepoint would commit handler-visible work. + fn codepoint(self: *BoundaryScanner, cp: u21) bool { + if (cp == 0x1B) { + self.parser.state = .escape; + self.parser.clear(); + return false; + } + + // Match Stream.handleCodepoint: all other accepted codepoints have + // already caused a handler-visible action (including supported C0s). + return true; + } +}; + +test "boundary scanner classifies effects and ground" { + const testing = std.testing; + var scanner: BoundaryScanner = .init(); + + try testing.expect(scanner.ground()); + try testing.expectEqual(BoundaryScanner.Effect.committed, scanner.next('A')); + try testing.expect(scanner.ground()); + + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, scanner.next(0x1B)); + try testing.expect(!scanner.ground()); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, scanner.next('[')); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, scanner.next('1')); + + // BEL commits an execute action without changing the unfinished CSI. + try testing.expectEqual(BoundaryScanner.Effect.omittable, scanner.next(0x07)); + try testing.expect(!scanner.ground()); + + try testing.expectEqual(BoundaryScanner.Effect.committed, scanner.next('m')); + try testing.expect(scanner.ground()); +} + +test "boundary scanner preserves unfinished builder actions" { + const testing = std.testing; + + var apc: BoundaryScanner = .init(); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, apc.next(0x1B)); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, apc.next('_')); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, apc.next('G')); + try testing.expectEqual(BoundaryScanner.Effect.committed, apc.next(0x1B)); + + var dcs: BoundaryScanner = .init(); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, dcs.next(0x1B)); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, dcs.next('P')); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, dcs.next('q')); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, dcs.next('x')); + try testing.expectEqual(BoundaryScanner.Effect.committed, dcs.next(0x1B)); +} + +test "boundary scanner handles UTF-8 and malformed retries" { + const testing = std.testing; + + var valid: BoundaryScanner = .init(); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, valid.next(0xF0)); + try testing.expect(!valid.ground()); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, valid.next(0x9F)); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, valid.next(0x98)); + try testing.expectEqual(BoundaryScanner.Effect.committed, valid.next(0x84)); + try testing.expect(valid.ground()); + + var malformed: BoundaryScanner = .init(); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, malformed.next(0xE0)); + try testing.expectEqual(BoundaryScanner.Effect.uncommitted, malformed.next(0xA0)); + try testing.expect(malformed.next(0xF0) != .uncommitted); + try testing.expect(!malformed.ground()); +} + +test "findVTReplayStart finds the latest ESC across vector boundaries" { + const testing = std.testing; + + try testing.expectEqual(@as(?usize, null), findVTReplayStart("")); + try testing.expectEqual(@as(?usize, null), findVTReplayStart("no escapes here")); + try testing.expectEqual(@as(?usize, 0), findVTReplayStart("\x1b")); + try testing.expectEqual(@as(?usize, 5), findVTReplayStart("\x1b[1m\x20\x1b[2")); + + // Exercise both the vector loop and the scalar remainder with replay + // starts at every position of a buffer larger than any vector width. + var buf: [193]u8 = undefined; + @memset(&buf, 'a'); + try testing.expectEqual(@as(?usize, null), findVTReplayStart(&buf)); + for (0..buf.len) |idx| { + @memset(&buf, 'a'); + buf[idx] = 0x1B; + try testing.expectEqual(@as(?usize, idx), findVTReplayStart(&buf)); + + // The latest ESC wins. + if (idx > 0) { + buf[idx - 1] = 0x1B; + try testing.expectEqual(@as(?usize, idx), findVTReplayStart(&buf)); + } + } +} + +test "findUtf8ReplayStart finds the pending lead byte" { + const testing = std.testing; + + try testing.expectEqual(@as(?usize, null), findUtf8ReplayStart("")); + try testing.expectEqual(@as(?usize, 4), findUtf8ReplayStart("text\xF0")); + try testing.expectEqual(@as(?usize, 4), findUtf8ReplayStart("text\xF0\x9F")); + try testing.expectEqual(@as(?usize, 4), findUtf8ReplayStart("text\xF0\x9F\x98")); + try testing.expectEqual(@as(?usize, 0), findUtf8ReplayStart("\xE0\xA0")); + + // A malformed prefix rejected earlier doesn't hide the pending lead. + try testing.expectEqual(@as(?usize, 2), findUtf8ReplayStart("\xE0\xA0\xF0")); + + // Continuation bytes of a sequence that started in an earlier input have + // no replay start of their own. + try testing.expectEqual(@as(?usize, null), findUtf8ReplayStart("\x9F")); + try testing.expectEqual(@as(?usize, null), findUtf8ReplayStart("\x9F\x98")); +} + +test "tracker retains and normalizes replay-safe bytes" { + const testing = std.testing; + var tracker = Tracker.init(testing.allocator, 64); + defer tracker.deinit(); + + tracker.append(.vt, "committed\x1b[1\x07"); + tracker.append(.vt, ";2"); + try testing.expect(!tracker.broken); + + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try tracker.write(&writer); + try testing.expectEqualStrings("\x1b[1;2", writer.buffered()); + + // A feed with a later replay start drops the previous suffix entirely. + tracker.append(.vt, "committed\x1b]0;t"); + var replaced_buf: [64]u8 = undefined; + var replaced_writer: std.Io.Writer = .fixed(&replaced_buf); + try tracker.write(&replaced_writer); + try testing.expectEqualStrings("\x1b]0;t", replaced_writer.buffered()); + + // An incomplete UTF-8 codepoint seeds at its lead byte and grows with + // continuation bytes from later feeds. + tracker.append(.utf8, "committed\xF0"); + tracker.append(.utf8, "\x9F"); + var utf8_buf: [4]u8 = undefined; + var utf8_writer: std.Io.Writer = .fixed(&utf8_buf); + try tracker.write(&utf8_writer); + try testing.expectEqualSlices(u8, "\xF0\x9F", utf8_writer.buffered()); +} + +test "tracker cap, reset, and broken recovery" { + const testing = std.testing; + var tracker = Tracker.init(testing.allocator, 4); + defer tracker.deinit(); + + tracker.append(.vt, "\x1b[123"); + try testing.expect(tracker.broken); + + // Feeds without a replay start are dropped while broken. + tracker.append(.vt, "4"); + try testing.expect(tracker.broken); + try testing.expectEqual(@as(usize, 0), tracker.bytes.items.len); + + // Suffixes that grow past the cap break tracking. + tracker.reset(); + tracker.append(.vt, "\x1b[1"); + tracker.append(.vt, "23"); + try testing.expect(tracker.broken); + + // A new replay start that fits recovers without a reset. + tracker.append(.vt, "\x1b["); + try testing.expect(!tracker.broken); + var buf: [4]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try tracker.write(&writer); + try testing.expectEqualStrings("\x1b[", writer.buffered()); + + tracker.append(.vt, "\x1b[123"); + try testing.expect(tracker.broken); + tracker.reset(); + try testing.expect(!tracker.broken); + var empty_buf: [1]u8 = undefined; + var empty_writer: std.Io.Writer = .fixed(&empty_buf); + try tracker.write(&empty_writer); + try testing.expectEqual(@as(usize, 0), empty_writer.end); +} + +test "tracker reports writer failure and defers allocation failure" { + const testing = std.testing; + var tracker = Tracker.init(testing.allocator, 64); + defer tracker.deinit(); + tracker.append(.vt, "\x1b["); + + var buf: [1]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try testing.expectError(error.WriteFailed, tracker.write(&writer)); + + var failing = testing.FailingAllocator.init(testing.allocator, .{ + .fail_index = 0, + }); + var failing_tracker = Tracker.init(failing.allocator(), 64); + defer failing_tracker.deinit(); + try testing.expect(!failing_tracker.broken); + + // The best-effort initial reservation failed, so the first required + // retention retries allocation and marks the tracker broken. + failing_tracker.append(.vt, "\x1b["); + try testing.expect(failing_tracker.broken); +} diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 7586711a4..2599097af 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -16,6 +16,8 @@ const osc = @import("osc.zig"); const osc_color = @import("osc/parsers/color.zig"); const kitty_color = @import("kitty/color.zig"); const size_report = @import("size_report.zig"); +const simd = @import("../simd/main.zig"); +const terminfo = @import("../terminfo/main.zig"); const Terminal = @import("Terminal.zig"); const log = std.log.scoped(.stream_terminal); @@ -55,6 +57,11 @@ pub const Handler = struct { /// effects. effects: Effects = .readonly, + /// Whether CSI 21 t may report the terminal title. This is disabled by + /// default because reporting an attacker-controlled title to the pty can + /// inject text into the input stream of the foreground process. + title_report: bool = false, + /// The APC command handler maintains the APC state. APC is like /// CSI or OSC, but it is a private escape sequence that is used /// to send commands to the terminal emulator. This is used by @@ -64,6 +71,23 @@ pub const Handler = struct { /// The DCS command handler maintains state for DCS queries. dcs_handler: dcs.Handler = .{}, + /// Called for sequence identifiers not supported by this library. + /// Currently, only APC is reported. Content is borrowed and only valid + /// for the duration of the callback. Set `apc_handler.unknown_max_bytes` + /// before starting the Stream to enable APC capture. + unknown_sequence: ?*const fn (*Handler, UnknownSequence) void = null, + + /// The name of the terminfo entry this terminal runs as, reported in + /// response to an XTGETTCAP query for "TN". + /// + /// The memory must remain valid for the lifetime of the handler. + /// Empty names and names longer than `max_terminfo_name_bytes` are + /// silently ignored. + terminfo_name: ?[]const u8 = null, + + /// Maximum byte length accepted for `terminfo_name`. + pub const max_terminfo_name_bytes = 128; + pub const Effects = struct { /// Called when the terminal needs to write data back to the pty, /// e.g. in response to a DECRQM query. The data is only valid @@ -151,6 +175,18 @@ pub const Handler = struct { }; }; + /// A sequence unsupported by the active handler. Payload data is borrowed + /// only for the duration of the handler callback. + pub const UnknownSequence = union(enum) { + apc: String, + + /// Content between a string sequence's introducer and terminator. + pub const String = struct { + content: []const u8, + truncated: bool, + }; + }; + pub fn init(terminal: *Terminal) Handler { return .{ .terminal = terminal, @@ -205,6 +241,11 @@ pub const Handler = struct { }; } + fn unknownSequence(self: *Handler, value: UnknownSequence) void { + const func = self.unknown_sequence orelse return; + func(self, value); + } + inline fn vtFallible( self: *Handler, comptime action: Action.Tag, @@ -320,7 +361,7 @@ pub const Handler = struct { .apc_start => self.apc_handler.start(), .apc_put => self.apc_handler.feed(self.terminal.gpa(), value), .apc_put_slice => self.apc_handler.feedSlice(self.terminal.gpa(), value.bytes), - .apc_end => self.apcEnd(), + .apc_end => self.apcEnd(value.terminated), // Effect-based handlers .bell => self.bell(), @@ -395,12 +436,51 @@ pub const Handler = struct { self.writePty(response[0..encoded.len :0]); }, - .tmux, - .xtgettcap, - => {}, + .xtgettcap => |*gettcap| { + if (self.effects.write_pty == null) return; + const map = comptime terminfo.ghostty.xtgettcapMap(); + while (gettcap.next()) |key| { + if (std.mem.eql(u8, key, encoded_tn_key)) { + self.writeTerminfoName(); + continue; + } + self.writePty(map.get(key) orelse continue); + } + }, + + .tmux => {}, } } + // Hex-encoded "TN", the XTGETTCAP key naming the terminfo entry. + // The static map also carries this key with Ghostty's own name, so + // it is intercepted before the lookup: an embedder that never + // configured a name must not be reported as Ghostty's entry. + const encoded_tn_key = &std.fmt.bytesToHex("TN", .upper); + + /// Answer an XTGETTCAP "TN" query from the configured terminfo name. + /// Unset, empty, or over-long names leave the query unanswered. + fn writeTerminfoName(self: *Handler) void { + const name = self.terminfo_name orelse return; + if (name.len == 0 or name.len > max_terminfo_name_bytes) return; + + // Fixed upper bound for an encoded "TN" reply calculated + // at comptime from our max terminfo size. + const max_tn_response_bytes = + comptime "\x1bP1+r".len + encoded_tn_key.len + "=".len + + (max_terminfo_name_bytes * 2) + "\x1b\\".len + + 1; // null terminator + + // Values are hex-encoded uppercase, matching the static map. The + // buffer fits any name allowed above, so the print cannot fail. + var buf: [max_tn_response_bytes]u8 = undefined; + self.writePty(std.fmt.bufPrintZ( + &buf, + "\x1bP1+r" ++ encoded_tn_key ++ "={X}\x1b\\", + .{name}, + ) catch unreachable); + } + fn bell(self: *Handler) void { const func = self.effects.bell orelse return; func(self); @@ -440,12 +520,13 @@ pub const Handler = struct { return; } - const decoder = std.base64.standard.Decoder; - const decoded_len = try decoder.calcSizeForSlice(data); + // Decode the base64 payload with the SIMD decoder (the same one + // used for Kitty graphics payloads) rather than the scalar std + // implementation; clipboard payloads can be megabytes. const alloc = self.terminal.gpa(); - const decoded = try alloc.alloc(u8, decoded_len); - defer alloc.free(decoded); - try decoder.decode(decoded, data); + const buf = try alloc.alloc(u8, simd.base64.maxLen(data)); + defer alloc.free(buf); + const decoded = try simd.base64.decode(data, buf); const contents = [_]clipboard.Content{.{ .mime = "text/plain", @@ -563,6 +644,7 @@ pub const Handler = struct { // Build the response. switch (style) { .csi_21_t => { + if (!self.title_report) return; const title = self.terminal.getTitle() orelse ""; aw.writer.print("\x1b]l{s}\x1b\\", .{title}) catch return; }, @@ -940,13 +1022,18 @@ pub const Handler = struct { } } - fn apcEnd(self: *Handler) void { + fn apcEnd(self: *Handler, terminated: bool) void { const io = self.terminal.io(); const alloc = self.terminal.gpa(); - var cmd = self.apc_handler.end() orelse return; - defer cmd.deinit(alloc); - - switch (cmd) { + var result = self.apc_handler.end() orelse return; + defer result.deinit(alloc); + switch (result) { + .unknown => |*unknown| { + if (terminated) self.unknownSequence(.{ .apc = .{ + .content = unknown.content, + .truncated = unknown.truncated, + } }); + }, .kitty => |*kitty_cmd| if (comptime build_options.kitty_graphics) { if (self.terminal.kittyGraphics( io, @@ -993,7 +1080,7 @@ test "resize clears synchronized output on unchanged cell dimensions" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); t.modes.set(.synchronized_output, true); @@ -1008,6 +1095,51 @@ test "resize clears synchronized output on unchanged cell dimensions" { try testing.expectEqual(@as(u32, 432), t.height_px); } +test "unknown APC effect callback" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var count: usize = 0; + var content: [16]u8 = undefined; + var content_len: usize = undefined; + var truncated: bool = undefined; + + fn unknownSequence(_: *Handler, value: Handler.UnknownSequence) void { + switch (value) { + .apc => |apc_value| { + content_len = apc_value.content.len; + @memcpy(content[0..apc_value.content.len], apc_value.content); + truncated = apc_value.truncated; + }, + } + count += 1; + } + }; + S.count = 0; + + var handler: Handler = .init(&t); + handler.unknown_sequence = &S.unknownSequence; + handler.apc_handler.unknown_max_bytes = 8; + var s: Stream = .init(.{ + .allocator = testing.allocator, + .handler = handler, + }); + defer s.deinit(); + + // Unknown OSC commands retain their legacy behavior and are ignored. + s.nextSlice("\x1B]999;abcdef\x07"); + s.nextSlice("\x1B_abcd;payload\x1B\\"); + + try testing.expectEqual(@as(usize, 1), S.count); + try testing.expectEqualStrings("abcd;pay", S.content[0..S.content_len]); + try testing.expect(S.truncated); + + // Aborted unknown APCs are suppressed. + s.nextSlice("\x1B_Xpayload\x18"); + try testing.expectEqual(@as(usize, 1), S.count); +} + test "resize reports mode 2048 geometry" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); @@ -1025,7 +1157,7 @@ test "resize reports mode 2048 geometry" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); t.modes.set(.in_band_size_reports, true); @@ -1055,7 +1187,7 @@ test "resize suppresses mode 2048 reports" { defer t.deinit(testing.allocator); var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Disabled mode suppresses a report even with pixels and a callback. @@ -1079,10 +1211,10 @@ test "resize suppresses mode 2048 reports" { ); defer readonly_terminal.deinit(testing.allocator); readonly_terminal.modes.set(.in_band_size_reports, true); - var readonly_stream: Stream = .initAlloc( - testing.allocator, - .init(&readonly_terminal), - ); + var readonly_stream: Stream = .init(.{ + .allocator = testing.allocator, + .handler = .init(&readonly_terminal), + }); defer readonly_stream.deinit(); try readonly_stream.handler.resize(.{ .cols = 80, @@ -1109,7 +1241,7 @@ test "resize failure preserves terminal state and does not write" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(alloc, handler); + var s: Stream = .init(.{ .allocator = alloc, .handler = handler }); defer s.deinit(); t.modes.set(.synchronized_output, true); @@ -1147,15 +1279,15 @@ test "resize effects do not change canonical terminal state" { }; var authoritative_handler: Handler = .init(&authoritative); authoritative_handler.effects.write_pty = &S.writePty; - var authoritative_stream: Stream = .initAlloc( - testing.allocator, - authoritative_handler, - ); + var authoritative_stream: Stream = .init(.{ + .allocator = testing.allocator, + .handler = authoritative_handler, + }); defer authoritative_stream.deinit(); - var readonly_stream: Stream = .initAlloc( - testing.allocator, - .init(&readonly), - ); + var readonly_stream: Stream = .init(.{ + .allocator = testing.allocator, + .handler = .init(&readonly), + }); defer readonly_stream.deinit(); authoritative.modes.set(.in_band_size_reports, true); @@ -1183,7 +1315,7 @@ test "basic print" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); s.nextSlice("Hello"); @@ -1201,7 +1333,7 @@ test "semantic failure is sticky while processing continues" { var t: Terminal = try .init(testing.io, alloc, .{ .cols = 10, .rows = 2 }); defer t.deinit(alloc); - var s: Stream = .initAlloc(alloc, .init(&t)); + var s: Stream = .init(.{ .allocator = alloc, .handler = .init(&t) }); defer s.deinit(); try testing.expect(!s.handler.semantic_failure); @@ -1232,7 +1364,7 @@ test "cursor movement" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Move cursor using escape sequences @@ -1250,7 +1382,7 @@ test "erase operations" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 20, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Print some text @@ -1271,7 +1403,7 @@ test "tabs" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); s.nextSlice("A\tB"); @@ -1286,7 +1418,7 @@ test "modes" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Test wraparound mode @@ -1301,7 +1433,7 @@ test "scrolling regions" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set scrolling region from line 5 to 20 @@ -1316,7 +1448,7 @@ test "charsets" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Configure G0 as DEC special graphics @@ -1332,7 +1464,7 @@ test "alt screen" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 5 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Write to primary screen @@ -1359,7 +1491,7 @@ test "cursor save and restore" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Move cursor to 10,15 @@ -1385,7 +1517,7 @@ test "attributes" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set bold and write text @@ -1434,20 +1566,24 @@ test "DECRQSS responses" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // SGR s.nextSlice("\x1B[1m\x1BP$qm\x1B\\"); try S.expectResponse("\x1BP1$r0;1m\x1B\\"); + // Overline + s.nextSlice("\x1B[0;53m\x1BP$qm\x1B\\"); + try S.expectResponse("\x1BP1$r0;53m\x1B\\"); + // Requests larger than the parser's fixed request buffer are ignored, // and the next DCS command must still be processed normally. s.nextSlice("\x1BP$qfoo\x1B\\"); try testing.expectEqual(@as(usize, 0), S.calls); try testing.expect(!s.handler.semantic_failure); s.nextSlice("\x1BP$qm\x1B\\"); - try S.expectResponse("\x1BP1$r0;1m\x1B\\"); + try S.expectResponse("\x1BP1$r0;53m\x1B\\"); } test "DECRQSS without write effect is ignored" { @@ -1458,13 +1594,168 @@ test "DECRQSS without write effect is ignored" { ); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); s.nextSlice("\x1BP$qm\x1B\\"); try testing.expect(!s.handler.semantic_failure); } +test "XTGETTCAP responses" { + var t: Terminal = try .init( + testing.io, + testing.allocator, + .{ .cols = 80, .rows = 24 }, + ); + defer t.deinit(testing.allocator); + + const S = struct { + var response: [128]u8 = undefined; + var response_len: usize = 0; + var calls: usize = 0; + + fn reset() void { + response_len = 0; + calls = 0; + } + + fn writePty(_: *Handler, data: [:0]const u8) void { + @memcpy(response[0..data.len], data); + response_len = data.len; + calls += 1; + } + + fn expectResponse(expected: []const u8) !void { + try testing.expectEqual(@as(usize, 1), calls); + try testing.expectEqualStrings( + expected, + response[0..response_len], + ); + reset(); + } + }; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // The full capability table comes from the static terminfo map; this + // checks the wiring for a valued and a valueless (boolean) capability. + s.nextSlice("\x1BP+q" ++ std.fmt.bytesToHex("Co", .upper) ++ "\x1B\\"); + try S.expectResponse("\x1BP1+r" ++ std.fmt.bytesToHex("Co", .upper) ++ "=" ++ + std.fmt.bytesToHex("256", .upper) ++ "\x1B\\"); + s.nextSlice("\x1BP+q" ++ std.fmt.bytesToHex("am", .upper) ++ "\x1B\\"); + try S.expectResponse("\x1BP1+r" ++ std.fmt.bytesToHex("am", .upper) ++ "\x1B\\"); + + // One response per requested key; lowercase hex is normalized by the + // DCS parser. The capture holds the last ("Co") reply. + s.nextSlice("\x1BP+q" ++ std.fmt.bytesToHex("am", .lower) ++ ";" ++ + std.fmt.bytesToHex("Co", .lower) ++ "\x1B\\"); + try testing.expectEqual(@as(usize, 2), S.calls); + try testing.expectEqualStrings( + "\x1BP1+r" ++ std.fmt.bytesToHex("Co", .upper) ++ "=" ++ + std.fmt.bytesToHex("256", .upper) ++ "\x1B\\", + S.response[0..S.response_len], + ); + S.reset(); + + // Unknown and malformed keys are skipped without an error. + s.nextSlice("\x1BP+qWHO;5;GG\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.calls); + try testing.expect(!s.handler.semantic_failure); +} + +test "XTGETTCAP without write effect is ignored" { + var t: Terminal = try .init( + testing.io, + testing.allocator, + .{ .cols = 80, .rows = 24 }, + ); + defer t.deinit(testing.allocator); + + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); + defer s.deinit(); + + s.nextSlice("\x1BP+q" ++ std.fmt.bytesToHex("TN", .upper) ++ ";" ++ + std.fmt.bytesToHex("am", .upper) ++ "\x1B\\"); + try testing.expect(!s.handler.semantic_failure); +} + +test "XTGETTCAP TN responses" { + var t: Terminal = try .init( + testing.io, + testing.allocator, + .{ .cols = 80, .rows = 24 }, + ); + defer t.deinit(testing.allocator); + + const S = struct { + var response: [512]u8 = undefined; + var response_len: usize = 0; + var calls: usize = 0; + + fn reset() void { + response_len = 0; + calls = 0; + } + + fn writePty(_: *Handler, data: [:0]const u8) void { + @memcpy(response[0..data.len], data); + response_len = data.len; + calls += 1; + } + + fn expectResponse(expected: []const u8) !void { + try testing.expectEqual(@as(usize, 1), calls); + try testing.expectEqualStrings( + expected, + response[0..response_len], + ); + reset(); + } + }; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + const tn_query = "\x1BP+q" ++ std.fmt.bytesToHex("TN", .upper) ++ "\x1B\\"; + + // While no name is configured the query goes unanswered. + s.nextSlice(tn_query); + try testing.expectEqual(@as(usize, 0), S.calls); + + // A configured name is reported hex-encoded. + s.handler.terminfo_name = "xterm-256color"; + s.nextSlice(tn_query); + try S.expectResponse("\x1BP1+r" ++ std.fmt.bytesToHex("TN", .upper) ++ "=" ++ + std.fmt.bytesToHex("xterm-256color", .upper) ++ "\x1B\\"); + + // A maximum-length name is still reported in full. + const max_name = "a" ** Handler.max_terminfo_name_bytes; + s.handler.terminfo_name = max_name; + s.nextSlice(tn_query); + try S.expectResponse("\x1BP1+r" ++ std.fmt.bytesToHex("TN", .upper) ++ "=" ++ + std.fmt.bytesToHex(max_name.*, .upper) ++ "\x1B\\"); + + // An empty name is silent; "Co" is still answered. + s.handler.terminfo_name = ""; + s.nextSlice("\x1BP+q" ++ std.fmt.bytesToHex("TN", .upper) ++ ";" ++ + std.fmt.bytesToHex("Co", .upper) ++ "\x1B\\"); + try S.expectResponse("\x1BP1+r" ++ std.fmt.bytesToHex("Co", .upper) ++ "=" ++ + std.fmt.bytesToHex("256", .upper) ++ "\x1B\\"); + + // As are names beyond the maximum length. + s.handler.terminfo_name = "a" ** (Handler.max_terminfo_name_bytes + 1); + s.nextSlice(tn_query); + try testing.expectEqual(@as(usize, 0), S.calls); + try testing.expect(!s.handler.semantic_failure); +} + test "DCS command memory is released" { var t: Terminal = try .init( testing.io, @@ -1473,10 +1764,10 @@ test "DCS command memory is released" { ); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); - // A completed, unsupported command transfers its allocation to Command; - // dcsCommand must release it even though stream_terminal ignores it. + // A completed command transfers its allocation to Command; dcsCommand + // must release it even when there is no write effect. s.nextSlice("\x1BP+q536D756C78\x1B\\"); // An incomplete command remains owned by the handler and must be released @@ -1489,7 +1780,7 @@ test "DECALN screen alignment" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 3 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Run DECALN @@ -1509,7 +1800,7 @@ test "full reset" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Make some changes @@ -1549,7 +1840,7 @@ test "glyph protocol APC with write_pty callback" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x1B_25a1;s\x1B\\"); @@ -1564,7 +1855,7 @@ test "ignores query actions" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // These should be ignored without error @@ -1592,7 +1883,7 @@ test "OSC 4 set and reset palette" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Save default color @@ -1615,7 +1906,7 @@ test "OSC 104 reset all palette colors" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set multiple colors @@ -1640,7 +1931,7 @@ test "OSC 10 set and reset foreground color" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Initially unset @@ -1662,9 +1953,12 @@ test "OSC 11 set and reset background color" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); + const default: color.RGB = .{ .r = 0x10, .g = 0x20, .b = 0x30 }; + t.colors.background.default = default; + // Set background to green s.nextSlice("\x1b]11;rgb:00/ff/00\x1b\\"); const bg = t.colors.background.get().?; @@ -1674,14 +1968,20 @@ test "OSC 11 set and reset background color" { // Reset background s.nextSlice("\x1b]111\x1b\\"); - try testing.expect(t.colors.background.get() == null); + try testing.expectEqual(default, t.colors.background.get().?); + try testing.expectEqual(null, t.colors.background.override); + + // A reset color continues to follow later configuration changes. + const updated: color.RGB = .{ .r = 0x40, .g = 0x50, .b = 0x60 }; + t.colors.background.default = updated; + try testing.expectEqual(updated, t.colors.background.get().?); } test "OSC 12 set and reset cursor color" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set cursor to blue @@ -1719,7 +2019,7 @@ test "OSC color query responses" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x1b]10;?\x1b\\"); @@ -1757,7 +2057,7 @@ test "kitty color protocol set palette" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set palette color 5 to magenta using kitty protocol @@ -1773,7 +2073,7 @@ test "kitty color protocol reset palette" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set and then reset palette color @@ -1790,7 +2090,7 @@ test "kitty color protocol set foreground" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set foreground using kitty protocol @@ -1805,7 +2105,7 @@ test "kitty color protocol set background" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set background using kitty protocol @@ -1820,7 +2120,7 @@ test "kitty color protocol set cursor" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set cursor using kitty protocol @@ -1835,7 +2135,7 @@ test "kitty color protocol reset foreground" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set and reset foreground @@ -1870,7 +2170,7 @@ test "kitty color protocol query responses" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x1b]21;background=?\x1b\\"); @@ -1891,7 +2191,7 @@ test "palette dirty flag set on color change" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Clear dirty flag @@ -1916,7 +2216,7 @@ test "semantic prompt fresh line" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); s.nextSlice("Hello"); @@ -1929,7 +2229,7 @@ test "semantic prompt fresh line new prompt" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Write some text and then send OSC 133;A (fresh_line_new_prompt) @@ -1953,7 +2253,7 @@ test "semantic prompt end of input, then start output" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Write some text and then send OSC 133;A (fresh_line_new_prompt) @@ -1970,7 +2270,7 @@ test "semantic prompt prompt_start" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Write some text @@ -1987,7 +2287,7 @@ test "semantic prompt new_command" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Write some text @@ -2005,7 +2305,7 @@ test "semantic prompt new_command at column zero" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // OSC 133;N when already at column 0 should stay on same line @@ -2019,7 +2319,7 @@ test "semantic prompt end_prompt_start_input_terminate_eol clears on linefeed" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Set input terminated by EOL @@ -2037,7 +2337,7 @@ test "bell effect callback" { // Test bell with null callback (default readonly effects) doesn't crash { - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); s.nextSlice("\x07"); @@ -2064,7 +2364,7 @@ test "bell effect callback" { var handler: Handler = .init(&t); handler.effects.bell = &S.bell; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x07"); @@ -2082,7 +2382,7 @@ test "desktop_notification effect callback" { // A null callback (the default readonly effects) silently ignores // notifications and leaves the terminal usable. { - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); s.nextSlice("\x1B]9;Ignored\x1B\\AfterNotification"); @@ -2114,7 +2414,7 @@ test "desktop_notification effect callback" { var handler: Handler = .init(&t); handler.effects.desktop_notification = &S.desktopNotification; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // OSC 9 is split across writes and carries only a body. @@ -2138,7 +2438,7 @@ test "progress_report effect callback" { // A null callback (the default readonly effects) silently ignores reports. { - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); s.nextSlice("\x1B]9;4;1;25\x1B\\"); } @@ -2161,7 +2461,7 @@ test "progress_report effect callback" { var handler: Handler = .init(&t); handler.effects.progress_report = &S.progressReport; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); const cases = [_]struct { @@ -2194,7 +2494,7 @@ test "clipboard_write effect callback" { // A null callback (the default readonly effects) silently ignores writes. { - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\"); @@ -2246,7 +2546,7 @@ test "clipboard_write effect callback" { var handler: Handler = .init(&t); handler.effects.clipboard_write = &S.clipboardWrite; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Selectors are normalized and payloads are decoded before the callback. @@ -2323,7 +2623,7 @@ test "clipboard_write allocation failure is ignored" { var handler: Handler = .init(&t); handler.effects.clipboard_write = &S.clipboardWrite; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Only the decoded scratch data uses the terminal allocator here. Swap in @@ -2344,7 +2644,7 @@ test "request mode DECRQM with write_pty callback" { // Without callback, DECRQM should not crash { - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // DECRQM for mode 7 (wraparound) — should be silently ignored @@ -2368,7 +2668,7 @@ test "request mode DECRQM with write_pty callback" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Wraparound mode (7) is set by default @@ -2383,6 +2683,10 @@ test "request mode DECRQM with write_pty callback" { // Query an unknown mode s.nextSlice("\x1B[?9999$p"); try testing.expectEqualStrings("\x1B[?9999;0$y", S.last_response.?); + + // Query DECECM, which Ghostty recognizes but does not allow changing + s.nextSlice("\x1B[?117$p"); + try testing.expectEqualStrings("\x1B[?117;4$y", S.last_response.?); } } @@ -2396,7 +2700,7 @@ test "stream: CSI W with intermediate but no params" { }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); s.nextSlice("\x1b[?W"); @@ -2417,7 +2721,7 @@ test "window_title effect is called" { var handler: Handler = .init(&t); handler.effects.title_changed = &S.titleChanged; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Set window title via OSC 2 @@ -2430,7 +2734,7 @@ test "window_title effect not called without callback" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // Should not crash when no callback is set @@ -2461,7 +2765,7 @@ test "window_title effect with empty title" { var handler: Handler = .init(&t); handler.effects.title_changed = &S.titleChanged; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Set empty window title @@ -2485,7 +2789,7 @@ test "kitty_keyboard_query" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Default kitty keyboard flags should be 0 @@ -2514,7 +2818,7 @@ test "xtversion default" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Without xtversion effect set, should report "libghostty" @@ -2541,7 +2845,7 @@ test "xtversion with effect" { handler.effects.write_pty = &S.writePty; handler.effects.xtversion = &S.xtversion; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x1b[>0q"); @@ -2567,7 +2871,7 @@ test "xtversion with empty string effect" { handler.effects.write_pty = &S.writePty; handler.effects.xtversion = &S.xtversion; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Empty string from effect should fall back to "libghostty" @@ -2594,7 +2898,7 @@ test "size report csi_14_t with effect" { handler.effects.write_pty = &S.writePty; handler.effects.size = &S.getSize; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // CSI 14 t - report text area size in pixels @@ -2622,7 +2926,7 @@ test "size report csi_16_t with effect" { handler.effects.write_pty = &S.writePty; handler.effects.size = &S.getSize; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // CSI 16 t - report cell size in pixels @@ -2650,7 +2954,7 @@ test "size report csi_18_t with effect" { handler.effects.write_pty = &S.writePty; handler.effects.size = &S.getSize; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // CSI 18 t - report text area size in characters @@ -2674,7 +2978,7 @@ test "size report no effect callback" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Without size effect, size reports should be silently ignored @@ -2682,7 +2986,7 @@ test "size report no effect callback" { try testing.expect(S.written == null); } -test "size report csi_21_t title" { +test "size report csi_21_t title disabled by default" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); @@ -2697,7 +3001,34 @@ test "size report csi_21_t title" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Set a title first + s.nextSlice("\x1b]2;My Title\x1b\\"); + + // CSI 21 t - report title (no size effect needed) + s.nextSlice("\x1b[21t"); + try testing.expect(S.written == null); +} + +test "size report csi_21_t title enabled" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var written: ?[]const u8 = null; + fn writePty(_: *Handler, data: [:0]const u8) void { + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + }; + S.written = null; + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.title_report = true; + + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Set a title first @@ -2724,7 +3055,7 @@ test "enquiry no effect" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // ENQ without enquiry effect should not write anything @@ -2751,7 +3082,7 @@ test "enquiry with effect" { handler.effects.write_pty = &S.writePty; handler.effects.enquiry = &S.enquiry; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x05"); @@ -2778,7 +3109,7 @@ test "enquiry with empty response" { handler.effects.write_pty = &S.writePty; handler.effects.enquiry = &S.enquiry; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Empty enquiry response should not write anything @@ -2803,7 +3134,7 @@ test "device status: operating status" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // CSI 5 n — operating status report @@ -2828,7 +3159,7 @@ test "device status: cursor position" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Default position is 0,0 — reported as 1,1 @@ -2858,7 +3189,7 @@ test "device status: cursor position with origin mode" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Set scroll region rows 5-20 @@ -2894,7 +3225,7 @@ test "device status: color scheme dark" { handler.effects.write_pty = &S.writePty; handler.effects.color_scheme = &S.colorScheme; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // CSI ? 996 n — color scheme query @@ -2923,7 +3254,7 @@ test "device status: color scheme light" { handler.effects.write_pty = &S.writePty; handler.effects.color_scheme = &S.colorScheme; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // CSI ? 996 n — color scheme query @@ -2948,7 +3279,7 @@ test "device status: color scheme without callback" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Without color_scheme effect, query should be silently ignored @@ -2977,7 +3308,7 @@ test "visibility reports" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Mode 2033 is supported and initially disabled. @@ -3011,7 +3342,7 @@ test "device status: readonly ignores all" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // All device status queries should be silently ignored without effects @@ -3048,7 +3379,7 @@ test "device attributes: primary DA" { handler.effects.write_pty = &S.writePty; handler.effects.device_attributes = &S.da; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x1B[c"); @@ -3076,7 +3407,7 @@ test "device attributes: secondary DA" { handler.effects.write_pty = &S.writePty; handler.effects.device_attributes = &S.da; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x1B[>c"); @@ -3104,7 +3435,7 @@ test "device attributes: tertiary DA" { handler.effects.write_pty = &S.writePty; handler.effects.device_attributes = &S.da; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x1B[=c"); @@ -3115,7 +3446,7 @@ test "device attributes: readonly ignores" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - var s: Stream = .initAlloc(testing.allocator, .init(&t)); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); defer s.deinit(); // All DA queries should be silently ignored without effects @@ -3160,7 +3491,7 @@ test "device attributes: custom response" { handler.effects.write_pty = &S.writePty; handler.effects.device_attributes = &S.da; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); s.nextSlice("\x1B[c"); @@ -3189,7 +3520,7 @@ test "kitty graphics APC response" { var handler: Handler = .init(&t); handler.effects.write_pty = &S.writePty; - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Send a kitty graphics transmit command with image id 1 @@ -3206,7 +3537,7 @@ test "kitty graphics via APC" { defer t.deinit(testing.allocator); const handler: Handler = .init(&t); - var s: Stream = .initAlloc(testing.allocator, handler); + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); defer s.deinit(); // Send a kitty graphics transmit command via APC: @@ -3218,3 +3549,153 @@ test "kitty graphics via APC" { const img = storage.imageById(1).?; try testing.expectEqual(.rgb, img.format); } + +test "continuation reconstructs standard stream without duplicate effects" { + const S = struct { + var bell_count: usize = 0; + var title_count: usize = 0; + var write_count: usize = 0; + var notification_count: usize = 0; + var clipboard_count: usize = 0; + + fn bell(_: *Handler) void { + bell_count += 1; + } + + fn titleChanged(_: *Handler) void { + title_count += 1; + } + + fn writePty(_: *Handler, _: [:0]const u8) void { + write_count += 1; + } + + fn desktopNotification( + _: *Handler, + _: Action.ShowDesktopNotification, + ) void { + notification_count += 1; + } + + fn clipboardWrite( + _: *Handler, + _: clipboard.Write, + ) clipboard.WriteResult { + clipboard_count += 1; + return .success; + } + + fn reset() void { + bell_count = 0; + title_count = 0; + write_count = 0; + notification_count = 0; + clipboard_count = 0; + } + }; + S.reset(); + + const committed = "A\n\x07" ++ + "\x1b]2;title\x1b\\" ++ + "\x1b[5n" ++ + "\x1b]9;body\x1b\\" ++ + "\x1b]52;c;aA==\x1b\\"; + + var source_terminal: Terminal = try .init( + testing.io, + testing.allocator, + .{ .cols = 80, .rows = 24 }, + ); + defer source_terminal.deinit(testing.allocator); + + var source_handler: Handler = .init(&source_terminal); + source_handler.effects.bell = &S.bell; + source_handler.effects.title_changed = &S.titleChanged; + source_handler.effects.write_pty = &S.writePty; + source_handler.effects.desktop_notification = &S.desktopNotification; + source_handler.effects.clipboard_write = &S.clipboardWrite; + var source = Stream.init(.{ + .allocator = testing.allocator, + .handler = source_handler, + .continuation_max_bytes = 1024, + }); + defer source.deinit(); + + // Terminal mutation and all callbacks have already committed. The + // unfinished CSI is the only input needed to recreate the stream state. + source.nextSlice(committed ++ "\x1b[31"); + try testing.expectEqual(@as(usize, 1), S.bell_count); + try testing.expectEqual(@as(usize, 1), S.title_count); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(@as(usize, 1), S.notification_count); + try testing.expectEqual(@as(usize, 1), S.clipboard_count); + + var continuation_buf: [1024]u8 = undefined; + var continuation_writer: std.Io.Writer = .fixed(&continuation_buf); + try source.writeContinuation(&continuation_writer); + try testing.expectEqualStrings("\x1b[31", continuation_writer.buffered()); + + var restored_terminal: Terminal = try .init( + testing.io, + testing.allocator, + .{ .cols = 80, .rows = 24 }, + ); + defer restored_terminal.deinit(testing.allocator); + + // Stand in for restoring the already-committed terminal snapshot. + { + var snapshot_stream: Stream = .init(.{ + .allocator = testing.allocator, + .handler = .init(&restored_terminal), + }); + defer snapshot_stream.deinit(); + snapshot_stream.nextSlice(committed); + } + + const before = try restored_terminal.plainString(testing.allocator); + defer testing.allocator.free(before); + const before_x = restored_terminal.screens.active.cursor.x; + const before_y = restored_terminal.screens.active.cursor.y; + const before_style = restored_terminal.screens.active.cursor.style_id; + const before_title = restored_terminal.getTitle().?; + + var restored_handler: Handler = .init(&restored_terminal); + restored_handler.effects.bell = &S.bell; + restored_handler.effects.title_changed = &S.titleChanged; + restored_handler.effects.write_pty = &S.writePty; + restored_handler.effects.desktop_notification = &S.desktopNotification; + restored_handler.effects.clipboard_write = &S.clipboardWrite; + var restored = Stream.init(.{ + .allocator = testing.allocator, + .handler = restored_handler, + .continuation_max_bytes = 1024, + }); + defer restored.deinit(); + + S.reset(); + restored.nextSlice(continuation_writer.buffered()); + try testing.expectEqual(@as(usize, 0), S.bell_count); + try testing.expectEqual(@as(usize, 0), S.title_count); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqual(@as(usize, 0), S.notification_count); + try testing.expectEqual(@as(usize, 0), S.clipboard_count); + const after = try restored_terminal.plainString(testing.allocator); + defer testing.allocator.free(after); + try testing.expectEqualStrings(before, after); + try testing.expectEqual(before_x, restored_terminal.screens.active.cursor.x); + try testing.expectEqual(before_y, restored_terminal.screens.active.cursor.y); + try testing.expectEqual(before_style, restored_terminal.screens.active.cursor.style_id); + try testing.expectEqualStrings(before_title, restored_terminal.getTitle().?); + + source.nextSlice("mB"); + restored.nextSlice("mB"); + const source_text = try source_terminal.plainString(testing.allocator); + defer testing.allocator.free(source_text); + const restored_text = try restored_terminal.plainString(testing.allocator); + defer testing.allocator.free(restored_text); + try testing.expectEqualStrings(source_text, restored_text); + try testing.expectEqual( + source_terminal.screens.active.cursor.style_id, + restored_terminal.screens.active.cursor.style_id, + ); +} diff --git a/src/terminal/style.zig b/src/terminal/style.zig index c7de43378..ee4c0e562 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -1,5 +1,6 @@ const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; +const fastprint = @import("../fastprint.zig"); const color = @import("color.zig"); const sgr = @import("sgr.zig"); const page = @import("page.zig"); @@ -331,63 +332,141 @@ pub const Style = struct { self: VTFormatter, writer: *std.Io.Writer, ) !void { + // Style emission is a hot path when formatting styled terminal + // contents, so all of the sequences are assembled in a buffer + // and written in one call rather than going through the + // (slower) format string machinery with a write per sequence. + // + // Worst case: `\x1b[0m` (4) + 7 flags (28) + `\x1b[53m` (5) + + // `\x1b[4:2m` (6) + 3 RGB colors (19 each = 57) = 100. + var buf: [128]u8 = undefined; + // Always reset the style. Styles are fully self-contained. // Even if this style is empty, then that means we want to go // back to the default. - try writer.writeAll("\x1b[0m"); + buf[0..4].* = "\x1b[0m".*; + var len: usize = 4; // Our flags - if (self.style.flags.bold) try writer.writeAll("\x1b[1m"); - if (self.style.flags.faint) try writer.writeAll("\x1b[2m"); - if (self.style.flags.italic) try writer.writeAll("\x1b[3m"); - if (self.style.flags.blink) try writer.writeAll("\x1b[5m"); - if (self.style.flags.inverse) try writer.writeAll("\x1b[7m"); - if (self.style.flags.invisible) try writer.writeAll("\x1b[8m"); - if (self.style.flags.strikethrough) try writer.writeAll("\x1b[9m"); - if (self.style.flags.overline) try writer.writeAll("\x1b[53m"); + if (self.style.flags.bold) { + buf[len..][0..4].* = "\x1b[1m".*; + len += 4; + } + if (self.style.flags.faint) { + buf[len..][0..4].* = "\x1b[2m".*; + len += 4; + } + if (self.style.flags.italic) { + buf[len..][0..4].* = "\x1b[3m".*; + len += 4; + } + if (self.style.flags.blink) { + buf[len..][0..4].* = "\x1b[5m".*; + len += 4; + } + if (self.style.flags.inverse) { + buf[len..][0..4].* = "\x1b[7m".*; + len += 4; + } + if (self.style.flags.invisible) { + buf[len..][0..4].* = "\x1b[8m".*; + len += 4; + } + if (self.style.flags.strikethrough) { + buf[len..][0..4].* = "\x1b[9m".*; + len += 4; + } + if (self.style.flags.overline) { + buf[len..][0..5].* = "\x1b[53m".*; + len += 5; + } switch (self.style.flags.underline) { .none => {}, - .single => try writer.writeAll("\x1b[4m"), - .double => try writer.writeAll("\x1b[4:2m"), - .curly => try writer.writeAll("\x1b[4:3m"), - .dotted => try writer.writeAll("\x1b[4:4m"), - .dashed => try writer.writeAll("\x1b[4:5m"), + .single => { + buf[len..][0..4].* = "\x1b[4m".*; + len += 4; + }, + .double => { + buf[len..][0..6].* = "\x1b[4:2m".*; + len += 6; + }, + .curly => { + buf[len..][0..6].* = "\x1b[4:3m".*; + len += 6; + }, + .dotted => { + buf[len..][0..6].* = "\x1b[4:4m".*; + len += 6; + }, + .dashed => { + buf[len..][0..6].* = "\x1b[4:5m".*; + len += 6; + }, } - // Various RGB colors. - try self.formatColor(writer, 38, self.style.fg_color); - try self.formatColor(writer, 48, self.style.bg_color); - try self.formatColor(writer, 58, self.style.underline_color); + // Various colors. + len += self.appendColor(buf[len..], 38, self.style.fg_color); + len += self.appendColor(buf[len..], 48, self.style.bg_color); + len += self.appendColor(buf[len..], 58, self.style.underline_color); + + try writer.writeAll(buf[0..len]); } - fn formatColor( + /// Appends a standalone `\x1b[{prefix};5;{idx}m` or + /// `\x1b[{prefix};2;{r};{g};{b}m` sequence to buf, returning the + /// length written. + fn appendColor( self: VTFormatter, - writer: *std.Io.Writer, + buf: []u8, prefix: u8, value: Color, - ) !void { + ) usize { switch (value) { - .none => {}, + .none => return 0, + .palette => |idx| { - if (self.palette) |p| { - const rgb = p[idx]; - try writer.print( - "\x1b[{d};2;{d};{d};{d}m", - .{ prefix, rgb.r, rgb.g, rgb.b }, - ); - } else { - try writer.print( - "\x1b[{d};5;{d}m", - .{ prefix, idx }, - ); - } + // Direct RGB: `\x1b[{prefix};2;{r};{g};{b}m` + if (self.palette) |p| return appendColorRgb( + buf, + prefix, + p[idx], + ); + + // Palette reference: `\x1b[{prefix};5;{idx}m` + buf[0..2].* = "\x1b[".*; + var len: usize = 2; + len += fastprint.printDecimal(u8, buf[len..], prefix); + buf[len..][0..3].* = ";5;".*; + len += 3; + len += fastprint.printDecimal(u8, buf[len..], idx); + buf[len] = 'm'; + len += 1; + return len; }, - .rgb => |rgb| try writer.print( - "\x1b[{d};2;{d};{d};{d}m", - .{ prefix, rgb.r, rgb.g, rgb.b }, - ), + + .rgb => |rgb| return appendColorRgb(buf, prefix, rgb), } } + + /// Appends `\x1b[{prefix};2;{r};{g};{b}m` to buf, returning the + /// length written. + fn appendColorRgb(buf: []u8, prefix: u8, rgb: color.RGB) usize { + buf[0..2].* = "\x1b[".*; + var len: usize = 2; + len += fastprint.printDecimal(u8, buf[len..], prefix); + buf[len..][0..3].* = ";2;".*; + len += 3; + len += fastprint.printDecimal(u8, buf[len..], rgb.r); + buf[len] = ';'; + len += 1; + len += fastprint.printDecimal(u8, buf[len..], rgb.g); + buf[len] = ';'; + len += 1; + len += fastprint.printDecimal(u8, buf[len..], rgb.b); + buf[len] = 'm'; + len += 1; + return len; + } }; const HtmlFormatter = struct { @@ -444,18 +523,52 @@ pub const Style = struct { property: []const u8, c: Color, ) !void { + // Style emission is a hot path when formatting styled terminal + // contents, so the values are assembled in a buffer and written + // in one call rather than going through the (slower) format + // string machinery. + var buf: [32]u8 = undefined; + var len: usize = 0; switch (c) { - .none => {}, + .none => return, + + // `{property}: rgb({r}, {g}, {b});` .palette => |idx| { if (self.palette) |p| { - const rgb = p[idx]; - try writer.print("{s}: rgb({d}, {d}, {d});", .{ property, rgb.r, rgb.g, rgb.b }); + len = formatColorRgb(&buf, p[idx]); } else { - try writer.print("{s}: var(--vt-palette-{d});", .{ property, idx }); + // `{property}: var(--vt-palette-{idx});` + const prefix = ": var(--vt-palette-"; + buf[0..prefix.len].* = prefix.*; + len = prefix.len; + len += fastprint.printDecimal(u8, buf[len..], idx); + buf[len..][0..2].* = ");".*; + len += 2; } }, - .rgb => |rgb| try writer.print("{s}: rgb({d}, {d}, {d});", .{ property, rgb.r, rgb.g, rgb.b }), + + .rgb => |rgb| len = formatColorRgb(&buf, rgb), } + + try writer.writeAll(property); + try writer.writeAll(buf[0..len]); + } + + /// Writes `: rgb({r}, {g}, {b});` into buf, returning the length + /// written. + fn formatColorRgb(buf: *[32]u8, rgb: color.RGB) usize { + buf[0..6].* = ": rgb(".*; + var len: usize = 6; + len += fastprint.printDecimal(u8, buf[len..], rgb.r); + buf[len..][0..2].* = ", ".*; + len += 2; + len += fastprint.printDecimal(u8, buf[len..], rgb.g); + buf[len..][0..2].* = ", ".*; + len += 2; + len += fastprint.printDecimal(u8, buf[len..], rgb.b); + buf[len..][0..2].* = ");".*; + len += 2; + return len; } }; diff --git a/src/terminfo/Source.zig b/src/terminfo/Source.zig index 922eec016..357e0ca26 100644 --- a/src/terminfo/Source.zig +++ b/src/terminfo/Source.zig @@ -12,7 +12,7 @@ const std = @import("std"); /// and are used to look up this terminal. Historically, the final name in the /// list was the most common name for the terminal and contains spaces and /// other characters. See terminfo(5) for details. -names: []const []const u8, +names: []const [:0]const u8, /// The set of capabilities in this terminfo file. capabilities: []const Capability, @@ -36,7 +36,7 @@ pub const Capability = struct { /// because it is a common integer size but this may be wrong. numeric: u32, - string: []const u8, + string: [:0]const u8, }; }; @@ -68,8 +68,12 @@ pub fn encode(self: Source, writer: *std.Io.Writer) !void { /// Returns a StaticStringMap for all of the capabilities in this terminfo. /// The value is the value that should be sent as a response to XTGETTCAP. /// Important: the value is the FULL response included the escape sequences. -pub fn xtgettcapMap(comptime self: Source) std.StaticStringMap([]const u8) { - const KV = struct { []const u8, []const u8 }; +/// +/// The responses are null-terminated so that they can be handed directly +/// to APIs that require a sentinel (e.g. the libghostty-vt write_pty +/// callback) without copying. +pub fn xtgettcapMap(comptime self: Source) std.StaticStringMap([:0]const u8) { + const KV = struct { []const u8, [:0]const u8 }; // We have all of our capabilities plus To, TN, and RGB which aren't // in the capabilities list but are query-able. @@ -115,11 +119,11 @@ pub fn xtgettcapMap(comptime self: Source) std.StaticStringMap([]const u8) { break :string result; }, .numeric => |v| numeric: { - var buf: [10]u8 = undefined; - var writer: std.Io.Writer = .fixed(&buf); + var buf: [11]u8 = @splat(0); + var writer: std.Io.Writer = .fixed(buf[0..10]); writer.printInt(v, 10, .upper, .{}) catch unreachable; const final = buf; - break :numeric final[0..writer.end]; + break :numeric final[0..writer.end :0]; }, }, }; @@ -130,8 +134,9 @@ pub fn xtgettcapMap(comptime self: Source) std.StaticStringMap([]const u8) { // The key is just the raw hex-encoded string entry[0] = hexencode(entry[0]); - // The value is more complex - var buf: [5 + entry[0].len + 1 + (entry[1].len * 2) + 2]u8 = undefined; + // The value is more complex. The buffer is zeroed so the byte + // after the response is already the null terminator. + var buf: [5 + entry[0].len + 1 + (entry[1].len * 2) + 2 + 1]u8 = @splat(0); const out = if (std.mem.eql(u8, entry[1], "")) std.fmt.bufPrint( &buf, "\x1bP1+r{s}\x1b\\", @@ -143,11 +148,11 @@ pub fn xtgettcapMap(comptime self: Source) std.StaticStringMap([]const u8) { ) catch unreachable; const final = buf; - entry[1] = final[0..out.len]; + entry[1] = final[0..out.len :0]; } const kvs_final = kvs; - return std.StaticStringMap([]const u8).initComptime(&kvs_final); + return std.StaticStringMap([:0]const u8).initComptime(&kvs_final); } fn hexencode(comptime input: []const u8) []const u8 { @@ -160,13 +165,13 @@ fn comptimeReplace( input: []const u8, needle: []const u8, replacement: []const u8, -) []const u8 { +) [:0]const u8 { comptime { const len = std.mem.replacementSize(u8, input, needle, replacement); - var buf: [len]u8 = undefined; - _ = std.mem.replace(u8, input, needle, replacement, &buf); + var buf: [len + 1]u8 = @splat(0); + _ = std.mem.replace(u8, input, needle, replacement, buf[0..len]); const final = buf; - return &final; + return final[0..len :0]; } } diff --git a/src/terminfo/ghostty.zig b/src/terminfo/ghostty.zig index 5788170e9..2b7434cbf 100644 --- a/src/terminfo/ghostty.zig +++ b/src/terminfo/ghostty.zig @@ -106,6 +106,10 @@ pub const ghostty: Source = .{ // Curly, dashed, etc underlines .{ .name = "Smulx", .value = .{ .string = "\\E[4:%p1%dm" } }, + // Overline + .{ .name = "Smol", .value = .{ .string = "\\E[53m" } }, + .{ .name = "Rmol", .value = .{ .string = "\\E[55m" } }, + // Colored underlines .{ .name = "Setulc", .value = .{ .string = "\\E[58:2::%p1%{65536}%/%d:%p1%{256}%/%{255}%&%d:%p1%{255}%&%d%;m" } }, diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 7b520a759..016291712 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -22,7 +22,6 @@ const shell_integration = @import("shell_integration.zig"); const terminal = @import("../terminal/main.zig"); const termio = @import("../termio.zig"); const Command = @import("../Command.zig"); -const SegmentedPool = @import("../datastruct/main.zig").SegmentedPool; const ptypkg = @import("../pty.zig"); const Pty = ptypkg.Pty; const EnvMap = std.process.Environ.Map; @@ -418,8 +417,9 @@ pub fn queueWrite( // our cached buffers that we can queue to the stream. var i: usize = 0; while (i < data.len) { - const req = try exec.write_req_pool.getGrow(alloc); - const buf = try exec.write_buf_pool.getGrow(alloc); + const w = try exec.write_pool.create(alloc); + w.td = exec; + const buf = &w.buf; const slice = slice: { // The maximum end index is either the end of our data or // the end of our buffer, whichever is smaller. @@ -459,26 +459,25 @@ pub fn queueWrite( exec.write_stream.queueWrite( td.loop, &exec.write_queue, - req, + &w.req, .{ .slice = slice }, - termio.Exec.ThreadData, - exec, + ThreadData.Write, + w, ttyWrite, ); } } fn ttyWrite( - td_: ?*ThreadData, + w_: ?*ThreadData.Write, _: *xev.Loop, _: *xev.Completion, _: xev.Stream, _: xev.WriteBuffer, r: xev.WriteError!usize, ) xev.CallbackAction { - const td = td_.?; - td.write_req_pool.put(); - td.write_buf_pool.put(); + const w = w_.?; + w.td.write_pool.destroy(w); const d = r catch |err| { log.err("write error: {}", .{err}); @@ -492,9 +491,21 @@ fn ttyWrite( /// The thread local data for the exec implementation. pub const ThreadData = struct { - // The preallocation size for the write request pool. This should be big - // enough to satisfy most write requests. It must be a power of 2. - const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5); + /// The state for a single queued pty write. The write request and + /// the buffer it writes from must both remain pointer-stable until + /// the write completes, so they're pooled together and checked out + /// per write. + pub const Write = struct { + /// Backpointer to the thread data so the write completion + /// callback can put this back into the pool. + td: *ThreadData, + + /// The libxev write request. + req: xev.WriteRequest, + + /// The buffer for the data being written. + buf: [64]u8, + }; /// Process start time and boolean of whether its already exited. start: std.Io.Timestamp, @@ -506,12 +517,9 @@ pub const ThreadData = struct { /// The process watcher process: ?xev.Process, - /// This is the pool of available (unused) write requests. If you grab + /// This is the pool of available (unused) write states. If you grab /// one from the pool, you must put it back when you're done! - write_req_pool: SegmentedPool(xev.WriteRequest, WRITE_REQ_PREALLOC) = .{}, - - /// The pool of available buffers for writing to the pty. - write_buf_pool: SegmentedPool([64]u8, WRITE_REQ_PREALLOC) = .{}, + write_pool: std.heap.MemoryPool(Write) = .empty, /// The write queue for the data stream. write_queue: xev.WriteQueue = .{}, @@ -541,11 +549,10 @@ pub const ThreadData = struct { pub fn deinit(self: *ThreadData, alloc: Allocator) void { _ = posix.system.close(self.read_thread_pipe); - // Clear our write pools. We know we aren't ever going to do + // Clear our write pool. We know we aren't ever going to do // any more IO since we stop our data stream below so we can just // drop this. - self.write_req_pool.deinit(alloc); - self.write_buf_pool.deinit(alloc); + self.write_pool.deinit(alloc); // Stop our process watcher if (self.process) |*p| p.deinit(); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index e3b564bc9..7d6e179d8 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -307,7 +307,10 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .size = opts.size, .backend = backend, .mailbox = opts.mailbox, - .terminal_stream = .initAlloc(alloc, handler), + .terminal_stream = .init(.{ + .allocator = alloc, + .handler = handler, + }), .thread_enter_state = thread_enter_state, }; } @@ -457,7 +460,7 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi }; // Set the image limits - try self.terminal.setKittyGraphicsSizeLimit(self.alloc, config.image_storage_limit); + self.terminal.setKittyGraphicsSizeLimit(self.alloc, config.image_storage_limit); self.terminal.setKittyGraphicsLoadingLimits(.allWithTempDir(global.tmpDirPath())); } diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 088096780..6b860a0d1 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -298,7 +298,7 @@ fn drainMailbox( // If we're draining, we just drain the mailbox and return. if (self.flags.drain) { - while (mailbox.pop(global.io())) |_| {} + while (mailbox.pop(global.io())) |msg| msg.deinit(); return; } diff --git a/src/termio/mailbox.zig b/src/termio/mailbox.zig index d5926f3a8..a053aaabe 100644 --- a/src/termio/mailbox.zig +++ b/src/termio/mailbox.zig @@ -47,6 +47,7 @@ pub const Mailbox = union(enum) { pub fn deinit(self: *Mailbox, alloc: Allocator) void { switch (self.*) { .spsc => |*v| { + while (v.queue.pop(global.io())) |msg| msg.deinit(); v.queue.destroy(alloc); v.wakeup.deinit(); }, @@ -76,6 +77,7 @@ pub const Mailbox = union(enum) { // lock so we need to unlock. mb.wakeup.notify() catch |err| { log.warn("failed to wake up writer, data will be dropped err={}", .{err}); + msg.deinit(); return; }; @@ -90,7 +92,7 @@ pub const Mailbox = union(enum) { // here. if (mutex) |m| m.unlock(global.io()); defer if (mutex) |m| m.lockUncancelable(global.io()); - _ = mb.queue.push(global.io(), msg, .{ .forever = {} }); + if (mb.queue.push(global.io(), msg, .{ .forever = {} }) == 0) msg.deinit(); }, } } diff --git a/src/termio/message.zig b/src/termio/message.zig index 5830a1db5..e51865e39 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -8,21 +8,21 @@ const MessageData = @import("../datastruct/main.zig").MessageData; /// The messages that can be sent to an IO thread. /// /// This is not a tiny structure (~40 bytes at the time of writing this comment), -/// but the messages are IO thread sends are also very few. At the current size -/// we can queue 26,000 messages before consuming a MB of RAM. +/// but the number of messages we send to the IO thread is also very small. +/// At the current size we can queue 26,000 messages before consuming a MB of RAM. pub const Message = union(enum) { /// Represents a write request. Magic number comes from the largest /// other union value. It can be upped if we add a larger union member /// in the future. pub const WriteReq = MessageData(u8, 38); - /// Request a color scheme report is sent to the pty. + /// Request a color scheme report is sent to the PTY. color_scheme_report: struct { /// Force write the current color scheme force: bool, }, - /// Request a visibility report is sent to the pty. + /// Request a visibility report is sent to the PTY. visibility_report: struct { /// The visibility state to report. visible: bool, @@ -48,7 +48,7 @@ pub const Message = union(enum) { /// Resize the window. resize: renderer.Size, - /// Request a size report is sent to the pty ([in-band + /// Request a size report is sent to the PTY ([in-band /// size report, mode 2048](https://gist.github.com/rockorager/e695fb2924d36b2bcf1fff4a3704bd83) and /// [XTWINOPS](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h4-Functions-using-CSI-_-ordered-by-the-final-character-lparen-s-rparen:CSI-Ps;Ps;Ps-t.1EB0)). size_report: SizeReport, @@ -102,6 +102,19 @@ pub const Message = union(enum) { }; } + /// Free resources owned by a message that will not be processed. + /// The message is invalid after this call. + pub fn deinit(self: *const Message) void { + switch (self.*) { + .change_config => |v| { + v.ptr.deinit(); + v.alloc.destroy(v.ptr); + }, + .write_alloc => |v| v.alloc.free(v.data), + else => {}, + } + } + /// The types of size reports that we support. pub const SizeReport = terminal.size_report.Style; }; diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 372ccb41c..c23ff9ad9 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -394,7 +394,7 @@ fn setupBash( var environ_map = try global.environMap(); defer environ_map.deinit(); var home_buf: [1024]u8 = undefined; - if (try homedir.home(&environ_map, &home_buf)) |home| { + if (try homedir.home(global.io(), &environ_map, &home_buf)) |home| { var histfile_buf: [std.fs.max_path_bytes]u8 = undefined; const histfile = try std.fmt.bufPrint( &histfile_buf, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index ddc5eb82b..ac269e7bc 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -476,11 +476,12 @@ pub const StreamHandler = struct { } pub fn apcEnd(self: *StreamHandler) !void { - var cmd = self.apc.end() orelse return; - defer cmd.deinit(self.alloc); + var result = self.apc.end() orelse return; + defer result.deinit(self.alloc); - // log.warn("APC command: {}", .{cmd}); - switch (cmd) { + // log.warn("APC command: {}", .{result}); + switch (result) { + .unknown => return, .kitty => |*kitty_cmd| { if (self.terminal.kittyGraphics(global.io(), self.alloc, kitty_cmd)) |resp| { var buf: [1024]u8 = undefined; diff --git a/typos.toml b/typos.toml index 2deb781be..46ac28dd8 100644 --- a/typos.toml +++ b/typos.toml @@ -80,6 +80,9 @@ rin = "rin" ower = "ower" # OpenType table names loca = "loca" +# TinyIo +tio = "tio" +WRONLY = "WRONLY" [type.po] extend-glob = ["*.po"]