diff --git a/.agents/commands/gh-issue b/.agents/commands/gh-issue deleted file mode 100755 index de2f37335..000000000 --- a/.agents/commands/gh-issue +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env nu - -# A command to generate an agent prompt to diagnose and formulate -# a plan for resolving a GitHub issue. -# -# IMPORTANT: This command is prompted to NOT write any code and to ONLY -# produce a plan. You should still be vigilant when running this but that -# is the expected behavior. -# -# The `` parameter can be either an issue number or a full GitHub -# issue URL. -def main [ - issue: any, # Ghostty issue number or URL - --repo: string = "ghostty-org/ghostty" # GitHub repository in the format "owner/repo" -] { - # TODO: This whole script doesn't handle errors very well. I actually - # don't know Nu well enough to know the proper way to handle it all. - - let issueData = gh issue view $issue --json author,title,number,body,comments | from json - let comments = $issueData.comments | each { |comment| - $" -### Comment by ($comment.author.login) -($comment.body) -" | str trim - } | str join "\n\n" - - $" -Deep-dive on this GitHub issue. Find the problem and generate a plan. -Do not write code. Explain the problem clearly and propose a comprehensive plan -to solve it. - -# ($issueData.title) \(($issueData.number)\) - -## Description -($issueData.body) - -## Comments -($comments) - -## Your Tasks - -You are an experienced software developer tasked with diagnosing issues. - -1. Review the issue context and details. -2. Examine the relevant parts of the codebase. Analyze the code thoroughly - until you have a solid understanding of how it works. -3. Explain the issue in detail, including the problem and its root cause. -4. Create a comprehensive plan to solve the issue. The plan should include: - - Required code changes - - Potential impacts on other parts of the system - - Necessary tests to be written or updated - - Documentation updates - - Performance considerations - - Security implications - - Backwards compatibility \(if applicable\) - - Include the reference link to the source issue and any related discussions -4. Think deeply about all aspects of the task. Consider edge cases, potential - challenges, and best practices for addressing the issue. Review the plan - with the oracle and adjust it based on its feedback. - -**ONLY CREATE A PLAN. DO NOT WRITE ANY CODE.** Your task is to create -a thorough, comprehensive strategy for understanding and resolving the issue. -" | str trim -} diff --git a/.agents/skills/writing-commit-messages/SKILL.md b/.agents/skills/writing-commit-messages/SKILL.md new file mode 100644 index 000000000..dedadbe5e --- /dev/null +++ b/.agents/skills/writing-commit-messages/SKILL.md @@ -0,0 +1,62 @@ +--- +name: writing-commit-messages +description: >- + Writes Git commit messages. Activates when the user asks to write + a commit message, draft a commit message, or similar. +--- + +# Writing Commit Messages + +Write commit messages that follow commit style guidelines for the project. + +## Format + +``` +: + + + + +``` + +## Rules + +### Subject line + +- **Subsystem prefix**: Use a short, lowercase identifier for the + area of code changed (e.g., `terminal`, `vt`, `lib`, `config`, + `font`). Determine this from the file paths in the diff. If + changes span the macOS app, use `macos`. For GTK, use `gtk`. For + build system, use `build`. Use nested subsystems with `/` when + helpful and exclusive (e.g., `terminal/osc`). +- **Summary**: Lowercase start (not capitalized), imperative mood, + no trailing period. Keep it concise—ideally under 60 characters + total for the whole subject line. + +### References + +- If the change relates to a GitHub issue, PR, or discussion, list + the relevant numbers on their own lines after the subject, separated + by a blank line. E.g. `#1234` +- If there are no references, omit this section entirely (no blank + line). + +### Long form description + +- Describe **what changed**, **what the previous behavior was**, + 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. +- Don't exceed a handful of paragraphs; less is more. + +## Workflow + +- If `.jj` is present, use `jj` instead of `git` for all commands. +- Run a diff to see what changes are present since the last commit. +- Identify the subsystem from the changed file paths. +- Identify any referenced issues/PRs from the diff context or + branch name. +- Draft the commit message following the format above. +- Apply the commit +- Don't push the commit; leave that to the user. diff --git a/.gitattributes b/.gitattributes index 9158b3979..2e976e5f9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,3 +12,4 @@ src/font/nerd_font_attributes.zig linguist-generated=true src/font/nerd_font_codepoint_tables.py linguist-generated=true src/font/res/** linguist-vendored src/terminal/res/** linguist-vendored +src/terminal/res/rgb.txt -text diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 54684edf9..353053fe8 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -19,6 +19,7 @@ # discussion by the author. Maintainers can denounce users by commenting # "!denounce" or "!denounce [username]" on a discussion. 00-kat +04cb aalhendi abdurrahmanski abudvytis @@ -28,8 +29,12 @@ alaasdk alanmoyano alexfeijoo44 alexjuca +alosarjos amadeus andrejdaskalov +anhthang +anmitalidev +anthonyzhoon atomk balazs-szucs bennettp123 @@ -40,24 +45,36 @@ bitigchi bkircher bo2themax brentschroeter +brianc442 cespare charliie-dev chernetskyi +chronologos cmwetherell +crayxt craziestowl curtismoncoq d-dudas daiimus damyanbogoev danulqua +dariogriffo +davidsanchez222 +deblasis dervedro +devsunb diaaeddin +dmehala doprz douglance +douglas drepper +dzhlobo +ekaterinepapava elias8 ephemera eriksremess +faukah filip7 flou francescarpi @@ -69,12 +86,20 @@ gpanders guilhermetk hakonhagland halosatrio +heaths +heddxh heddxh +-highimpact-dev Disrespectful AI user +hlcfan hqnna +hulet icodesign +j0hnm4r5 jacobsandlund jake-stewart jcollie +jesusvazquez jguthmiller +jmcgover johnslavik josephmart jparise @@ -85,6 +110,7 @@ khipp kirwiisp kjvdven kloneets +-kody-w koranir kristina8888 kristofersoler @@ -92,12 +118,18 @@ laxystem liby linustalacko lonsagisawa +luisnquin +lynicis +mac0ne mahnokropotkinvich marijagjorgjieva markdorison markhuot +marler8997 marrocco-simone matkotiric +micaeljarniac +michielvk miguelelgallo mihi314 mikailmm @@ -105,6 +137,7 @@ misairuzame mischief mitchellh miupa +molechowski mrmage mtak natesmyth @@ -113,17 +146,23 @@ nicosuave nmggithub noib3 nwehg +ocean6954 oshdubh +paaloeye pan93412 pangoraw +pauley-unsaturated peilingjiang peterdavehello +philocalyst phush0 piedrahitac pluiedev pouwerkerk +poweruser64 prakhar54-byte priyans-hu +puzza007 qwerasd205 reo101 rgehan @@ -133,18 +172,26 @@ rmunn rockorager rpfaeffle secrus +seruman silveirapf slsrepo sunshine-syz +tdgroot tdslot ticclick tnagatomi trag1c tristan957 +turbolent tweedbeetle uhojin +unphased uzaaft +vaughanandrews +viruslobster vlsi +wyounas yamshta +ydah zenyr zeshi09 diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index 7bb39faf2..d64ab829a 100644 --- a/.github/workflows/flatpak.yml +++ b/.github/workflows/flatpak.yml @@ -30,7 +30,7 @@ jobs: runs-on: ${{ matrix.variant.runner }} steps: - name: Download Source Tarball Artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: run-id: ${{ inputs.source-run-id }} artifact-ids: ${{ inputs.source-artifact-id }} diff --git a/.github/workflows/milestone.yml b/.github/workflows/milestone.yml index 33a074159..34e7652a7 100644 --- a/.github/workflows/milestone.yml +++ b/.github/workflows/milestone.yml @@ -11,12 +11,15 @@ on: jobs: update-milestone: + # Ignore bot-authored pull requests (dependabot, app bots, etc) + # and CI-only PRs. + if: github.event_name == 'issues' || (github.event.pull_request.user.type != 'Bot' && !startsWith(github.event.pull_request.title, 'ci:')) runs-on: namespace-profile-ghostty-sm name: Milestone Update steps: - name: Set Milestone for PR uses: hustcer/milestone-action@ebed8d5daafd855a600d7e665c1b130f06d24130 # v3.1 - if: github.event.pull_request.merged == true + if: github.event.pull_request.merged == true && !contains(github.event.pull_request.title, 'VOUCHED') && !startsWith(github.event.pull_request.title, 'ci:') with: action: bind-pr # `bind-pr` is the default action github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index fe3dd1336..e2aa913aa 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -47,10 +47,10 @@ jobs: /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index f7d4a7b6e..3e1c0f15e 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -89,11 +89,11 @@ jobs: /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -147,7 +147,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -299,7 +299,7 @@ jobs: curl -sL https://sentry.io/get-cli/ | bash - name: Download macOS Artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: macos @@ -322,7 +322,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download macOS Artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: macos @@ -370,17 +370,17 @@ jobs: GHOSTTY_VERSION: ${{ needs.setup.outputs.version }} steps: - name: Download macOS Artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: macos - name: Download Sparkle Artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: sparkle - name: Download Source Tarball Artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: source-tarball diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index a2d8c1078..218c10244 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -42,10 +42,10 @@ jobs: with: path: | /nix - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -175,10 +175,10 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -195,7 +195,7 @@ jobs: nix develop -c minisign -S -m ghostty-source.tar.gz -s minisign.key < minisign.password - name: Update Release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -245,7 +245,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -378,7 +378,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -501,7 +501,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -627,7 +627,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -698,7 +698,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -824,7 +824,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 with: name: 'Ghostty Tip ("Nightly")' prerelease: true diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml index bdef91c30..67f291601 100644 --- a/.github/workflows/snap.yml +++ b/.github/workflows/snap.yml @@ -26,7 +26,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Download Source Tarball Artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: run-id: ${{ inputs.source-run-id }} artifact-ids: ${{ inputs.source-artifact-id }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2b59d2e97..3d9082642 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,19 +31,19 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: filter_every with: - token: ${{ secrets.GITHUB_TOKEN }} + token: "" predicate-quantifier: "every" filters: | code: - '**' - '!.github/VOUCHED.td' - - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: filter_any with: - token: ${{ secrets.GITHUB_TOKEN }} + token: "" filters: | macos: - '.swiftlint.yml' @@ -85,24 +85,28 @@ jobs: - skip - build-bench - build-dist - - build-examples + - build-examples-zig + - build-examples-cmake + - build-examples-cmake-windows + - build-cmake - build-flatpak - build-libghostty-vt - build-libghostty-vt-android - build-libghostty-vt-macos + - build-libghostty-vt-windows - build-linux - build-linux-libghostty - build-nix - build-macos - build-macos-freetype - build-snap - - build-windows - test - test-simd - test-gtk - test-sentry-linux - test-i18n - test-fuzz-libghostty + - test-lib-vt - test-macos - pinact - prettier @@ -156,10 +160,10 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -167,21 +171,123 @@ jobs: - name: Build Benchmarks run: nix develop -c zig build -Demit-bench - build-examples: + list-examples: + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' + needs: skip + runs-on: namespace-profile-ghostty-xsm + outputs: + zig: ${{ steps.list.outputs.zig }} + cmake: ${{ steps.list.outputs.cmake }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - id: list + name: List example directories + run: | + zig=$(ls example/*/build.zig.zon 2>/dev/null | xargs -n1 dirname | xargs -n1 basename | jq -R -s -c 'split("\n") | map(select(. != ""))') + echo "$zig" | jq . + echo "zig=$zig" >> "$GITHUB_OUTPUT" + cmake=$(ls example/*/CMakeLists.txt 2>/dev/null | xargs -n1 dirname | xargs -n1 basename | jq -R -s -c 'split("\n") | map(select(. != ""))') + echo "$cmake" | jq . + echo "cmake=$cmake" >> "$GITHUB_OUTPUT" + + build-examples-zig: strategy: fail-fast: false matrix: - dir: - [ - c-vt, - c-vt-key-encode, - c-vt-paste, - c-vt-sgr, - zig-formatter, - zig-vt, - zig-vt-stream, - ] + dir: ${{ fromJSON(needs.list-examples.outputs.zig) }} name: Example ${{ matrix.dir }} + runs-on: namespace-profile-ghostty-xsm + needs: [test, list-examples] + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + path: | + /nix + /zig + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Build Example + run: | + cd example/${{ matrix.dir }} + nix develop -c zig build + + build-examples-cmake: + strategy: + fail-fast: false + matrix: + dir: ${{ fromJSON(needs.list-examples.outputs.cmake) }} + name: Example ${{ matrix.dir }} + runs-on: namespace-profile-ghostty-xsm + needs: [test, list-examples] + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + path: | + /nix + /zig + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Build Example + run: | + cd example/${{ matrix.dir }} + nix develop -c cmake -B build -DFETCHCONTENT_SOURCE_DIR_GHOSTTY=${{ github.workspace }} + nix develop -c cmake --build build + + build-examples-cmake-windows: + strategy: + fail-fast: false + matrix: + dir: ${{ fromJSON(needs.list-examples.outputs.cmake) }} + name: Example ${{ matrix.dir }} (Windows) + runs-on: windows-2025 + timeout-minutes: 45 + needs: [test, list-examples] + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install zig + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + + - name: Build Example + shell: pwsh + run: | + cd example/${{ matrix.dir }} + cmake -B build -DFETCHCONTENT_SOURCE_DIR_GHOSTTY=${{ github.workspace }} + cmake --build build + + build-cmake: runs-on: namespace-profile-ghostty-sm needs: test env: @@ -199,18 +305,25 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: Build Example + - name: Build run: | - cd example/${{ matrix.dir }} - nix develop -c zig build + nix develop -c cmake -B build + nix develop -c cmake --build build + + - name: Verify artifacts + run: | + test -f zig-out/lib/libghostty-vt.so.0.1.0 + test -d zig-out/include/ghostty + ls -la zig-out/lib/ + ls -la zig-out/include/ghostty/ build-flatpak: strategy: @@ -232,10 +345,10 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -266,10 +379,10 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -310,21 +423,21 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: Build run: | - nix develop -c zig build lib-vt \ + nix develop -c zig build -Demit-lib-vt \ -Dtarget=${{ matrix.target }} \ -Dsimd=false - # lib-vt requires macOS runner for macOS/iOS builds becauase it requires the `apple_sdk` path + # lib-vt requires macOS runner for macOS/iOS builds because it requires the `apple_sdk` path build-libghostty-vt-macos: strategy: matrix: @@ -350,7 +463,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -360,7 +473,7 @@ jobs: - name: Build run: | - nix develop -c zig build lib-vt \ + nix develop -c zig build -Demit-lib-vt \ -Dtarget=${{ matrix.target }} # lib-vt requires the Android NDK for Android builds @@ -387,10 +500,10 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -406,11 +519,28 @@ jobs: - name: Build run: | - nix develop -c zig build lib-vt \ + nix develop -c zig build -Demit-lib-vt \ -Dtarget=${{ matrix.target }} env: ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + build-libghostty-vt-windows: + runs-on: windows-2025 + timeout-minutes: 45 + needs: test + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Zig + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + + - name: Test libghostty-vt + run: zig build test-lib-vt + + - name: Build libghostty-vt + run: zig build -Demit-lib-vt + build-linux: strategy: fail-fast: false @@ -433,10 +563,10 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -462,10 +592,10 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -495,10 +625,10 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -541,10 +671,10 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -621,7 +751,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -681,7 +811,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -696,83 +826,16 @@ jobs: id: deps run: nix build -L .#deps && echo "deps=$(readlink ./result)" >> $GITHUB_OUTPUT + # We run tests with an empty test filter so it runs all unit tests + # but skips Xcode tests - name: Test All run: | - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_freetype + nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_freetype -Dtest-filter="" - name: Build All run: | nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_freetype - build-windows: - runs-on: windows-2022 - # this will not stop other jobs from running - continue-on-error: true - timeout-minutes: 45 - needs: test - steps: - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - # This could be from a script if we wanted to but inlining here for now - # in one place. - # Using powershell so that we do not need to install WSL components. Also, - # WSLv1 is only installed on Github runners. - - name: Install zig - shell: pwsh - run: | - # Get the zig version from build.zig.zon so that it only needs to be updated - $fileContent = Get-Content -Path "build.zig.zon" -Raw - $pattern = 'minimum_zig_version\s*=\s*"([^"]+)"' - $zigVersion = [regex]::Match($fileContent, $pattern).Groups[1].Value - $version = "zig-x86_64-windows-$zigVersion" - Write-Output $version - $uri = "https://ziglang.org/download/$zigVersion/$version.zip" - Invoke-WebRequest -Uri "$uri" -OutFile ".\zig-windows.zip" - Expand-Archive -Path ".\zig-windows.zip" -DestinationPath ".\" -Force - Remove-Item -Path ".\zig-windows.zip" - Rename-Item -Path ".\$version" -NewName ".\zig" - Write-Host "Zig installed." - .\zig\zig.exe version - - - name: Generate build testing script - shell: pwsh - run: | - # Generate a script so that we can swallow the errors - $scriptContent = @" - .\zig\zig.exe build test 2>&1 | Out-File -FilePath "build.log" -Append - exit 0 - "@ - $scriptPath = "zigbuild.ps1" - # Write the script content to a file - $scriptContent | Set-Content -Path $scriptPath - Write-Host "Script generated at: $scriptPath" - - - name: Test Windows - shell: pwsh - run: .\zigbuild.ps1 -ErrorAction SilentlyContinue - - - name: Generate build script - shell: pwsh - run: | - # Generate a script so that we can swallow the errors - $scriptContent = @" - .\zig\zig.exe build 2>&1 | Out-File -FilePath "build.log" -Append - exit 0 - "@ - $scriptPath = "zigbuild.ps1" - # Write the script content to a file - $scriptContent | Set-Content -Path $scriptPath - Write-Host "Script generated at: $scriptPath" - - - name: Build Windows - shell: pwsh - run: .\zigbuild.ps1 -ErrorAction SilentlyContinue - - - name: Dump logs - shell: pwsh - run: Get-Content -Path ".\build.log" - test: if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' needs: skip @@ -799,10 +862,10 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -817,6 +880,36 @@ jobs: - name: Test System Build run: nix develop -c zig build --system ${ZIG_GLOBAL_CACHE_DIR}/p + test-lib-vt: + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' + needs: skip + runs-on: namespace-profile-ghostty-md + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + path: | + /nix + /zig + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Test + run: nix develop -c zig build test-lib-vt + test-gtk: strategy: fail-fast: false @@ -841,10 +934,10 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -889,10 +982,10 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -924,10 +1017,10 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -958,7 +1051,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -999,10 +1092,10 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1030,10 +1123,10 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1072,10 +1165,10 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1103,10 +1196,10 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1133,10 +1226,10 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1165,7 +1258,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1191,10 +1284,10 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1219,10 +1312,10 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1247,10 +1340,10 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1280,10 +1373,10 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1308,10 +1401,10 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1345,10 +1438,10 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1364,13 +1457,13 @@ jobs: needs: [test, build-dist] steps: - name: Install and configure Namespace CLI - uses: namespacelabs/nscloud-setup@f378676225212387f1283f4da878712af2c4cd60 # v0.0.11 + uses: namespacelabs/nscloud-setup@df198f982fcecfb8264bea3f1274b56a61b6dfdc # v0.0.12 - name: Configure Namespace powered Buildx - uses: namespacelabs/nscloud-setup-buildx-action@f5814dcf37a16cce0624d5bec2ab879654294aa0 # v0.0.22 + uses: namespacelabs/nscloud-setup-buildx-action@d059ed7184f0bc7c8b27e8810cea153d02bcc6dd # v0.0.23 - name: Download Source Tarball Artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: source-tarball @@ -1380,7 +1473,7 @@ jobs: tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Build and push - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: dist file: dist/src/build/docker/debian/Dockerfile @@ -1407,10 +1500,10 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 762b3d007..3c2ebd4a1 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -29,10 +29,10 @@ jobs: /zig - name: Setup Nix - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + uses: cachix/install-nix-action@51f3067b56fe8ae331890c77d4e454f6d60615ff # v31.10.2 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/.github/workflows/vouch-check-issue.yml b/.github/workflows/vouch-check-issue.yml index 3fa3bb542..d0456b00a 100644 --- a/.github/workflows/vouch-check-issue.yml +++ b/.github/workflows/vouch-check-issue.yml @@ -8,7 +8,7 @@ jobs: check: runs-on: namespace-profile-ghostty-xsm steps: - - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 id: app-token with: app-id: ${{ secrets.VOUCH_APP_ID }} diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml index 0efb6208c..0602553ca 100644 --- a/.github/workflows/vouch-check-pr.yml +++ b/.github/workflows/vouch-check-pr.yml @@ -8,7 +8,7 @@ jobs: check: runs-on: namespace-profile-ghostty-xsm steps: - - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 id: app-token with: app-id: ${{ secrets.VOUCH_APP_ID }} diff --git a/.github/workflows/vouch-manage-by-discussion.yml b/.github/workflows/vouch-manage-by-discussion.yml index cf7c092e2..7288c4ab2 100644 --- a/.github/workflows/vouch-manage-by-discussion.yml +++ b/.github/workflows/vouch-manage-by-discussion.yml @@ -12,7 +12,7 @@ jobs: manage: runs-on: namespace-profile-ghostty-xsm steps: - - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 id: app-token with: app-id: ${{ secrets.VOUCH_APP_ID }} diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml index 6f85520bd..6f61592ff 100644 --- a/.github/workflows/vouch-manage-by-issue.yml +++ b/.github/workflows/vouch-manage-by-issue.yml @@ -12,7 +12,7 @@ jobs: manage: runs-on: namespace-profile-ghostty-xsm steps: - - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 id: app-token with: app-id: ${{ secrets.VOUCH_APP_ID }} diff --git a/.github/workflows/vouch-sync-codeowners.yml b/.github/workflows/vouch-sync-codeowners.yml index fac06a372..0879c9722 100644 --- a/.github/workflows/vouch-sync-codeowners.yml +++ b/.github/workflows/vouch-sync-codeowners.yml @@ -13,7 +13,7 @@ jobs: sync: runs-on: namespace-profile-ghostty-xsm steps: - - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 id: app-token with: app-id: ${{ secrets.VOUCH_APP_ID }} diff --git a/.gitignore b/.gitignore index 40a04dbae..699ac9a5f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ zig-cache/ .zig-cache/ zig-out/ +build-cmake/ +CMakeCache.txt +CMakeFiles/ /build.zig.zon.bak /result* /.nixos-test-history @@ -25,3 +28,4 @@ glad.zip /ghostty.qcow2 vgcore.* + diff --git a/.prettierignore b/.prettierignore index f40567bfa..2699f7e10 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,6 +11,9 @@ zig-out/ # macos is managed by XCode GUI macos/ +# Xcode asset catalogs +**/*.xcassets/ + # produced by Icon Composer on macOS images/Ghostty.icon/icon.json diff --git a/AGENTS.md b/AGENTS.md index c6bd79b0e..3298f2160 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,16 +5,20 @@ A file for [guiding coding agents](https://agents.md/). ## Commands - **Build:** `zig build` + - If you're on macOS and don't need to build the macOS app, use + `-Demit-macos-app=false` to skip building the app bundle and speed up + compilation. - **Test (Zig):** `zig build test` + - Prefer to run targeted tests with `-Dtest-filter` because the full + test suite is slow to run. - **Test filter (Zig)**: `zig build test -Dtest-filter=` - **Formatting (Zig)**: `zig fmt .` -- **Formatting (Swift)**: `swiftlint lint --fix` +- **Formatting (Swift)**: `swiftlint lint --strict --fix` - **Formatting (other)**: `prettier -w .` ## Directory Structure - Shared Zig core: `src/` -- C API: `include` - macOS app: `macos/` - GTK (Linux and FreeBSD) app: `src/apprt/gtk` diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 000000000..6a6cd216a --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,230 @@ +# CMake wrapper for libghostty-vt +# +# This file delegates to `zig build -Demit-lib-vt` to produce the shared library, +# headers, and pkg-config file. It exists so that CMake-based projects can +# consume libghostty-vt without interacting with the Zig build system +# directly. However, downstream users do still require `zig` on the PATH. +# Please consult the Ghostty docs for the required Zig version: +# +# https://ghostty.org/docs/install/build +# +# Building within the Ghostty repo +# --------------------------------- +# +# cmake -B build +# cmake --build build +# cmake --install build --prefix /usr/local +# +# Pass extra flags to the Zig build with GHOSTTY_ZIG_BUILD_FLAGS: +# +# cmake -B build -DGHOSTTY_ZIG_BUILD_FLAGS="-Demit-macos-app=false" +# +# Integrating into a downstream CMake project +# --------------------------------------------- +# +# Option 1 — FetchContent (recommended, no manual install step): +# +# include(FetchContent) +# FetchContent_Declare(ghostty +# GIT_REPOSITORY https://github.com/ghostty-org/ghostty.git +# GIT_TAG main +# ) +# FetchContent_MakeAvailable(ghostty) +# +# target_link_libraries(myapp PRIVATE ghostty-vt) # shared +# target_link_libraries(myapp PRIVATE ghostty-vt-static) # static +# +# To use a local checkout instead of fetching: +# +# cmake -B build -DFETCHCONTENT_SOURCE_DIR_GHOSTTY=/path/to/ghostty +# +# Option 2 — find_package (after installing to a prefix): +# +# find_package(ghostty-vt REQUIRED) +# target_link_libraries(myapp PRIVATE ghostty-vt::ghostty-vt) # shared +# target_link_libraries(myapp PRIVATE ghostty-vt::ghostty-vt-static) # static +# +# See dist/cmake/README.md for more details and example/c-vt-cmake/ for a +# complete working example. + +cmake_minimum_required(VERSION 3.19) +project(ghostty-vt VERSION 0.1.0 LANGUAGES C) + +# --- Options ---------------------------------------------------------------- + +set(GHOSTTY_ZIG_BUILD_FLAGS "" CACHE STRING "Additional flags to pass to zig build") + +# Map CMake build types to Zig optimization levels. +if(CMAKE_BUILD_TYPE) + string(TOUPPER "${CMAKE_BUILD_TYPE}" _bt) + if(_bt STREQUAL "RELEASE" OR _bt STREQUAL "MINSIZEREL" OR _bt STREQUAL "RELWITHDEBINFO") + list(APPEND GHOSTTY_ZIG_BUILD_FLAGS "-Doptimize=ReleaseFast") + endif() + unset(_bt) +endif() + +# --- Find Zig ---------------------------------------------------------------- + +find_program(ZIG_EXECUTABLE zig REQUIRED) +message(STATUS "Found zig: ${ZIG_EXECUTABLE}") + +# --- Build via zig build ----------------------------------------------------- + +# The zig build installs into zig-out/ relative to the source tree. +set(ZIG_OUT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/zig-out") + +# Shared library names (zig build produces both shared and static). +if(APPLE) + set(GHOSTTY_VT_LIBNAME "${CMAKE_SHARED_LIBRARY_PREFIX}ghostty-vt${CMAKE_SHARED_LIBRARY_SUFFIX}") + set(GHOSTTY_VT_SONAME "${CMAKE_SHARED_LIBRARY_PREFIX}ghostty-vt.0${CMAKE_SHARED_LIBRARY_SUFFIX}") + set(GHOSTTY_VT_REALNAME "${CMAKE_SHARED_LIBRARY_PREFIX}ghostty-vt.0.1.0${CMAKE_SHARED_LIBRARY_SUFFIX}") +elseif(WIN32) + set(GHOSTTY_VT_LIBNAME "ghostty-vt.dll") + set(GHOSTTY_VT_REALNAME "ghostty-vt.dll") + set(GHOSTTY_VT_IMPLIB "ghostty-vt.lib") +else() + set(GHOSTTY_VT_LIBNAME "${CMAKE_SHARED_LIBRARY_PREFIX}ghostty-vt${CMAKE_SHARED_LIBRARY_SUFFIX}") + set(GHOSTTY_VT_SONAME "${CMAKE_SHARED_LIBRARY_PREFIX}ghostty-vt${CMAKE_SHARED_LIBRARY_SUFFIX}.0") + set(GHOSTTY_VT_REALNAME "${CMAKE_SHARED_LIBRARY_PREFIX}ghostty-vt${CMAKE_SHARED_LIBRARY_SUFFIX}.0.1.0") +endif() + +if(WIN32) + set(GHOSTTY_VT_SHARED_LIBRARY "${ZIG_OUT_DIR}/bin/${GHOSTTY_VT_REALNAME}") +else() + set(GHOSTTY_VT_SHARED_LIBRARY "${ZIG_OUT_DIR}/lib/${GHOSTTY_VT_REALNAME}") +endif() + +# Static library name. +# On Windows, the static lib is named "ghostty-vt-static.lib" to avoid +# colliding with the DLL import library "ghostty-vt.lib". +if(WIN32) + set(GHOSTTY_VT_STATIC_REALNAME "ghostty-vt-static.lib") +else() + set(GHOSTTY_VT_STATIC_REALNAME "libghostty-vt.a") +endif() +set(GHOSTTY_VT_STATIC_LIBRARY "${ZIG_OUT_DIR}/lib/${GHOSTTY_VT_STATIC_REALNAME}") + +# Ensure the output directories exist so CMake doesn't reject the +# INTERFACE_INCLUDE_DIRECTORIES before the zig build has run. +file(MAKE_DIRECTORY "${ZIG_OUT_DIR}/include") + +# Custom command: run zig build -Demit-lib-vt (produces both shared and static) +add_custom_command( + OUTPUT "${GHOSTTY_VT_SHARED_LIBRARY}" "${GHOSTTY_VT_STATIC_LIBRARY}" "${ZIG_OUT_DIR}/lib/${GHOSTTY_VT_IMPLIB}" + COMMAND "${ZIG_EXECUTABLE}" build -Demit-lib-vt ${GHOSTTY_ZIG_BUILD_FLAGS} + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + COMMENT "Building libghostty-vt via zig build..." + USES_TERMINAL +) + +add_custom_target(zig_build_lib_vt ALL + DEPENDS "${GHOSTTY_VT_SHARED_LIBRARY}" "${GHOSTTY_VT_STATIC_LIBRARY}" +) + +# Tell CMake's clean target to also remove Zig's output directory. +set_property(DIRECTORY APPEND PROPERTY + ADDITIONAL_CLEAN_FILES "${ZIG_OUT_DIR}" +) + +# --- IMPORTED library targets ------------------------------------------------ + +# Shared +add_library(ghostty-vt SHARED IMPORTED GLOBAL) +set_target_properties(ghostty-vt PROPERTIES + IMPORTED_LOCATION "${GHOSTTY_VT_SHARED_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${ZIG_OUT_DIR}/include" +) +if(APPLE) + set_target_properties(ghostty-vt PROPERTIES + IMPORTED_SONAME "@rpath/${GHOSTTY_VT_SONAME}" + ) +elseif(WIN32) + set_target_properties(ghostty-vt PROPERTIES + IMPORTED_IMPLIB "${ZIG_OUT_DIR}/lib/${GHOSTTY_VT_IMPLIB}" + ) +else() + set_target_properties(ghostty-vt PROPERTIES + IMPORTED_SONAME "${GHOSTTY_VT_SONAME}" + ) +endif() +add_dependencies(ghostty-vt zig_build_lib_vt) + +# Static +# +# When linking the static library, consumers must also link its transitive +# dependencies. By default (with SIMD enabled), these are: +# - libc +# - libc++ (or libstdc++ on Linux) +# - highway +# - simdutf +# +# Building with -Dsimd=false removes the C++ / highway / simdutf +# dependencies, leaving only libc. +add_library(ghostty-vt-static STATIC IMPORTED GLOBAL) +set_target_properties(ghostty-vt-static PROPERTIES + IMPORTED_LOCATION "${GHOSTTY_VT_STATIC_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${ZIG_OUT_DIR}/include" +) +if(WIN32) + # On Windows, the Zig standard library uses NT API functions + # (NtClose, NtCreateSection, etc.) and kernel32 functions that + # consumers must link when using the static library. + set_target_properties(ghostty-vt-static PROPERTIES + INTERFACE_LINK_LIBRARIES "ntdll;kernel32" + ) +endif() +add_dependencies(ghostty-vt-static zig_build_lib_vt) + +# --- Install ------------------------------------------------------------------ + +include(GNUInstallDirs) + +# Install shared library +if(WIN32) + # On Windows, install the DLL and PDB to bin/ and the import library to lib/ + install(FILES "${GHOSTTY_VT_SHARED_LIBRARY}" "${ZIG_OUT_DIR}/bin/ghostty-vt.pdb" TYPE BIN) + install(FILES "${ZIG_OUT_DIR}/lib/${GHOSTTY_VT_IMPLIB}" TYPE LIB) +else() + install(FILES "${GHOSTTY_VT_SHARED_LIBRARY}" TYPE LIB) + # Install symlinks + install(CODE " + execute_process(COMMAND \${CMAKE_COMMAND} -E create_symlink + \"${GHOSTTY_VT_REALNAME}\" + \"\$ENV{DESTDIR}\${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_LIBDIR}/${GHOSTTY_VT_SONAME}\") + execute_process(COMMAND \${CMAKE_COMMAND} -E create_symlink + \"${GHOSTTY_VT_SONAME}\" + \"\$ENV{DESTDIR}\${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_LIBDIR}/${GHOSTTY_VT_LIBNAME}\") + ") +endif() + +# Install static library +install(FILES "${GHOSTTY_VT_STATIC_LIBRARY}" TYPE LIB) + +# Install headers +install(DIRECTORY "${ZIG_OUT_DIR}/include/ghostty" DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") + +# --- CMake package config for find_package() ---------------------------------- + +include(CMakePackageConfigHelpers) + +# Generate the config file +configure_package_config_file( + "${CMAKE_CURRENT_SOURCE_DIR}/dist/cmake/ghostty-vt-config.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/ghostty-vt-config.cmake" + INSTALL_DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/ghostty-vt" +) + +# Generate the version file +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/ghostty-vt-config-version.cmake" + VERSION "${PROJECT_VERSION}" + COMPATIBILITY SameMajorVersion +) + +# Install the config files +install( + FILES + "${CMAKE_CURRENT_BINARY_DIR}/ghostty-vt-config.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/ghostty-vt-config-version.cmake" + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/ghostty-vt" +) diff --git a/CODEOWNERS b/CODEOWNERS index 1212133de..0e8aebe4e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -169,6 +169,7 @@ /po/de.po @ghostty-org/de_DE /po/es_AR.po @ghostty-org/es_AR /po/es_BO.po @ghostty-org/es_BO +/po/es_ES.po @ghostty-org/es_ES /po/fr.po @ghostty-org/fr_FR /po/ga.po @ghostty-org/ga_IE /po/he.po @ghostty-org/he_IL @@ -189,6 +190,7 @@ /po/ru.po @ghostty-org/ru_RU /po/tr.po @ghostty-org/tr_TR /po/uk.po @ghostty-org/uk_UA +/po/vi.po @ghostty-org/vi_VN /po/zh_CN.po @ghostty-org/zh_CN /po/zh_TW.po @ghostty-org/zh_TW diff --git a/README.md b/README.md index 961968097..293f5a6e2 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@

Fast, native, feature-rich terminal emulator pushing modern features.
+ A native GUI or embeddable library via libghostty. +
About · Download @@ -26,20 +28,13 @@ fast, feature-rich, and native. While there are many excellent terminal emulators available, they all force you to choose between speed, features, or native UIs. Ghostty provides all three. -In all categories, I am not trying to claim that Ghostty is the -best (i.e. the fastest, most feature-rich, or most native). But -Ghostty is competitive in all three categories and Ghostty -doesn't make you choose between them. - -Ghostty also intends to push the boundaries of what is possible with a -terminal emulator by exposing modern, opt-in features that enable CLI tool -developers to build more feature rich, interactive applications. - -While aiming for this ambitious goal, our first step is to make Ghostty -one of the best fully standards compliant terminal emulator, remaining -compatible with all existing shells and software while supporting all of -the latest terminal innovations in the ecosystem. You can use Ghostty -as a drop-in replacement for your existing terminal emulator. +**`libghostty`** is a cross-platform, zero-dependency C and Zig library +for building terminal emulators or utilizing terminal functionality +(such as style parsing). Anyone can use `libghostty` to build a terminal +emulator or embed a terminal into their own applications. See +[Ghostling](https://github.com/ghostty-org/ghostling) for a minimal complete project +example or the [`examples` directory](https://github.com/ghostty-org/ghostty/tree/main/example) +for smaller examples of using `libghostty` in C and Zig. For more details, see [About Ghostty](https://ghostty.org/docs/about). @@ -61,30 +56,37 @@ to get involved with Ghostty's development as well should also read the ## Roadmap and Status +Ghostty is stable and in use by millions of people and machines daily. + The high-level ambitious plan for the project, in order: -| # | Step | Status | -| :-: | --------------------------------------------------------- | :----: | -| 1 | Standards-compliant terminal emulation | ✅ | -| 2 | Competitive performance | ✅ | -| 3 | Basic customizability -- fonts, bg colors, etc. | ✅ | -| 4 | Richer windowing features -- multi-window, tabbing, panes | ✅ | -| 5 | Native Platform Experiences (i.e. Mac Preference Panel) | ⚠️ | -| 6 | Cross-platform `libghostty` for Embeddable Terminals | ⚠️ | -| 7 | Windows Terminals (including PowerShell, Cmd, WSL) | ❌ | -| N | Fancy features (to be expanded upon later) | ❌ | +| # | Step | Status | +| :-: | ------------------------------------------------------- | :----: | +| 1 | Standards-compliant terminal emulation | ✅ | +| 2 | Competitive performance | ✅ | +| 3 | Rich windowing features -- multi-window, tabbing, panes | ✅ | +| 4 | Native Platform Experiences | ✅ | +| 5 | Cross-platform `libghostty` for Embeddable Terminals | ✅ | +| 6 | Ghostty-only Terminal Control Sequences | ❌ | Additional details for each step in the big roadmap below: #### Standards-Compliant Terminal Emulation -Ghostty implements enough control sequences to be used by hundreds of -testers daily for over the past year. Further, we've done a -[comprehensive xterm audit](https://github.com/ghostty-org/ghostty/issues/632) +Ghostty implements all of the regularly used control sequences and +can run every mainstream terminal program without issue. For legacy sequences, +we've done a [comprehensive xterm audit](https://github.com/ghostty-org/ghostty/issues/632) comparing Ghostty's behavior to xterm and building a set of conformance test cases. -We believe Ghostty is one of the most compliant terminal emulators available. +In addition to legacy sequences (what you'd call real "terminal" emulation), +Ghostty also supports more modern sequences than almost any other terminal +emulator. These features include things like the Kitty graphics protocol, +Kitty image protocol, clipboard sequences, synchronized rendering, +light/dark mode notifications, and many, many more. + +We believe Ghostty is one of the most compliant and feature-rich terminal +emulators available. Terminal behavior is partially a de jure standard (i.e. [ECMA-48](https://ecma-international.org/publications-and-standards/standards/ecma-48/)) @@ -96,33 +98,30 @@ views as a "standard." #### Competitive Performance -We need better benchmarks to continuously verify this, but Ghostty is -generally in the same performance category as the other highest performing -terminal emulators. +Ghostty is generally in the same performance category as the other highest +performing terminal emulators. -For rendering, we have a multi-renderer architecture that uses OpenGL on -Linux and Metal on macOS. As far as I'm aware, we're the only terminal -emulator other than iTerm that uses Metal directly. And we're the only -terminal emulator that has a Metal renderer that supports ligatures (iTerm -uses a CPU renderer if ligatures are enabled). We can maintain around 60fps -under heavy load and much more generally -- though the terminal is -usually rendering much lower due to little screen changes. +"The same performance category" means that Ghostty is much faster than +traditional or "slow" terminals and is within an unnoticeable margin of the +well-known "fast" terminals. For example, Ghostty and Alacritty are usually within +a few percentage points of each other on various benchmarks, but are both +something like 100x faster than Terminal.app and iTerm. However, Ghostty +is much more feature rich than Alacritty and has a much more native app +experience. -For IO, we have a dedicated IO thread that maintains very little jitter -under heavy IO load (i.e. `cat .txt`). On benchmarks for IO, -we're usually within a small margin of other fast terminal emulators. -For example, reading a dump of plain text is 4x faster compared to iTerm and -Kitty, and 2x faster than Terminal.app. Alacritty is very fast but we're still -around the same speed (give or take) and our app experience is much more -feature rich. +This performance is achieved through high-level architectural decisions and +low-level optimizations. At a high-level, Ghostty has a multi-threaded +architecture with a dedicated read thread, write thread, and render thread +per terminal. Our renderer uses OpenGL on Linux and Metal on macOS. +Our read thread has a heavily optimized terminal parser that leverages +CPU-specific SIMD instructions. Etc. -> [!NOTE] -> Despite being _very fast_, there is a lot of room for improvement here. - -#### Richer Windowing Features +#### Rich Windowing Features The Mac and Linux (build with GTK) apps support multi-window, tabbing, and -splits. +splits with additional features such as tab renaming, coloring, etc. These +features allow for a higher degree of organization and customization than +single-window terminals. #### Native Platform Experiences @@ -133,10 +132,15 @@ in Zig but we do a lot of platform-native things: - The macOS app is a true SwiftUI-based application with all the things you would expect such as real windowing, menu bars, a settings GUI, etc. - macOS uses a true Metal renderer with CoreText for font discovery. +- macOS supports AppleScript, Apple Shortcuts (AppIntents), etc. - The Linux app is built with GTK. +- The Linux app integrates deeply with systemd if available for things + like always-on, new windows in a single instance, cgroup isolation, etc. -There are more improvements to be made. The macOS settings window is still -a work-in-progress. Similar improvements will follow with Linux. +Our goal with Ghostty is for users of whatever platform they run Ghostty +on to think that Ghostty was built for their platform first and maybe even +exclusively. We want Ghostty to feel like a native app on every platform, +for the best definition of "native" on each platform. #### Cross-platform `libghostty` for Embeddable Terminals @@ -151,15 +155,34 @@ terminal state. This is covered in more detail in this [blog post](https://mitchellh.com/writing/libghostty-is-coming). `libghostty-vt` is already available and usable today for Zig and C and -is compatible for macOS, Linux, Windows, and WebAssembly. At the time of -writing this, the API isn't stable yet and we haven't tagged an official -release, but the core logic is well proven (since Ghostty uses it) and -we're working hard on it now. +is compatible for macOS, Linux, Windows, and WebAssembly. The functionality +is extremely stable (since its been proven in Ghostty GUI for a long time), +but the API signatures are still in flux. -The ultimate goal is not hypothetical! The macOS app is a `libghostty` consumer. -The macOS app is a native Swift app developed in Xcode and `main()` is -within Swift. The Swift app links to `libghostty` and uses the C API to -render terminals. +`libghostty` is already heavily in use. See [`examples`](https://github.com/ghostty-org/ghostty/tree/main/example) +for small examples of using `libghostty` in C and Zig or the +[Ghostling](https://github.com/ghostty-org/ghostling) project for a +complete example. See [awesome-libghostty](https://github.com/Uzaaft/awesome-libghostty) +for a list of projects and resources related to `libghostty`. + +We haven't tagged libghostty with a version yet and we're still working +on a better docs experience, but our [Doxygen website](https://libghostty.tip.ghostty.org/) +is a good resource for the C API. + +#### Ghostty-only Terminal Control Sequences + +We want and believe that terminal applications can and should be able +to do so much more. We've worked hard to support a wide variety of modern +sequences created by other terminal emulators towards this end, but we also +want to fill the gaps by creating our own sequences. + +We've been hesitant to do this up until now because we don't want to create +more fragmentation in the terminal ecosystem by creating sequences that only +work in Ghostty. But, we do want to balance that with the desire to push the +terminal forward with stagnant standards and the slow pace of change in the +terminal ecosystem. + +We haven't done any of this yet. ## Crash Reports diff --git a/build.zig b/build.zig index f9d861b19..9362eeb59 100644 --- a/build.zig +++ b/build.zig @@ -35,7 +35,6 @@ pub fn build(b: *std.Build) !void { // All our steps which we'll hook up later. The steps are shown // up here just so that they are more self-documenting. - const libvt_step = b.step("lib-vt", "Build libghostty-vt"); const run_step = b.step("run", "Run the app"); const run_valgrind_step = b.step( "run-valgrind", @@ -91,16 +90,6 @@ pub fn build(b: *std.Build) !void { check_step.dependOn(dist.install_step); } - // libghostty (internal, big) - const libghostty_shared = try buildpkg.GhosttyLib.initShared( - b, - &deps, - ); - const libghostty_static = try buildpkg.GhosttyLib.initStatic( - b, - &deps, - ); - // libghostty-vt const libghostty_vt_shared = shared: { if (config.target.result.cpu.arch.isWasm()) { @@ -115,9 +104,31 @@ pub fn build(b: *std.Build) !void { &mod, ); }; - libghostty_vt_shared.install(libvt_step); libghostty_vt_shared.install(b.getInstallStep()); + // libghostty-vt static lib + const libghostty_vt_static = try buildpkg.GhosttyLibVt.initStatic( + b, + &mod, + ); + if (config.is_dep) { + // If we're a dependency, we need to install everything as-is + // so that dep.artifact("ghostty-vt-static") works. + libghostty_vt_static.install(b.getInstallStep()); + } else { + // If we're not a dependency, we rename the static lib to + // be idiomatic. On Windows, we use a distinct name to avoid + // colliding with the DLL import library (ghostty-vt.lib). + const static_lib_name = if (config.target.result.os.tag == .windows) + "ghostty-vt-static.lib" + else + "libghostty-vt.a"; + b.getInstallStep().dependOn(&b.addInstallLibFile( + libghostty_vt_static.output, + static_lib_name, + ).step); + } + // Helpgen if (config.emit_helpgen) deps.help_strings.install(); @@ -128,26 +139,34 @@ pub fn build(b: *std.Build) !void { resources.install(); if (i18n) |v| v.install(); } - } else { - // Libghostty + } else if (!config.emit_lib_vt) { + // The macOS Ghostty Library // - // Note: libghostty is not stable for general purpose use. It is used - // heavily by Ghostty on macOS but it isn't built to be reusable yet. - // As such, these build steps are lacking. For example, the Darwin - // build only produces an xcframework. + // This is NOT libghostty (even though its named that for historical + // reasons). It is just the glue between Ghostty GUI on macOS and + // the full Ghostty GUI core. + const lib_shared = try buildpkg.GhosttyLib.initShared(b, &deps); + const lib_static = try buildpkg.GhosttyLib.initStatic(b, &deps); // We shouldn't have this guard but we don't currently // build on macOS this way ironically so we need to fix that. if (!config.target.result.os.tag.isDarwin()) { - libghostty_shared.installHeader(); // Only need one header - libghostty_shared.install("libghostty.so"); - libghostty_static.install("libghostty.a"); + lib_shared.installHeader(); // Only need one header + if (config.target.result.os.tag == .windows) { + lib_shared.install("ghostty.dll"); + lib_static.install("ghostty-static.lib"); + } else { + lib_shared.install("libghostty.so"); + lib_static.install("libghostty.a"); + } } } // macOS only artifacts. These will error if they're initialized for // other targets. - if (config.target.result.os.tag.isDarwin()) { + if (config.target.result.os.tag.isDarwin() and + (config.emit_xcframework or config.emit_macos_app)) + { // Ghostty xcframework const xcframework = try buildpkg.GhosttyXCFramework.init( b, @@ -202,7 +221,9 @@ pub fn build(b: *std.Build) !void { // On macOS we can run the macOS app. For "run" we always force // a native-only build so that we can run as quickly as possible. - if (config.target.result.os.tag.isDarwin()) { + if (config.target.result.os.tag.isDarwin() and + (config.emit_xcframework or config.emit_macos_app)) + { const xcframework_native = try buildpkg.GhosttyXCFramework.init( b, &deps, diff --git a/build.zig.zon b/build.zig.zon index e7a8747f7..b0b66a052 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .ghostty, - .version = "1.3.0-dev", + .version = "1.3.2-dev", .paths = .{""}, .fingerprint = 0x64407a2a0b4147e5, .minimum_zig_version = "0.15.2", @@ -91,8 +91,8 @@ .lazy = true, }, .wayland_protocols = .{ - .url = "https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz", - .hash = "N-V-__8AAKw-DAAaV8bOAAGqA0-oD7o-HNIlPFYKRXSPT03S", + .url = "https://gitlab.freedesktop.org/wayland/wayland-protocols/-/archive/1.47/wayland-protocols-1.47.tar.gz", + .hash = "N-V-__8AAFdWDwA0ktbNUi9pFBHCRN4weXIgIfCrVjfGxqgA", .lazy = true, }, .plasma_wayland_protocols = .{ diff --git a/build.zig.zon.json b/build.zig.zon.json index 4a88e2017..b4e9dc39c 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -139,6 +139,11 @@ "url": "https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz", "hash": "sha256-XO3K3egbdeYPI+XoO13SuOtO+5+Peb16NH0UiusFMPg=" }, + "N-V-__8AAFdWDwA0ktbNUi9pFBHCRN4weXIgIfCrVjfGxqgA": { + "name": "wayland_protocols", + "url": "https://gitlab.freedesktop.org/wayland/wayland-protocols/-/archive/1.47/wayland-protocols-1.47.tar.gz", + "hash": "sha256-3S3xSrX0EDgleq7cxLX7msDuAY8/D5SvkJcCjmDTMiM=" + }, "N-V-__8AAAzZywE3s51XfsLbP9eyEw57ae9swYB9aGB6fCMs": { "name": "wuffs", "url": "https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 53e1b6c02..a3f18c692 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -306,6 +306,14 @@ in hash = "sha256-XO3K3egbdeYPI+XoO13SuOtO+5+Peb16NH0UiusFMPg="; }; } + { + name = "N-V-__8AAFdWDwA0ktbNUi9pFBHCRN4weXIgIfCrVjfGxqgA"; + path = fetchZigArtifact { + name = "wayland_protocols"; + url = "https://gitlab.freedesktop.org/wayland/wayland-protocols/-/archive/1.47/wayland-protocols-1.47.tar.gz"; + hash = "sha256-3S3xSrX0EDgleq7cxLX7msDuAY8/D5SvkJcCjmDTMiM="; + }; + } { name = "N-V-__8AAAzZywE3s51XfsLbP9eyEw57ae9swYB9aGB6fCMs"; path = fetchZigArtifact { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 4ac9e6592..75585dcb8 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -34,3 +34,4 @@ https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz +https://gitlab.freedesktop.org/wayland/wayland-protocols/-/archive/1.47/wayland-protocols-1.47.tar.gz diff --git a/dist/cmake/README.md b/dist/cmake/README.md new file mode 100644 index 000000000..ed8b86453 --- /dev/null +++ b/dist/cmake/README.md @@ -0,0 +1,67 @@ +# CMake Support for libghostty-vt + +The top-level `CMakeLists.txt` wraps the Zig build system so that CMake +projects can consume libghostty-vt without invoking `zig build` manually. +Running `cmake --build` triggers `zig build -Demit-lib-vt` automatically. + +This means downstream projects do require a working Zig compiler on +`PATH` to build, but don't need to know any Zig-specific details. + +## Using FetchContent (recommended) + +Add the following to your project's `CMakeLists.txt`: + +```cmake +include(FetchContent) +FetchContent_Declare(ghostty + GIT_REPOSITORY https://github.com/ghostty-org/ghostty.git + GIT_TAG main +) +FetchContent_MakeAvailable(ghostty) + +add_executable(myapp main.c) +target_link_libraries(myapp PRIVATE ghostty-vt) +``` + +This fetches the Ghostty source, builds libghostty-vt via Zig during your +CMake build, and links it into your target. Headers are added to the +include path automatically. + +### Using a local checkout + +If you already have the Ghostty source checked out, skip the download by +pointing CMake at it: + +```shell-session +cmake -B build -DFETCHCONTENT_SOURCE_DIR_GHOSTTY=/path/to/ghostty +cmake --build build +``` + +## Using find_package (install-based) + +Build and install libghostty-vt first: + +```shell-session +cd /path/to/ghostty +cmake -B build +cmake --build build +cmake --install build --prefix /usr/local +``` + +Then in your project: + +```cmake +find_package(ghostty-vt REQUIRED) + +add_executable(myapp main.c) +target_link_libraries(myapp PRIVATE ghostty-vt::ghostty-vt) +``` + +## Files + +- `ghostty-vt-config.cmake.in` — template for the CMake package config + file installed alongside the library, enabling `find_package()` support. + +## Example + +See `example/c-vt-cmake/` for a complete working example. diff --git a/dist/cmake/ghostty-vt-config.cmake.in b/dist/cmake/ghostty-vt-config.cmake.in new file mode 100644 index 000000000..8e1d75729 --- /dev/null +++ b/dist/cmake/ghostty-vt-config.cmake.in @@ -0,0 +1,65 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) + +set(_ghostty_vt_libdir "${PACKAGE_PREFIX_DIR}/@CMAKE_INSTALL_LIBDIR@") + +# Shared library target +if(NOT TARGET ghostty-vt::ghostty-vt) + add_library(ghostty-vt::ghostty-vt SHARED IMPORTED) + + if(WIN32) + set(_ghostty_vt_shared_location "${PACKAGE_PREFIX_DIR}/@CMAKE_INSTALL_BINDIR@/@GHOSTTY_VT_REALNAME@") + else() + set(_ghostty_vt_shared_location "${_ghostty_vt_libdir}/@GHOSTTY_VT_REALNAME@") + endif() + + set_target_properties(ghostty-vt::ghostty-vt PROPERTIES + IMPORTED_LOCATION "${_ghostty_vt_shared_location}" + INTERFACE_INCLUDE_DIRECTORIES "${PACKAGE_PREFIX_DIR}/@CMAKE_INSTALL_INCLUDEDIR@" + ) + unset(_ghostty_vt_shared_location) + + if(APPLE) + set_target_properties(ghostty-vt::ghostty-vt PROPERTIES + IMPORTED_SONAME "@rpath/@GHOSTTY_VT_SONAME@" + INTERFACE_LINK_DIRECTORIES "${_ghostty_vt_libdir}" + ) + # Ensure consumers can find the @rpath dylib at runtime + set_property(TARGET ghostty-vt::ghostty-vt APPEND PROPERTY + INTERFACE_LINK_OPTIONS "LINKER:-rpath,${_ghostty_vt_libdir}" + ) + elseif(WIN32) + set_target_properties(ghostty-vt::ghostty-vt PROPERTIES + IMPORTED_IMPLIB "${_ghostty_vt_libdir}/@GHOSTTY_VT_IMPLIB@" + ) + else() + set_target_properties(ghostty-vt::ghostty-vt PROPERTIES + IMPORTED_SONAME "@GHOSTTY_VT_SONAME@" + ) + endif() +endif() + +# Static library target +# +# Consumers must link transitive dependencies themselves. By default (with +# SIMD enabled): libc, libc++ (or libstdc++ on Linux), highway, and +# simdutf. Building with -Dsimd=false removes the C++ / highway / simdutf +# dependencies. +if(NOT TARGET ghostty-vt::ghostty-vt-static) + add_library(ghostty-vt::ghostty-vt-static STATIC IMPORTED) + + set_target_properties(ghostty-vt::ghostty-vt-static PROPERTIES + IMPORTED_LOCATION "${_ghostty_vt_libdir}/@GHOSTTY_VT_STATIC_REALNAME@" + INTERFACE_INCLUDE_DIRECTORIES "${PACKAGE_PREFIX_DIR}/@CMAKE_INSTALL_INCLUDEDIR@" + ) + if(WIN32) + set_target_properties(ghostty-vt::ghostty-vt-static PROPERTIES + INTERFACE_LINK_LIBRARIES "ntdll;kernel32" + ) + endif() +endif() + +unset(_ghostty_vt_libdir) + +check_required_components(ghostty-vt) diff --git a/dist/linux/com.mitchellh.ghostty.metainfo.xml.in b/dist/linux/com.mitchellh.ghostty.metainfo.xml.in index 42ccc2754..4f23c35da 100644 --- a/dist/linux/com.mitchellh.ghostty.metainfo.xml.in +++ b/dist/linux/com.mitchellh.ghostty.metainfo.xml.in @@ -52,6 +52,12 @@ + + https://ghostty.org/docs/install/release-notes/1-3-1 + + + https://ghostty.org/docs/install/release-notes/1-3-0 + https://ghostty.org/docs/install/release-notes/1-0-1 diff --git a/dist/linux/ghostty_dolphin.desktop b/dist/linux/ghostty_dolphin.desktop index 5e8351390..b9ac7fd26 100755 --- a/dist/linux/ghostty_dolphin.desktop +++ b/dist/linux/ghostty_dolphin.desktop @@ -7,5 +7,4 @@ Actions=RunGhosttyDir [Desktop Action RunGhosttyDir] Name=Open Ghostty Here Icon=com.mitchellh.ghostty -Exec=ghostty --working-directory=%F --gtk-single-instance=false - +Exec=ghostty +new-window --working-directory=%F diff --git a/example/.gitignore b/example/.gitignore index 3fa248f4c..6f372bc4d 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -2,3 +2,4 @@ dist/ node_modules/ example.wasm* +build/ diff --git a/example/AGENTS.md b/example/AGENTS.md new file mode 100644 index 000000000..280b97e18 --- /dev/null +++ b/example/AGENTS.md @@ -0,0 +1,39 @@ +# Example Libghostty Projects + +Each example is a standalone project with its own `build.zig`, +`build.zig.zon`, `README.md`, and `src/main.c` (or `.zig`). Examples are +auto-discovered by CI via `example/*/build.zig.zon`, so no workflow file +edits are needed when adding a new example. + +## Adding a New Example + +1. Copy an existing example directory (e.g., `c-vt-encode-focus/`) as a + starting point. +2. Update `build.zig.zon`: change `.name`, generate a **new unique** + `.fingerprint` value (a random `u64` hex literal), and keep + `.minimum_zig_version` matching the others. +3. Update `build.zig`: change the executable `.name` to match the directory. +4. Write a `README.md` following the existing format. + +## Doxygen Snippet Tags + +Example source files use Doxygen `@snippet` tags so the corresponding +header in `include/ghostty/vt/` can reference them. Wrap the relevant +code with `//! [snippet-name]` markers: + +```c +//! [my-snippet] +int main() { ... } +//! [my-snippet] +``` + +The header then uses `@snippet

/src/main.c my-snippet` instead of +inline `@code` blocks. Never duplicate example code inline in the +headers — always use `@snippet`. When modifying example code, keep the +snippet markers in sync with the headers in `include/ghostty/vt/`. + +## Conventions + +- Executable names use underscores: `c_vt_encode_focus` (not hyphens). +- All C examples link `ghostty-vt` via `lazyDependency("ghostty", ...)`. +- `build.zig` files follow a common template — keep them consistent. diff --git a/example/README.md b/example/README.md new file mode 100644 index 000000000..25e41aeeb --- /dev/null +++ b/example/README.md @@ -0,0 +1,17 @@ +# Examples + +Standalone projects demonstrating the Ghostty library APIs. +The directories starting with `c-` use the C API and the directories +starting with `zig-` use the Zig API. + +Every example can be built and run using `zig build` and `zig build run` +from within the respective example directory. +Even the C API examples use the Zig build system (not the language) to +build the project. + +## Running an Example + +```shell-session +cd example/ +zig build run +``` diff --git a/example/c-vt-build-info/README.md b/example/c-vt-build-info/README.md new file mode 100644 index 000000000..08fc1cb3c --- /dev/null +++ b/example/c-vt-build-info/README.md @@ -0,0 +1,17 @@ +# Example: `ghostty-vt` Build Info + +This contains a simple example of how to use the `ghostty-vt` build info +API to query compile-time build configuration. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-key-encode/build.zig b/example/c-vt-build-info/build.zig similarity index 97% rename from example/c-vt-key-encode/build.zig rename to example/c-vt-build-info/build.zig index b4b759744..2cd3d307a 100644 --- a/example/c-vt-key-encode/build.zig +++ b/example/c-vt-build-info/build.zig @@ -29,7 +29,7 @@ pub fn build(b: *std.Build) void { // Exe const exe = b.addExecutable(.{ - .name = "c_vt_key_encode", + .name = "c_vt_build_info", .root_module = exe_mod, }); b.installArtifact(exe); diff --git a/example/c-vt-build-info/build.zig.zon b/example/c-vt-build-info/build.zig.zon new file mode 100644 index 000000000..14966615a --- /dev/null +++ b/example/c-vt-build-info/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_build_info, + .version = "0.0.0", + .fingerprint = 0xc6b57ed4f83fb16, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-build-info/src/main.c b/example/c-vt-build-info/src/main.c new file mode 100644 index 000000000..11de240c6 --- /dev/null +++ b/example/c-vt-build-info/src/main.c @@ -0,0 +1,23 @@ +#include +#include + +//! [build-info-query] +void query_build_info() { + bool simd = false; + bool kitty_graphics = false; + bool tmux_control_mode = false; + + ghostty_build_info(GHOSTTY_BUILD_INFO_SIMD, &simd); + ghostty_build_info(GHOSTTY_BUILD_INFO_KITTY_GRAPHICS, &kitty_graphics); + ghostty_build_info(GHOSTTY_BUILD_INFO_TMUX_CONTROL_MODE, &tmux_control_mode); + + printf("SIMD: %s\n", simd ? "enabled" : "disabled"); + printf("Kitty graphics: %s\n", kitty_graphics ? "enabled" : "disabled"); + printf("Tmux control mode: %s\n", tmux_control_mode ? "enabled" : "disabled"); +} +//! [build-info-query] + +int main() { + query_build_info(); + return 0; +} diff --git a/example/c-vt-cmake-static/CMakeLists.txt b/example/c-vt-cmake-static/CMakeLists.txt new file mode 100644 index 000000000..bb4b1ac35 --- /dev/null +++ b/example/c-vt-cmake-static/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.19) +project(c-vt-cmake-static LANGUAGES C) + +include(FetchContent) +FetchContent_Declare(ghostty + GIT_REPOSITORY https://github.com/ghostty-org/ghostty.git + GIT_TAG main +) +set(GHOSTTY_ZIG_BUILD_FLAGS "-Dsimd=false" CACHE STRING "" FORCE) +FetchContent_MakeAvailable(ghostty) + +add_executable(c_vt_cmake_static src/main.c) +target_link_libraries(c_vt_cmake_static PRIVATE ghostty-vt-static) diff --git a/example/c-vt-cmake-static/README.md b/example/c-vt-cmake-static/README.md new file mode 100644 index 000000000..6aa503e04 --- /dev/null +++ b/example/c-vt-cmake-static/README.md @@ -0,0 +1,21 @@ +# c-vt-cmake-static + +Demonstrates consuming libghostty-vt as a **static** library from a CMake +project using `FetchContent`. Creates a terminal, writes VT sequences into +it, and formats the screen contents as plain text. + +## Building + +```shell-session +cd example/c-vt-cmake-static +cmake -B build +cmake --build build +./build/c_vt_cmake_static +``` + +To build against a local checkout instead of fetching from GitHub: + +```shell-session +cmake -B build -DFETCHCONTENT_SOURCE_DIR_GHOSTTY=../.. +cmake --build build +``` diff --git a/example/c-vt-cmake-static/src/main.c b/example/c-vt-cmake-static/src/main.c new file mode 100644 index 000000000..233bd34d1 --- /dev/null +++ b/example/c-vt-cmake-static/src/main.c @@ -0,0 +1,52 @@ +#include +#include +#include +#include +#include + +int main() { + // Create a terminal with a small grid + GhosttyTerminal terminal; + GhosttyTerminalOptions opts = { + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }; + GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts); + assert(result == GHOSTTY_SUCCESS); + + // Write some VT-encoded content into the terminal + const char *commands[] = { + "Hello from a \033[1mCMake\033[0m-built program (static)!\r\n", + "Line 2: \033[4munderlined\033[0m text\r\n", + "Line 3: \033[31mred\033[0m \033[32mgreen\033[0m \033[34mblue\033[0m\r\n", + }; + for (size_t i = 0; i < sizeof(commands) / sizeof(commands[0]); i++) { + ghostty_terminal_vt_write(terminal, (const uint8_t *)commands[i], + strlen(commands[i])); + } + + // Format the terminal contents as plain text + GhosttyFormatterTerminalOptions fmt_opts = + GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions); + fmt_opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN; + fmt_opts.trim = true; + + GhosttyFormatter formatter; + result = ghostty_formatter_terminal_new(NULL, &formatter, terminal, fmt_opts); + assert(result == GHOSTTY_SUCCESS); + + uint8_t *buf = NULL; + size_t len = 0; + result = ghostty_formatter_format_alloc(formatter, NULL, &buf, &len); + assert(result == GHOSTTY_SUCCESS); + + printf("Plain text (%zu bytes):\n", len); + fwrite(buf, 1, len, stdout); + printf("\n"); + + ghostty_free(NULL, buf, len); + ghostty_formatter_free(formatter); + ghostty_terminal_free(terminal); + return 0; +} diff --git a/example/c-vt-cmake/CMakeLists.txt b/example/c-vt-cmake/CMakeLists.txt new file mode 100644 index 000000000..ff6e35bc1 --- /dev/null +++ b/example/c-vt-cmake/CMakeLists.txt @@ -0,0 +1,12 @@ +cmake_minimum_required(VERSION 3.19) +project(c-vt-cmake LANGUAGES C) + +include(FetchContent) +FetchContent_Declare(ghostty + GIT_REPOSITORY https://github.com/ghostty-org/ghostty.git + GIT_TAG main +) +FetchContent_MakeAvailable(ghostty) + +add_executable(c_vt_cmake src/main.c) +target_link_libraries(c_vt_cmake PRIVATE ghostty-vt) diff --git a/example/c-vt-cmake/README.md b/example/c-vt-cmake/README.md new file mode 100644 index 000000000..d76ca946d --- /dev/null +++ b/example/c-vt-cmake/README.md @@ -0,0 +1,21 @@ +# c-vt-cmake + +Demonstrates consuming libghostty-vt from a CMake project using +`FetchContent`. Creates a terminal, writes VT sequences into it, and +formats the screen contents as plain text. + +## Building + +```shell-session +cd example/c-vt-cmake +cmake -B build +cmake --build build +./build/c_vt_cmake +``` + +To build against a local checkout instead of fetching from GitHub: + +```shell-session +cmake -B build -DFETCHCONTENT_SOURCE_DIR_GHOSTTY=../.. +cmake --build build +``` diff --git a/example/c-vt-cmake/src/main.c b/example/c-vt-cmake/src/main.c new file mode 100644 index 000000000..992586451 --- /dev/null +++ b/example/c-vt-cmake/src/main.c @@ -0,0 +1,52 @@ +#include +#include +#include +#include +#include + +int main() { + // Create a terminal with a small grid + GhosttyTerminal terminal; + GhosttyTerminalOptions opts = { + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }; + GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts); + assert(result == GHOSTTY_SUCCESS); + + // Write some VT-encoded content into the terminal + const char *commands[] = { + "Hello from a \033[1mCMake\033[0m-built program!\r\n", + "Line 2: \033[4munderlined\033[0m text\r\n", + "Line 3: \033[31mred\033[0m \033[32mgreen\033[0m \033[34mblue\033[0m\r\n", + }; + for (size_t i = 0; i < sizeof(commands) / sizeof(commands[0]); i++) { + ghostty_terminal_vt_write(terminal, (const uint8_t *)commands[i], + strlen(commands[i])); + } + + // Format the terminal contents as plain text + GhosttyFormatterTerminalOptions fmt_opts = + GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions); + fmt_opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN; + fmt_opts.trim = true; + + GhosttyFormatter formatter; + result = ghostty_formatter_terminal_new(NULL, &formatter, terminal, fmt_opts); + assert(result == GHOSTTY_SUCCESS); + + uint8_t *buf = NULL; + size_t len = 0; + result = ghostty_formatter_format_alloc(formatter, NULL, &buf, &len); + assert(result == GHOSTTY_SUCCESS); + + printf("Plain text (%zu bytes):\n", len); + fwrite(buf, 1, len, stdout); + printf("\n"); + + ghostty_free(NULL, buf, len); + ghostty_formatter_free(formatter); + ghostty_terminal_free(terminal); + return 0; +} diff --git a/example/c-vt-effects/README.md b/example/c-vt-effects/README.md new file mode 100644 index 000000000..5f5a22b14 --- /dev/null +++ b/example/c-vt-effects/README.md @@ -0,0 +1,18 @@ +# Example: `ghostty-vt` Terminal Effects + +This contains a simple example of how to register and use terminal +effect callbacks (`write_pty`, `bell`, `title_changed`) with the +`ghostty-vt` C library. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-effects/build.zig b/example/c-vt-effects/build.zig new file mode 100644 index 000000000..c3b1af73b --- /dev/null +++ b/example/c-vt-effects/build.zig @@ -0,0 +1,42 @@ +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"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_effects", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + 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-effects/build.zig.zon b/example/c-vt-effects/build.zig.zon new file mode 100644 index 000000000..0275f4f68 --- /dev/null +++ b/example/c-vt-effects/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_effects, + .version = "0.0.0", + .fingerprint = 0xc02634cd65f5b583, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-effects/src/main.c b/example/c-vt-effects/src/main.c new file mode 100644 index 000000000..1e3a3d645 --- /dev/null +++ b/example/c-vt-effects/src/main.c @@ -0,0 +1,97 @@ +#include +#include +#include +#include +#include +#include + +//! [effects-write-pty] +void on_write_pty(GhosttyTerminal terminal, + void* userdata, + const uint8_t* data, + size_t len) { + (void)terminal; + (void)userdata; + printf(" write_pty (%zu bytes): ", len); + fwrite(data, 1, len, stdout); + printf("\n"); +} +//! [effects-write-pty] + +//! [effects-bell] +void on_bell(GhosttyTerminal terminal, void* userdata) { + (void)terminal; + int* count = (int*)userdata; + (*count)++; + printf(" bell! (count=%d)\n", *count); +} +//! [effects-bell] + +//! [effects-title-changed] +void on_title_changed(GhosttyTerminal terminal, void* userdata) { + (void)userdata; + // Query the cursor position to confirm the terminal processed the + // title change (the title itself is tracked by the embedder via the + // OSC parser or its own state). + uint16_t col = 0; + ghostty_terminal_get(terminal, GHOSTTY_TERMINAL_DATA_CURSOR_X, &col); + printf(" title changed (cursor at col %u)\n", col); +} +//! [effects-title-changed] + +//! [effects-register] +int main() { + // Create a terminal + GhosttyTerminal terminal = NULL; + GhosttyTerminalOptions opts = { + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }; + if (ghostty_terminal_new(NULL, &terminal, opts) != GHOSTTY_SUCCESS) { + fprintf(stderr, "Failed to create terminal\n"); + return 1; + } + + // Set up userdata — a simple bell counter + int bell_count = 0; + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_USERDATA, &bell_count); + + // Register effect callbacks + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_WRITE_PTY, + (const void *)on_write_pty); + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_BELL, + (const void *)on_bell); + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_TITLE_CHANGED, + (const void *)on_title_changed); + + // Feed VT data that triggers effects: + + // 1. Bell (BEL = 0x07) + printf("Sending BEL:\n"); + const uint8_t bel = 0x07; + ghostty_terminal_vt_write(terminal, &bel, 1); + + // 2. Title change (OSC 2 ; ST) + printf("Sending title change:\n"); + const char* title_seq = "\x1B]2;Hello Effects\x1B\\"; + ghostty_terminal_vt_write(terminal, (const uint8_t*)title_seq, + strlen(title_seq)); + + // 3. Device status report (DECRQM for wraparound mode ?7) + // triggers write_pty with the response + printf("Sending DECRQM query:\n"); + const char* decrqm = "\x1B[?7$p"; + ghostty_terminal_vt_write(terminal, (const uint8_t*)decrqm, + strlen(decrqm)); + + // 4. Another bell to show the counter increments + printf("Sending another BEL:\n"); + ghostty_terminal_vt_write(terminal, &bel, 1); + + printf("Total bells: %d\n", bell_count); + + ghostty_terminal_free(terminal); + return 0; +} +//! [effects-register] diff --git a/example/c-vt-encode-focus/README.md b/example/c-vt-encode-focus/README.md new file mode 100644 index 000000000..f433e8808 --- /dev/null +++ b/example/c-vt-encode-focus/README.md @@ -0,0 +1,17 @@ +# Example: `ghostty-vt` Encode Focus + +This contains a simple example of how to use the `ghostty-vt` focus +encoding API to encode focus gained/lost events into escape sequences. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-encode-focus/build.zig b/example/c-vt-encode-focus/build.zig new file mode 100644 index 000000000..2904371fb --- /dev/null +++ b/example/c-vt-encode-focus/build.zig @@ -0,0 +1,42 @@ +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"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_encode_focus", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + 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-encode-focus/build.zig.zon b/example/c-vt-encode-focus/build.zig.zon new file mode 100644 index 000000000..0da20475c --- /dev/null +++ b/example/c-vt-encode-focus/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_encode_focus, + .version = "0.0.0", + .fingerprint = 0x89f01fd829fcc550, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-encode-focus/src/main.c b/example/c-vt-encode-focus/src/main.c new file mode 100644 index 000000000..15854792f --- /dev/null +++ b/example/c-vt-encode-focus/src/main.c @@ -0,0 +1,20 @@ +#include <stdio.h> +#include <ghostty/vt.h> + +//! [focus-encode] +int main() { + char buf[8]; + size_t written = 0; + + GhosttyResult result = ghostty_focus_encode( + GHOSTTY_FOCUS_GAINED, buf, sizeof(buf), &written); + + if (result == GHOSTTY_SUCCESS) { + printf("Encoded %zu bytes: ", written); + fwrite(buf, 1, written, stdout); + printf("\n"); + } + + return 0; +} +//! [focus-encode] diff --git a/example/c-vt-key-encode/README.md b/example/c-vt-encode-key/README.md similarity index 100% rename from example/c-vt-key-encode/README.md rename to example/c-vt-encode-key/README.md diff --git a/example/c-vt-encode-key/build.zig b/example/c-vt-encode-key/build.zig new file mode 100644 index 000000000..de878a7ad --- /dev/null +++ b/example/c-vt-encode-key/build.zig @@ -0,0 +1,42 @@ +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"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_encode_key", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + 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-key-encode/build.zig.zon b/example/c-vt-encode-key/build.zig.zon similarity index 100% rename from example/c-vt-key-encode/build.zig.zon rename to example/c-vt-encode-key/build.zig.zon diff --git a/example/c-vt-encode-key/src/main.c b/example/c-vt-encode-key/src/main.c new file mode 100644 index 000000000..99b782022 --- /dev/null +++ b/example/c-vt-encode-key/src/main.c @@ -0,0 +1,40 @@ +#include <assert.h> +#include <stddef.h> +#include <stdio.h> +#include <string.h> +#include <ghostty/vt.h> + +//! [key-encode] +int main() { + // Create encoder + GhosttyKeyEncoder encoder; + GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder); + assert(result == GHOSTTY_SUCCESS); + + // Enable Kitty keyboard protocol with all features + ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS, + &(uint8_t){GHOSTTY_KITTY_KEY_ALL}); + + // Create and configure key event for Ctrl+C press + GhosttyKeyEvent event; + result = ghostty_key_event_new(NULL, &event); + assert(result == GHOSTTY_SUCCESS); + ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_PRESS); + ghostty_key_event_set_key(event, GHOSTTY_KEY_C); + ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL); + + // Encode the key event + char buf[128]; + size_t written = 0; + result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); + assert(result == GHOSTTY_SUCCESS); + + // Use the encoded sequence (e.g., write to terminal) + fwrite(buf, 1, written, stdout); + + // Cleanup + ghostty_key_event_free(event); + ghostty_key_encoder_free(encoder); + return 0; +} +//! [key-encode] diff --git a/example/c-vt-encode-mouse/README.md b/example/c-vt-encode-mouse/README.md new file mode 100644 index 000000000..754e09805 --- /dev/null +++ b/example/c-vt-encode-mouse/README.md @@ -0,0 +1,23 @@ +# Example: `ghostty-vt` C Mouse Encoding + +This example demonstrates how to use the `ghostty-vt` C library to encode mouse +events into terminal escape sequences. + +This example specifically shows how to: + +1. Create a mouse encoder with the C API +2. Configure tracking mode and output format (this example uses SGR) +3. Set terminal geometry for pixel-to-cell coordinate mapping +4. Create and configure a mouse event +5. Encode the mouse event into a terminal escape sequence + +The example encodes a left button press at pixel position (50, 40) using SGR +format, producing an escape sequence like `\x1b[<0;6;3M`. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-encode-mouse/build.zig b/example/c-vt-encode-mouse/build.zig new file mode 100644 index 000000000..4fdb353c0 --- /dev/null +++ b/example/c-vt-encode-mouse/build.zig @@ -0,0 +1,42 @@ +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"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_encode_mouse", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + 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-encode-mouse/build.zig.zon b/example/c-vt-encode-mouse/build.zig.zon new file mode 100644 index 000000000..1ab5da284 --- /dev/null +++ b/example/c-vt-encode-mouse/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt, + .version = "0.0.0", + .fingerprint = 0x413a8529a6dd3c51, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-encode-mouse/src/main.c b/example/c-vt-encode-mouse/src/main.c new file mode 100644 index 000000000..d75ed9c54 --- /dev/null +++ b/example/c-vt-encode-mouse/src/main.c @@ -0,0 +1,52 @@ +#include <assert.h> +#include <stddef.h> +#include <stdio.h> +#include <string.h> +#include <ghostty/vt.h> + +//! [mouse-encode] +int main() { + // Create encoder + GhosttyMouseEncoder encoder; + GhosttyResult result = ghostty_mouse_encoder_new(NULL, &encoder); + assert(result == GHOSTTY_SUCCESS); + + // Configure SGR format with normal tracking + ghostty_mouse_encoder_setopt(encoder, GHOSTTY_MOUSE_ENCODER_OPT_EVENT, + &(GhosttyMouseTrackingMode){GHOSTTY_MOUSE_TRACKING_NORMAL}); + ghostty_mouse_encoder_setopt(encoder, GHOSTTY_MOUSE_ENCODER_OPT_FORMAT, + &(GhosttyMouseFormat){GHOSTTY_MOUSE_FORMAT_SGR}); + + // Set terminal geometry for coordinate mapping + ghostty_mouse_encoder_setopt(encoder, GHOSTTY_MOUSE_ENCODER_OPT_SIZE, + &(GhosttyMouseEncoderSize){ + .size = sizeof(GhosttyMouseEncoderSize), + .screen_width = 800, .screen_height = 600, + .cell_width = 10, .cell_height = 20, + }); + + // Create and configure a left button press event + GhosttyMouseEvent event; + result = ghostty_mouse_event_new(NULL, &event); + assert(result == GHOSTTY_SUCCESS); + ghostty_mouse_event_set_action(event, GHOSTTY_MOUSE_ACTION_PRESS); + ghostty_mouse_event_set_button(event, GHOSTTY_MOUSE_BUTTON_LEFT); + ghostty_mouse_event_set_position(event, + (GhosttyMousePosition){.x = 50.0f, .y = 40.0f}); + + // Encode the mouse event + char buf[128]; + size_t written = 0; + result = ghostty_mouse_encoder_encode(encoder, event, + buf, sizeof(buf), &written); + assert(result == GHOSTTY_SUCCESS); + + // Use the encoded sequence (e.g., write to terminal) + fwrite(buf, 1, written, stdout); + + // Cleanup + ghostty_mouse_event_free(event); + ghostty_mouse_encoder_free(encoder); + return 0; +} +//! [mouse-encode] diff --git a/example/c-vt-formatter/README.md b/example/c-vt-formatter/README.md new file mode 100644 index 000000000..f416c8dbd --- /dev/null +++ b/example/c-vt-formatter/README.md @@ -0,0 +1,18 @@ +# Example: `ghostty-vt` Terminal Formatter + +This contains a simple example of how to use the `ghostty-vt` terminal and +formatter APIs to create a terminal, write VT-encoded content into it, and +format the screen contents as plain text. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-formatter/build.zig b/example/c-vt-formatter/build.zig new file mode 100644 index 000000000..637b48f13 --- /dev/null +++ b/example/c-vt-formatter/build.zig @@ -0,0 +1,42 @@ +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"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_formatter", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + 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-formatter/build.zig.zon b/example/c-vt-formatter/build.zig.zon new file mode 100644 index 000000000..a14f0aedb --- /dev/null +++ b/example/c-vt-formatter/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_formatter, + .version = "0.0.0", + .fingerprint = 0x9e3758265677a0c4, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-formatter/src/main.c b/example/c-vt-formatter/src/main.c new file mode 100644 index 000000000..56f9d1220 --- /dev/null +++ b/example/c-vt-formatter/src/main.c @@ -0,0 +1,63 @@ +#include <assert.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <ghostty/vt.h> + +int main() { + // Create a terminal with a small grid + GhosttyTerminal terminal; + GhosttyTerminalOptions opts = { + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }; + GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts); + assert(result == GHOSTTY_SUCCESS); + + // Write VT-encoded content into the terminal to exercise various + // cursor movement and styling sequences. + const char *commands[] = { + "Line 1: Hello World!\r\n", // Simple text on row 1 + "Line 2: \033[1mBold\033[0m and " // Bold text on row 2 + "\033[4mUnderline\033[0m\r\n", + "Line 3: placeholder\r\n", // Will be overwritten below + "\033[3;1H", // CUP: move cursor back to row 3, col 1 + "\033[2K", // EL: erase the entire line + "Line 3: Overwritten!\r\n", // Rewrite row 3 with new content + "\033[5;10H", // CUP: jump to row 5, col 10 + "Placed at (5,10)", // Write at that position + "\033[1;72H", // CUP: jump to row 1, col 72 + "RIGHT->", // Near the right edge of row 1 + }; + for (size_t i = 0; i < sizeof(commands) / sizeof(commands[0]); i++) { + ghostty_terminal_vt_write(terminal, (const uint8_t *)commands[i], + strlen(commands[i])); + } + + // Create a plain-text formatter for the terminal + GhosttyFormatterTerminalOptions fmt_opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions); + fmt_opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN; + fmt_opts.trim = true; + + GhosttyFormatter formatter; + result = ghostty_formatter_terminal_new(NULL, &formatter, terminal, fmt_opts); + assert(result == GHOSTTY_SUCCESS); + + // Format into an allocated buffer + uint8_t *buf = NULL; + size_t len = 0; + result = ghostty_formatter_format_alloc(formatter, NULL, &buf, &len); + assert(result == GHOSTTY_SUCCESS); + + // Print the formatted output + printf("Formatted output (%zu bytes):\n", len); + fwrite(buf, 1, len, stdout); + printf("\n"); + + // Clean up + ghostty_free(NULL, buf, len); + ghostty_formatter_free(formatter); + ghostty_terminal_free(terminal); + return 0; +} diff --git a/example/c-vt-grid-traverse/README.md b/example/c-vt-grid-traverse/README.md new file mode 100644 index 000000000..f9a15851a --- /dev/null +++ b/example/c-vt-grid-traverse/README.md @@ -0,0 +1,19 @@ +# Example: `ghostty-vt` Grid Traversal + +This contains a simple example of how to use the `ghostty-vt` terminal and +grid reference APIs to create a terminal, write content into it, and then +traverse the entire grid cell-by-cell using grid refs to inspect codepoints, +row state, and styles. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-grid-traverse/build.zig b/example/c-vt-grid-traverse/build.zig new file mode 100644 index 000000000..caf174028 --- /dev/null +++ b/example/c-vt-grid-traverse/build.zig @@ -0,0 +1,42 @@ +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"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_grid_traverse", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + 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-grid-traverse/build.zig.zon b/example/c-vt-grid-traverse/build.zig.zon new file mode 100644 index 000000000..21b6cea18 --- /dev/null +++ b/example/c-vt-grid-traverse/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_grid_traverse, + .version = "0.0.0", + .fingerprint = 0xf694dd12db9be040, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-grid-traverse/src/main.c b/example/c-vt-grid-traverse/src/main.c new file mode 100644 index 000000000..f07169eb6 --- /dev/null +++ b/example/c-vt-grid-traverse/src/main.c @@ -0,0 +1,85 @@ +#include <assert.h> +#include <stdio.h> +#include <string.h> +#include <ghostty/vt.h> + +//! [grid-ref-traverse] +int main() { + // Create a small terminal + GhosttyTerminal terminal; + GhosttyTerminalOptions opts = { + .cols = 10, + .rows = 3, + .max_scrollback = 0, + }; + GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts); + assert(result == GHOSTTY_SUCCESS); + + // Write some content so the grid has interesting data + const char *text = "Hello!\r\n" // Row 0: H e l l o ! + "World\r\n" // Row 1: W o r l d + "\033[1mBold"; // Row 2: B o l d (bold style) + ghostty_terminal_vt_write( + terminal, (const uint8_t *)text, strlen(text)); + + // Get terminal dimensions + uint16_t cols, rows; + ghostty_terminal_get(terminal, GHOSTTY_TERMINAL_DATA_COLS, &cols); + ghostty_terminal_get(terminal, GHOSTTY_TERMINAL_DATA_ROWS, &rows); + + // Traverse the entire grid using grid refs + for (uint16_t row = 0; row < rows; row++) { + printf("Row %u: ", row); + for (uint16_t col = 0; col < cols; col++) { + // Resolve the point to a grid reference + GhosttyGridRef ref = GHOSTTY_INIT_SIZED(GhosttyGridRef); + GhosttyPoint pt = { + .tag = GHOSTTY_POINT_TAG_ACTIVE, + .value = { .coordinate = { .x = col, .y = row } }, + }; + result = ghostty_terminal_grid_ref(terminal, pt, &ref); + assert(result == GHOSTTY_SUCCESS); + + // Read the cell from the grid ref + GhosttyCell cell; + result = ghostty_grid_ref_cell(&ref, &cell); + assert(result == GHOSTTY_SUCCESS); + + // Check if the cell has text + bool has_text = false; + ghostty_cell_get(cell, GHOSTTY_CELL_DATA_HAS_TEXT, &has_text); + + if (has_text) { + uint32_t codepoint = 0; + ghostty_cell_get(cell, GHOSTTY_CELL_DATA_CODEPOINT, &codepoint); + printf("%c", (char)codepoint); + } else { + printf("."); + } + } + + // Also inspect the row for wrap state + GhosttyGridRef ref = GHOSTTY_INIT_SIZED(GhosttyGridRef); + GhosttyPoint pt = { + .tag = GHOSTTY_POINT_TAG_ACTIVE, + .value = { .coordinate = { .x = 0, .y = row } }, + }; + ghostty_terminal_grid_ref(terminal, pt, &ref); + + GhosttyRow grid_row; + ghostty_grid_ref_row(&ref, &grid_row); + + bool wrap = false; + ghostty_row_get(grid_row, GHOSTTY_ROW_DATA_WRAP, &wrap); + printf(" (wrap=%s", wrap ? "true" : "false"); + + // Check the style of the first cell with text + GhosttyStyle style = GHOSTTY_INIT_SIZED(GhosttyStyle); + ghostty_grid_ref_style(&ref, &style); + printf(", bold=%s)\n", style.bold ? "true" : "false"); + } + + ghostty_terminal_free(terminal); + return 0; +} +//! [grid-ref-traverse] diff --git a/example/c-vt-key-encode/src/main.c b/example/c-vt-key-encode/src/main.c deleted file mode 100644 index 82444f99d..000000000 --- a/example/c-vt-key-encode/src/main.c +++ /dev/null @@ -1,59 +0,0 @@ -#include <assert.h> -#include <stddef.h> -#include <stdio.h> -#include <string.h> -#include <ghostty/vt.h> - -int main() { - GhosttyKeyEncoder encoder; - GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder); - assert(result == GHOSTTY_SUCCESS); - - // Set kitty flags with all features enabled - ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS, &(uint8_t){GHOSTTY_KITTY_KEY_ALL}); - - // Create key event - GhosttyKeyEvent event; - result = ghostty_key_event_new(NULL, &event); - assert(result == GHOSTTY_SUCCESS); - ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_RELEASE); - ghostty_key_event_set_key(event, GHOSTTY_KEY_CONTROL_LEFT); - ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL); - printf("Encoding event: left ctrl release with all Kitty flags enabled\n"); - - // Optionally, encode with null buffer to get required size. You can - // skip this step and provide a sufficiently large buffer directly. - // If there isn't enoug hspace, the function will return an out of memory - // error. - size_t required = 0; - result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required); - assert(result == GHOSTTY_OUT_OF_MEMORY); - printf("Required buffer size: %zu bytes\n", required); - - // Encode the key event. We don't use our required size above because - // that was just an example; we know 128 bytes is enough. - char buf[128]; - size_t written = 0; - result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); - assert(result == GHOSTTY_SUCCESS); - printf("Encoded %zu bytes\n", written); - - // Print the encoded sequence (hex and string) - printf("Hex: "); - for (size_t i = 0; i < written; i++) printf("%02x ", (unsigned char)buf[i]); - printf("\n"); - - printf("String: "); - for (size_t i = 0; i < written; i++) { - if (buf[i] == 0x1b) { - printf("\\x1b"); - } else { - printf("%c", buf[i]); - } - } - printf("\n"); - - ghostty_key_event_free(event); - ghostty_key_encoder_free(encoder); - return 0; -} diff --git a/example/c-vt-modes/README.md b/example/c-vt-modes/README.md new file mode 100644 index 000000000..bd43c1799 --- /dev/null +++ b/example/c-vt-modes/README.md @@ -0,0 +1,18 @@ +# Example: `ghostty-vt` Mode Utilities + +This contains a simple example of how to use the `ghostty-vt` mode +utilities to pack and unpack terminal mode identifiers and encode +DECRPM responses. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-modes/build.zig b/example/c-vt-modes/build.zig new file mode 100644 index 000000000..1a4b3f8d8 --- /dev/null +++ b/example/c-vt-modes/build.zig @@ -0,0 +1,42 @@ +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"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_modes", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + 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-modes/build.zig.zon b/example/c-vt-modes/build.zig.zon new file mode 100644 index 000000000..bdfeefdca --- /dev/null +++ b/example/c-vt-modes/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_modes, + .version = "0.0.0", + .fingerprint = 0x67ce079ebc70a02a, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-modes/src/main.c b/example/c-vt-modes/src/main.c new file mode 100644 index 000000000..e957c9777 --- /dev/null +++ b/example/c-vt-modes/src/main.c @@ -0,0 +1,45 @@ +#include <stdio.h> +#include <ghostty/vt.h> + +//! [modes-pack-unpack] +void modes_example() { + // Create a mode for DEC mode 25 (cursor visible) + GhosttyMode tag = ghostty_mode_new(25, false); + printf("value=%u ansi=%d packed=0x%04x\n", + ghostty_mode_value(tag), + ghostty_mode_ansi(tag), + tag); + + // Create a mode for ANSI mode 4 (insert mode) + GhosttyMode ansi_tag = ghostty_mode_new(4, true); + printf("value=%u ansi=%d packed=0x%04x\n", + ghostty_mode_value(ansi_tag), + ghostty_mode_ansi(ansi_tag), + ansi_tag); +} +//! [modes-pack-unpack] + +//! [modes-decrpm] +void decrpm_example() { + char buf[32]; + size_t written = 0; + + // Encode a report that DEC mode 25 (cursor visible) is set + GhosttyResult result = ghostty_mode_report_encode( + GHOSTTY_MODE_CURSOR_VISIBLE, + GHOSTTY_MODE_REPORT_SET, + buf, sizeof(buf), &written); + + if (result == GHOSTTY_SUCCESS) { + printf("Encoded %zu bytes: ", written); + fwrite(buf, 1, written, stdout); + printf("\n"); // prints: ESC[?25;1$y + } +} +//! [modes-decrpm] + +int main() { + modes_example(); + decrpm_example(); + return 0; +} diff --git a/example/c-vt-paste/src/main.c b/example/c-vt-paste/src/main.c index 153861ca9..bb9e8e2a5 100644 --- a/example/c-vt-paste/src/main.c +++ b/example/c-vt-paste/src/main.c @@ -2,18 +2,23 @@ #include <string.h> #include <ghostty/vt.h> -int main() { - // Test safe paste data - const char *safe_data = "hello world"; +//! [paste-safety] +void basic_example() { + const char* safe_data = "hello world"; + const char* unsafe_data = "rm -rf /\n"; + if (ghostty_paste_is_safe(safe_data, strlen(safe_data))) { - printf("'%s' is safe to paste\n", safe_data); + printf("Safe to paste\n"); } - // Test unsafe paste data with newline - const char *unsafe_newline = "rm -rf /\n"; - if (!ghostty_paste_is_safe(unsafe_newline, strlen(unsafe_newline))) { - printf("'%s' is UNSAFE - contains newline\n", unsafe_newline); + if (!ghostty_paste_is_safe(unsafe_data, strlen(unsafe_data))) { + printf("Unsafe! Contains newline\n"); } +} +//! [paste-safety] + +int main() { + basic_example(); // Test unsafe paste data with bracketed paste end sequence const char *unsafe_escape = "evil\x1b[201~code"; diff --git a/example/c-vt-render/README.md b/example/c-vt-render/README.md new file mode 100644 index 000000000..3725ed46f --- /dev/null +++ b/example/c-vt-render/README.md @@ -0,0 +1,19 @@ +# Example: `ghostty-vt` Render State + +This contains an example of how to use the `ghostty-vt` render-state API +to create a render state, update it from terminal content, iterate rows +and cells, read styles and colors, inspect cursor state, and manage dirty +tracking. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-render/build.zig b/example/c-vt-render/build.zig new file mode 100644 index 000000000..15e3e5405 --- /dev/null +++ b/example/c-vt-render/build.zig @@ -0,0 +1,42 @@ +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"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_render", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + 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-render/build.zig.zon b/example/c-vt-render/build.zig.zon new file mode 100644 index 000000000..3919970f9 --- /dev/null +++ b/example/c-vt-render/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_render, + .version = "0.0.0", + .fingerprint = 0xb10e18b2fab773c9, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-render/src/main.c b/example/c-vt-render/src/main.c new file mode 100644 index 000000000..0714d4160 --- /dev/null +++ b/example/c-vt-render/src/main.c @@ -0,0 +1,234 @@ +#include <assert.h> +#include <stdbool.h> +#include <stdio.h> +#include <string.h> +#include <ghostty/vt.h> + +/// Helper: resolve a style color to an RGB value using the palette. +static GhosttyColorRgb resolve_color(GhosttyStyleColor color, + const GhosttyRenderStateColors* colors, + GhosttyColorRgb fallback) { + switch (color.tag) { + case GHOSTTY_STYLE_COLOR_RGB: + return color.value.rgb; + case GHOSTTY_STYLE_COLOR_PALETTE: + return colors->palette[color.value.palette]; + default: + return fallback; + } +} + +int main(void) { + GhosttyResult result; + + //! [render-state-update] + // Create a terminal and render state, then update the render state + // from the terminal. The render state captures a snapshot of everything + // needed to draw a frame. + GhosttyTerminal terminal = NULL; + GhosttyTerminalOptions terminal_opts = { + .cols = 40, + .rows = 5, + .max_scrollback = 10000, + }; + result = ghostty_terminal_new(NULL, &terminal, terminal_opts); + assert(result == GHOSTTY_SUCCESS); + + GhosttyRenderState render_state = NULL; + result = ghostty_render_state_new(NULL, &render_state); + assert(result == GHOSTTY_SUCCESS); + + // Feed some styled content into the terminal. + const char* content = + "Hello, \033[1;32mworld\033[0m!\r\n" // bold green "world" + "\033[4munderlined\033[0m text\r\n" // underlined text + "\033[38;2;255;128;0morange\033[0m\r\n"; // 24-bit orange fg + ghostty_terminal_vt_write( + terminal, (const uint8_t*)content, strlen(content)); + + result = ghostty_render_state_update(render_state, terminal); + assert(result == GHOSTTY_SUCCESS); + //! [render-state-update] + + //! [render-dirty-check] + // Check the global dirty state to decide how much work the renderer + // needs to do. After rendering, reset it to false. + GhosttyRenderStateDirty dirty; + result = ghostty_render_state_get( + render_state, GHOSTTY_RENDER_STATE_DATA_DIRTY, &dirty); + assert(result == GHOSTTY_SUCCESS); + + switch (dirty) { + case GHOSTTY_RENDER_STATE_DIRTY_FALSE: + printf("Frame is clean, nothing to draw.\n"); + break; + case GHOSTTY_RENDER_STATE_DIRTY_PARTIAL: + printf("Partial redraw needed.\n"); + break; + case GHOSTTY_RENDER_STATE_DIRTY_FULL: + printf("Full redraw needed.\n"); + break; + } + //! [render-dirty-check] + + //! [render-colors] + // Retrieve colors (background, foreground, palette) from the render + // state. These are needed to resolve palette-indexed cell colors. + GhosttyRenderStateColors colors = + GHOSTTY_INIT_SIZED(GhosttyRenderStateColors); + result = ghostty_render_state_colors_get(render_state, &colors); + assert(result == GHOSTTY_SUCCESS); + + printf("Background: #%02x%02x%02x\n", + colors.background.r, colors.background.g, colors.background.b); + printf("Foreground: #%02x%02x%02x\n", + colors.foreground.r, colors.foreground.g, colors.foreground.b); + //! [render-colors] + + //! [render-cursor] + // Read cursor position and visual style from the render state. + bool cursor_visible = false; + ghostty_render_state_get( + render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_VISIBLE, + &cursor_visible); + + bool cursor_in_viewport = false; + ghostty_render_state_get( + render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_HAS_VALUE, + &cursor_in_viewport); + + if (cursor_visible && cursor_in_viewport) { + uint16_t cx, cy; + ghostty_render_state_get( + render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_X, &cx); + ghostty_render_state_get( + render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_Y, &cy); + + GhosttyRenderStateCursorVisualStyle style; + ghostty_render_state_get( + render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_VISUAL_STYLE, + &style); + + const char* style_name = "unknown"; + switch (style) { + case GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BAR: + style_name = "bar"; + break; + case GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BLOCK: + style_name = "block"; + break; + case GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_UNDERLINE: + style_name = "underline"; + break; + case GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BLOCK_HOLLOW: + style_name = "hollow"; + break; + } + printf("Cursor at (%u, %u), style: %s\n", cx, cy, style_name); + } + //! [render-cursor] + + //! [render-row-iterate] + // Iterate rows via the row iterator. For each dirty row, iterate its + // cells, read codepoints/graphemes and styles, and emit ANSI-colored + // output as a simple "renderer". + GhosttyRenderStateRowIterator row_iter = NULL; + result = ghostty_render_state_row_iterator_new(NULL, &row_iter); + assert(result == GHOSTTY_SUCCESS); + + result = ghostty_render_state_get( + render_state, GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR, &row_iter); + assert(result == GHOSTTY_SUCCESS); + + GhosttyRenderStateRowCells cells = NULL; + result = ghostty_render_state_row_cells_new(NULL, &cells); + assert(result == GHOSTTY_SUCCESS); + + int row_index = 0; + while (ghostty_render_state_row_iterator_next(row_iter)) { + // Check per-row dirty state; a real renderer would skip clean rows. + bool row_dirty = false; + ghostty_render_state_row_get( + row_iter, GHOSTTY_RENDER_STATE_ROW_DATA_DIRTY, &row_dirty); + + printf("Row %2d [%s]: ", row_index, + row_dirty ? "dirty" : "clean"); + + // Get cells for this row (reuses the same cells handle). + result = ghostty_render_state_row_get( + row_iter, GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, &cells); + assert(result == GHOSTTY_SUCCESS); + + while (ghostty_render_state_row_cells_next(cells)) { + // Get the grapheme length; 0 means the cell is empty. + uint32_t grapheme_len = 0; + ghostty_render_state_row_cells_get( + cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN, + &grapheme_len); + + if (grapheme_len == 0) { + putchar(' '); + continue; + } + + // Read the style for this cell. Returns the default style for + // cells that have no explicit styling. + GhosttyStyle style = GHOSTTY_INIT_SIZED(GhosttyStyle); + ghostty_render_state_row_cells_get( + cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE, &style); + + // Resolve foreground color for this cell. + GhosttyColorRgb fg = + resolve_color(style.fg_color, &colors, colors.foreground); + + // Emit ANSI true-color escape for the foreground. + printf("\033[38;2;%u;%u;%um", fg.r, fg.g, fg.b); + if (style.bold) printf("\033[1m"); + if (style.underline) printf("\033[4m"); + + // Read grapheme codepoints into a buffer and print them. + // The buffer must be at least grapheme_len elements. + uint32_t codepoints[16]; + uint32_t len = grapheme_len < 16 ? grapheme_len : 16; + ghostty_render_state_row_cells_get( + cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF, + codepoints); + + for (uint32_t i = 0; i < len; i++) { + // Simple ASCII print; a real renderer would handle UTF-8. + if (codepoints[i] < 128) + putchar((char)codepoints[i]); + else + printf("U+%04X", codepoints[i]); + } + + printf("\033[0m"); // Reset style after each cell. + } + + printf("\n"); + + // Clear per-row dirty flag after "rendering" it. + bool clean = false; + ghostty_render_state_row_set( + row_iter, GHOSTTY_RENDER_STATE_ROW_OPTION_DIRTY, &clean); + + row_index++; + } + //! [render-row-iterate] + + //! [render-dirty-reset] + // After finishing the frame, reset the global dirty state so the next + // update can report changes accurately. + GhosttyRenderStateDirty clean_state = GHOSTTY_RENDER_STATE_DIRTY_FALSE; + result = ghostty_render_state_set( + render_state, GHOSTTY_RENDER_STATE_OPTION_DIRTY, &clean_state); + assert(result == GHOSTTY_SUCCESS); + //! [render-dirty-reset] + + // Cleanup + ghostty_render_state_row_cells_free(cells); + ghostty_render_state_row_iterator_free(row_iter); + ghostty_render_state_free(render_state); + ghostty_terminal_free(terminal); + return 0; +} diff --git a/example/c-vt-sgr/src/main.c b/example/c-vt-sgr/src/main.c index 21a529726..e213c0c93 100644 --- a/example/c-vt-sgr/src/main.c +++ b/example/c-vt-sgr/src/main.c @@ -2,12 +2,43 @@ #include <stdio.h> #include <ghostty/vt.h> -int main() { +//! [sgr-basic] +void basic_example() { // Create parser GhosttySgrParser parser; GhosttyResult result = ghostty_sgr_new(NULL, &parser); assert(result == GHOSTTY_SUCCESS); + // Parse "bold, red foreground" sequence: ESC[1;31m + uint16_t params[] = {1, 31}; + result = ghostty_sgr_set_params(parser, params, NULL, 2); + assert(result == GHOSTTY_SUCCESS); + + // Iterate through attributes + GhosttySgrAttribute attr; + while (ghostty_sgr_next(parser, &attr)) { + switch (attr.tag) { + case GHOSTTY_SGR_ATTR_BOLD: + printf("Bold enabled\n"); + break; + case GHOSTTY_SGR_ATTR_FG_8: + printf("Foreground color: %d\n", attr.value.fg_8); + break; + default: + break; + } + } + + // Cleanup + ghostty_sgr_free(parser); +} +//! [sgr-basic] + +void advanced_example() { + GhosttySgrParser parser; + GhosttyResult result = ghostty_sgr_new(NULL, &parser); + assert(result == GHOSTTY_SUCCESS); + // Parse a complex SGR sequence from Kakoune // This corresponds to the escape sequence: // ESC[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136m @@ -26,10 +57,9 @@ int main() { result = ghostty_sgr_set_params(parser, params, separators, sizeof(params) / sizeof(params[0])); assert(result == GHOSTTY_SUCCESS); - printf("Parsing Kakoune SGR sequence:\n"); + printf("\nParsing Kakoune SGR sequence:\n"); printf("ESC[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136m\n\n"); - // Iterate through attributes GhosttySgrAttribute attr; int count = 0; while (ghostty_sgr_next(parser, &attr)) { @@ -124,8 +154,11 @@ int main() { } printf("\nTotal attributes parsed: %d\n", count); - - // Cleanup ghostty_sgr_free(parser); +} + +int main() { + basic_example(); + advanced_example(); return 0; } diff --git a/example/c-vt-size-report/README.md b/example/c-vt-size-report/README.md new file mode 100644 index 000000000..0e6ef2c85 --- /dev/null +++ b/example/c-vt-size-report/README.md @@ -0,0 +1,17 @@ +# Example: `ghostty-vt` Size Report Encoding + +This contains a simple example of how to use the `ghostty-vt` size report +encoding API to encode terminal size reports into escape sequences. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-size-report/build.zig b/example/c-vt-size-report/build.zig new file mode 100644 index 000000000..fbd0f5e23 --- /dev/null +++ b/example/c-vt-size-report/build.zig @@ -0,0 +1,42 @@ +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"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_size_report", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + 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-size-report/build.zig.zon b/example/c-vt-size-report/build.zig.zon new file mode 100644 index 000000000..71d10d343 --- /dev/null +++ b/example/c-vt-size-report/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_size_report, + .version = "0.0.0", + .fingerprint = 0x17e8cdb658fab232, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-size-report/src/main.c b/example/c-vt-size-report/src/main.c new file mode 100644 index 000000000..99e9c10dc --- /dev/null +++ b/example/c-vt-size-report/src/main.c @@ -0,0 +1,27 @@ +#include <stdio.h> +#include <ghostty/vt.h> + +//! [size-report-encode] +int main() { + GhosttySizeReportSize size = { + .rows = 24, + .columns = 80, + .cell_width = 9, + .cell_height = 18, + }; + + char buf[64]; + size_t written = 0; + + GhosttyResult result = ghostty_size_report_encode( + GHOSTTY_SIZE_REPORT_MODE_2048, size, buf, sizeof(buf), &written); + + if (result == GHOSTTY_SUCCESS) { + printf("Encoded %zu bytes: ", written); + fwrite(buf, 1, written, stdout); + printf("\n"); + } + + return 0; +} +//! [size-report-encode] diff --git a/example/c-vt-static/README.md b/example/c-vt-static/README.md new file mode 100644 index 000000000..52da4ddb0 --- /dev/null +++ b/example/c-vt-static/README.md @@ -0,0 +1,18 @@ +# Example: `ghostty-vt` Static Linking + +This contains a simple example of how to statically link the `ghostty-vt` +C library with a C program using the `ghostty-vt-static` artifact. It is +otherwise identical to the `c-vt` example. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-static/build.zig b/example/c-vt-static/build.zig new file mode 100644 index 000000000..0e53d69c5 --- /dev/null +++ b/example/c-vt-static/build.zig @@ -0,0 +1,44 @@ +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"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + // Use "ghostty-vt-static" for static linking instead of + // "ghostty-vt" which provides a shared library. + exe_mod.linkLibrary(dep.artifact("ghostty-vt-static")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_static", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + 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-static/build.zig.zon b/example/c-vt-static/build.zig.zon new file mode 100644 index 000000000..413bf66fb --- /dev/null +++ b/example/c-vt-static/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_static, + .version = "0.0.0", + .fingerprint = 0xa592a9fdd5d87ed2, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-static/src/main.c b/example/c-vt-static/src/main.c new file mode 100644 index 000000000..b1297d7a7 --- /dev/null +++ b/example/c-vt-static/src/main.c @@ -0,0 +1,36 @@ +#include <stddef.h> +#include <stdio.h> +#include <string.h> +#include <ghostty/vt.h> + +int main() { + GhosttyOscParser parser; + if (ghostty_osc_new(NULL, &parser) != GHOSTTY_SUCCESS) { + return 1; + } + + // Setup change window title command to change the title to "hello" + ghostty_osc_next(parser, '0'); + ghostty_osc_next(parser, ';'); + const char *title = "hello"; + for (size_t i = 0; i < strlen(title); i++) { + ghostty_osc_next(parser, title[i]); + } + + // End parsing and get command + GhosttyOscCommand command = ghostty_osc_end(parser, 0); + + // Get and print command type + GhosttyOscCommandType type = ghostty_osc_command_type(command); + printf("Command type: %d\n", type); + + // Extract and print the title + if (ghostty_osc_command_data(command, GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR, &title)) { + printf("Extracted title: %s\n", title); + } else { + printf("Failed to extract title\n"); + } + + ghostty_osc_free(parser); + return 0; +} diff --git a/example/wasm-key-encode/README.md b/example/wasm-key-encode/README.md index ccd906cf7..e52844991 100644 --- a/example/wasm-key-encode/README.md +++ b/example/wasm-key-encode/README.md @@ -8,7 +8,7 @@ to encode key events into terminal escape sequences. First, build the WebAssembly module: ```bash -zig build lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall +zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall ``` This will create `zig-out/bin/ghostty-vt.wasm`. diff --git a/example/wasm-sgr/README.md b/example/wasm-sgr/README.md index a107c910d..465d6fdbb 100644 --- a/example/wasm-sgr/README.md +++ b/example/wasm-sgr/README.md @@ -9,7 +9,7 @@ styling attributes. First, build the WebAssembly module: ```bash -zig build lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall +zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall ``` This will create `zig-out/bin/ghostty-vt.wasm`. diff --git a/example/zig-formatter/src/main.zig b/example/zig-formatter/src/main.zig index ad101dbf1..df21a2046 100644 --- a/example/zig-formatter/src/main.zig +++ b/example/zig-formatter/src/main.zig @@ -23,8 +23,8 @@ pub fn main() !void { // Replace \n with \r\n for (buf[0..n]) |byte| { - if (byte == '\n') try stream.next('\r'); - try stream.next(byte); + if (byte == '\n') stream.next('\r'); + stream.next(byte); } } diff --git a/example/zig-vt-stream/src/main.zig b/example/zig-vt-stream/src/main.zig index 8fd438b70..87d8857dd 100644 --- a/example/zig-vt-stream/src/main.zig +++ b/example/zig-vt-stream/src/main.zig @@ -14,24 +14,24 @@ pub fn main() !void { defer stream.deinit(); // Basic text with newline - try stream.nextSlice("Hello, World!\r\n"); + stream.nextSlice("Hello, World!\r\n"); // ANSI color codes: ESC[1;32m = bold green, ESC[0m = reset - try stream.nextSlice("\x1b[1;32mGreen Text\x1b[0m\r\n"); + stream.nextSlice("\x1b[1;32mGreen Text\x1b[0m\r\n"); // Cursor positioning: ESC[1;1H = move to row 1, column 1 - try stream.nextSlice("\x1b[1;1HTop-left corner\r\n"); + stream.nextSlice("\x1b[1;1HTop-left corner\r\n"); // Cursor movement: ESC[5B = move down 5 lines - try stream.nextSlice("\x1b[5B"); - try stream.nextSlice("Moved down!\r\n"); + stream.nextSlice("\x1b[5B"); + stream.nextSlice("Moved down!\r\n"); // Erase line: ESC[2K = clear entire line - try stream.nextSlice("\x1b[2K"); - try stream.nextSlice("New content\r\n"); + stream.nextSlice("\x1b[2K"); + stream.nextSlice("New content\r\n"); // Multiple lines - try stream.nextSlice("Line A\r\nLine B\r\nLine C\r\n"); + stream.nextSlice("Line A\r\nLine B\r\nLine C\r\n"); // Get the final terminal state as a plain string const str = try t.plainString(alloc); diff --git a/flake.lock b/flake.lock index 6f12f66b9..f3a4814bb 100644 --- a/flake.lock +++ b/flake.lock @@ -16,24 +16,6 @@ "type": "github" } }, - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, "home-manager": { "inputs": { "nixpkgs": [ @@ -70,14 +52,15 @@ "root": { "inputs": { "flake-compat": "flake-compat", - "flake-utils": "flake-utils", "home-manager": "home-manager", "nixpkgs": "nixpkgs", + "systems": "systems", "zig": "zig", "zon2nix": "zon2nix" } }, "systems": { + "flake": false, "locked": { "lastModified": 1681028828, "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", @@ -97,19 +80,19 @@ "flake-compat": [ "flake-compat" ], - "flake-utils": [ - "flake-utils" - ], "nixpkgs": [ "nixpkgs" + ], + "systems": [ + "systems" ] }, "locked": { - "lastModified": 1763295135, - "narHash": "sha256-sGv/NHCmEnJivguGwB5w8LRmVqr1P72OjS+NzcJsssE=", + "lastModified": 1773145353, + "narHash": "sha256-dE8zx8WA54TRmFFQBvA48x/sXGDTP7YaDmY6nNKMAYw=", "owner": "mitchellh", "repo": "zig-overlay", - "rev": "64f8b42cfc615b2cf99144adf2b7728c7847c72a", + "rev": "8666155d83bf792956a7c40915508e6d4b2b8716", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index e063f2d70..fec675cf0 100644 --- a/flake.nix +++ b/flake.nix @@ -10,7 +10,6 @@ # Gnome 49/Gtk 4.20. # nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz"; - flake-utils.url = "github:numtide/flake-utils"; # Used for shell.nix flake-compat = { @@ -18,12 +17,17 @@ flake = false; }; + systems = { + url = "github:nix-systems/default"; + flake = false; + }; + zig = { url = "github:mitchellh/zig-overlay"; inputs = { nixpkgs.follows = "nixpkgs"; - flake-utils.follows = "flake-utils"; flake-compat.follows = "flake-compat"; + systems.follows = "systems"; }; }; diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index e58ecd448..b971163b4 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -167,6 +167,12 @@ "dest": "vendor/p/N-V-__8AAKw-DAAaV8bOAAGqA0-oD7o-HNIlPFYKRXSPT03S", "sha256": "5cedcadde81b75e60f23e5e83b5dd2b8eb4efb9f8f79bd7a347d148aeb0530f8" }, + { + "type": "archive", + "url": "https://gitlab.freedesktop.org/wayland/wayland-protocols/-/archive/1.47/wayland-protocols-1.47.tar.gz", + "dest": "vendor/p/N-V-__8AAFdWDwA0ktbNUi9pFBHCRN4weXIgIfCrVjfGxqgA", + "sha256": "dd2df14ab5f41038257aaedcc4b5fb9ac0ee018f3f0f94af9097028e60d33223" + }, { "type": "archive", "url": "https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz", diff --git a/include/ghostty.h b/include/ghostty.h index 19a200f10..3c4002abc 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -17,6 +17,11 @@ extern "C" { #include <stdint.h> #include <sys/types.h> +#ifdef _MSC_VER +#include <BaseTsd.h> +typedef SSIZE_T ssize_t; +#endif + //------------------------------------------------------------------- // Macros @@ -889,6 +894,7 @@ typedef enum { GHOSTTY_ACTION_RENDER_INSPECTOR, GHOSTTY_ACTION_DESKTOP_NOTIFICATION, GHOSTTY_ACTION_SET_TITLE, + GHOSTTY_ACTION_SET_TAB_TITLE, GHOSTTY_ACTION_PROMPT_TITLE, GHOSTTY_ACTION_PWD, GHOSTTY_ACTION_MOUSE_SHAPE, @@ -937,6 +943,7 @@ typedef union { ghostty_action_inspector_e inspector; ghostty_action_desktop_notification_s desktop_notification; ghostty_action_set_title_s set_title; + ghostty_action_set_title_s set_tab_title; ghostty_action_prompt_title_e prompt_title; ghostty_action_pwd_s pwd; ghostty_action_mouse_shape_e mouse_shape; @@ -968,7 +975,7 @@ typedef struct { } ghostty_action_s; typedef void (*ghostty_runtime_wakeup_cb)(void*); -typedef void (*ghostty_runtime_read_clipboard_cb)(void*, +typedef bool (*ghostty_runtime_read_clipboard_cb)(void*, ghostty_clipboard_e, void*); typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 4f8fef88e..2a52f4b08 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -28,33 +28,55 @@ * @section groups_sec API Reference * * The API is organized into the following groups: - * - @ref key "Key Encoding" - Encode key events into terminal sequences + * - @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 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 build_info "Build Info" - Query compile-time build configuration * - @ref allocator "Memory Management" - Memory management and custom allocators * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions * + * Encoding related APIs: + * - @ref focus "Focus Encoding" - Encode focus in/out events into terminal sequences + * - @ref key "Key Encoding" - Encode key events into terminal sequences + * - @ref mouse "Mouse Encoding" - Encode mouse events into terminal sequences + * * @section examples_sec Examples * * Complete working examples: + * - @ref c-vt-build-info/src/main.c - Build info query example * - @ref c-vt/src/main.c - OSC parser example - * - @ref c-vt-key-encode/src/main.c - Key encoding example + * - @ref c-vt-encode-key/src/main.c - Key encoding example + * - @ref c-vt-encode-mouse/src/main.c - Mouse encoding example * - @ref c-vt-paste/src/main.c - Paste safety check example * - @ref c-vt-sgr/src/main.c - SGR parser example + * - @ref c-vt-formatter/src/main.c - Terminal formatter example + * - @ref c-vt-grid-traverse/src/main.c - Grid traversal example using grid refs * */ +/** @example c-vt-build-info/src/main.c + * This example demonstrates how to query compile-time build configuration + * such as SIMD support, Kitty graphics, and tmux control mode availability. + */ + /** @example c-vt/src/main.c * This example demonstrates how to use the OSC parser to parse an OSC sequence, * extract command information, and retrieve command-specific data like window titles. */ -/** @example c-vt-key-encode/src/main.c +/** @example c-vt-encode-key/src/main.c * This example demonstrates how to use the key encoder to convert key events * into terminal escape sequences using the Kitty keyboard protocol. */ +/** @example c-vt-encode-mouse/src/main.c + * This example demonstrates how to use the mouse encoder to convert mouse events + * into terminal escape sequences using the SGR mouse format. + */ + /** @example c-vt-paste/src/main.c * This example demonstrates how to use the paste utilities to check if * paste data is safe before sending it to the terminal. @@ -65,6 +87,17 @@ * styling sequences and extract text attributes like colors and underline styles. */ +/** @example c-vt-formatter/src/main.c + * This example demonstrates how to use the terminal and formatter APIs to + * create a terminal, write VT-encoded content into it, and format the screen + * contents as plain text. + */ + +/** @example c-vt-grid-traverse/src/main.c + * This example demonstrates how to traverse the entire terminal grid using + * grid refs to inspect cell codepoints, row wrap state, and cell styles. + */ + #ifndef GHOSTTY_VT_H #define GHOSTTY_VT_H @@ -72,12 +105,25 @@ extern "C" { #endif -#include <ghostty/vt/result.h> +#include <ghostty/vt/types.h> #include <ghostty/vt/allocator.h> +#include <ghostty/vt/build_info.h> +#include <ghostty/vt/color.h> +#include <ghostty/vt/device.h> +#include <ghostty/vt/focus.h> +#include <ghostty/vt/formatter.h> +#include <ghostty/vt/render.h> +#include <ghostty/vt/terminal.h> +#include <ghostty/vt/grid_ref.h> #include <ghostty/vt/osc.h> #include <ghostty/vt/sgr.h> +#include <ghostty/vt/style.h> #include <ghostty/vt/key.h> +#include <ghostty/vt/modes.h> +#include <ghostty/vt/mouse.h> #include <ghostty/vt/paste.h> +#include <ghostty/vt/screen.h> +#include <ghostty/vt/size_report.h> #include <ghostty/vt/wasm.h> #ifdef __cplusplus diff --git a/include/ghostty/vt/allocator.h b/include/ghostty/vt/allocator.h index 4cebe91bb..ed2d36794 100644 --- a/include/ghostty/vt/allocator.h +++ b/include/ghostty/vt/allocator.h @@ -44,6 +44,24 @@ * 2. Create a GhosttyAllocator struct with your vtable and context * 3. Pass the allocator to functions that accept one * + * ## Alloc/Free Helpers + * + * ghostty_alloc() and ghostty_free() provide a simple malloc/free-style + * interface for allocating and freeing byte buffers through the library's + * allocator. These are useful when: + * + * - You need to allocate a buffer to pass into a libghostty-vt function + * (e.g. preparing input data for ghostty_terminal_vt_write()). + * - You need to free a buffer returned by a libghostty-vt function + * (e.g. the output of ghostty_formatter_format_alloc()). + * - You are on a platform where the library's internal allocator differs + * from the consumer's C runtime (e.g. Windows, where Zig's libc and + * MSVC's CRT maintain separate heaps), so calling the standard C + * free() on library-allocated memory would be undefined behavior. + * + * Always use the same allocator (or NULL) for both the allocation and + * the corresponding free. + * * @{ */ @@ -191,6 +209,46 @@ typedef struct GhosttyAllocator { const GhosttyAllocatorVtable *vtable; } GhosttyAllocator; +/** + * Allocate a buffer of `len` bytes. + * + * Uses the provided allocator, or the default allocator if NULL is passed. + * The returned buffer must be freed with ghostty_free() using the same + * allocator. + * + * @param allocator Pointer to the allocator to use, or NULL for the default + * @param len Number of bytes to allocate + * @return Pointer to the allocated buffer, or NULL if allocation failed + * + * @ingroup allocator + */ +uint8_t* ghostty_alloc(const GhosttyAllocator* allocator, size_t len); + +/** + * Free memory that was allocated by a libghostty-vt function. + * + * Use this to free buffers returned by functions such as + * ghostty_formatter_format_alloc(). Pass the same allocator that was + * used for the allocation, or NULL if the default allocator was used. + * + * On platforms where the library's internal allocator differs from the + * consumer's C runtime (e.g. Windows, where Zig's libc and MSVC's CRT + * maintain separate heaps), calling the standard C free() on memory + * allocated by the library causes undefined behavior. This function + * guarantees the correct allocator is used regardless of platform. + * + * It is safe to pass a NULL pointer; the call is a no-op in that case. + * + * @param allocator Pointer to the allocator that was used to allocate the + * memory, or NULL if the default allocator was used + * @param ptr Pointer to the memory to free (may be NULL) + * @param len Length of the allocation in bytes (must match the original + * allocation size) + * + * @ingroup allocator + */ +void ghostty_free(const GhosttyAllocator* allocator, uint8_t* ptr, size_t len); + /** @} */ #endif /* GHOSTTY_VT_ALLOCATOR_H */ diff --git a/include/ghostty/vt/build_info.h b/include/ghostty/vt/build_info.h new file mode 100644 index 000000000..8908a278c --- /dev/null +++ b/include/ghostty/vt/build_info.h @@ -0,0 +1,103 @@ +/** + * @file build_info.h + * + * Build info - query compile-time build configuration of libghostty-vt. + */ + +#ifndef GHOSTTY_VT_BUILD_INFO_H +#define GHOSTTY_VT_BUILD_INFO_H + +/** @defgroup build_info Build Info + * + * Query compile-time build configuration of libghostty-vt. + * + * These values reflect the options the library was built with and are + * constant for the lifetime of the process. + * + * ## Basic Usage + * + * Use ghostty_build_info() to query individual build options: + * + * @snippet c-vt-build-info/src/main.c build-info-query + * + * @{ + */ + +#include <stdbool.h> + +#include <ghostty/vt/types.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Build optimization mode. + */ +typedef enum { + GHOSTTY_OPTIMIZE_DEBUG = 0, + GHOSTTY_OPTIMIZE_RELEASE_SAFE = 1, + GHOSTTY_OPTIMIZE_RELEASE_SMALL = 2, + GHOSTTY_OPTIMIZE_RELEASE_FAST = 3, +} GhosttyOptimizeMode; + +/** + * Build info data types that can be queried. + * + * Each variant documents the expected output pointer type. + */ +typedef enum { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_BUILD_INFO_INVALID = 0, + + /** + * Whether SIMD-accelerated code paths are enabled. + * + * Output type: bool * + */ + GHOSTTY_BUILD_INFO_SIMD = 1, + + /** + * Whether Kitty graphics protocol support is available. + * + * Output type: bool * + */ + GHOSTTY_BUILD_INFO_KITTY_GRAPHICS = 2, + + /** + * Whether tmux control mode support is available. + * + * Output type: bool * + */ + GHOSTTY_BUILD_INFO_TMUX_CONTROL_MODE = 3, + + /** + * The optimization mode the library was built with. + * + * Output type: GhosttyOptimizeMode * + */ + GHOSTTY_BUILD_INFO_OPTIMIZE = 4, +} GhosttyBuildInfo; + +/** + * Query a compile-time build configuration value. + * + * The caller must pass a pointer to the correct output type for the + * requested data (see GhosttyBuildInfo variants for types). + * + * @param data The build info field to query + * @param out Pointer to store the result (type depends on data parameter) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the + * data type is invalid + * + * @ingroup build_info + */ +GhosttyResult ghostty_build_info(GhosttyBuildInfo data, void *out); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_BUILD_INFO_H */ diff --git a/include/ghostty/vt/device.h b/include/ghostty/vt/device.h new file mode 100644 index 000000000..fdf6bca7d --- /dev/null +++ b/include/ghostty/vt/device.h @@ -0,0 +1,150 @@ +/** + * @file device.h + * + * Device types used by the terminal for device status and device attribute + * queries. + */ + +#ifndef GHOSTTY_VT_DEVICE_H +#define GHOSTTY_VT_DEVICE_H + +#include <stddef.h> +#include <stdint.h> + +/* DA1 conformance levels (Pp parameter). */ +#define GHOSTTY_DA_CONFORMANCE_VT100 1 +#define GHOSTTY_DA_CONFORMANCE_VT101 1 +#define GHOSTTY_DA_CONFORMANCE_VT102 6 +#define GHOSTTY_DA_CONFORMANCE_VT125 12 +#define GHOSTTY_DA_CONFORMANCE_VT131 7 +#define GHOSTTY_DA_CONFORMANCE_VT132 4 +#define GHOSTTY_DA_CONFORMANCE_VT220 62 +#define GHOSTTY_DA_CONFORMANCE_VT240 62 +#define GHOSTTY_DA_CONFORMANCE_VT320 63 +#define GHOSTTY_DA_CONFORMANCE_VT340 63 +#define GHOSTTY_DA_CONFORMANCE_VT420 64 +#define GHOSTTY_DA_CONFORMANCE_VT510 65 +#define GHOSTTY_DA_CONFORMANCE_VT520 65 +#define GHOSTTY_DA_CONFORMANCE_VT525 65 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_2 62 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_3 63 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_4 64 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_5 65 + +/* DA1 feature codes (Ps parameters). */ +#define GHOSTTY_DA_FEATURE_COLUMNS_132 1 +#define GHOSTTY_DA_FEATURE_PRINTER 2 +#define GHOSTTY_DA_FEATURE_REGIS 3 +#define GHOSTTY_DA_FEATURE_SIXEL 4 +#define GHOSTTY_DA_FEATURE_SELECTIVE_ERASE 6 +#define GHOSTTY_DA_FEATURE_USER_DEFINED_KEYS 8 +#define GHOSTTY_DA_FEATURE_NATIONAL_REPLACEMENT 9 +#define GHOSTTY_DA_FEATURE_TECHNICAL_CHARACTERS 15 +#define GHOSTTY_DA_FEATURE_LOCATOR 16 +#define GHOSTTY_DA_FEATURE_TERMINAL_STATE 17 +#define GHOSTTY_DA_FEATURE_WINDOWING 18 +#define GHOSTTY_DA_FEATURE_HORIZONTAL_SCROLLING 21 +#define GHOSTTY_DA_FEATURE_ANSI_COLOR 22 +#define GHOSTTY_DA_FEATURE_RECTANGULAR_EDITING 28 +#define GHOSTTY_DA_FEATURE_ANSI_TEXT_LOCATOR 29 +#define GHOSTTY_DA_FEATURE_CLIPBOARD 52 + +/* DA2 device type identifiers (Pp parameter). */ +#define GHOSTTY_DA_DEVICE_TYPE_VT100 0 +#define GHOSTTY_DA_DEVICE_TYPE_VT220 1 +#define GHOSTTY_DA_DEVICE_TYPE_VT240 2 +#define GHOSTTY_DA_DEVICE_TYPE_VT330 18 +#define GHOSTTY_DA_DEVICE_TYPE_VT340 19 +#define GHOSTTY_DA_DEVICE_TYPE_VT320 24 +#define GHOSTTY_DA_DEVICE_TYPE_VT382 32 +#define GHOSTTY_DA_DEVICE_TYPE_VT420 41 +#define GHOSTTY_DA_DEVICE_TYPE_VT510 61 +#define GHOSTTY_DA_DEVICE_TYPE_VT520 64 +#define GHOSTTY_DA_DEVICE_TYPE_VT525 65 + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Color scheme reported in response to a CSI ? 996 n query. + * + * @ingroup terminal + */ +typedef enum { + GHOSTTY_COLOR_SCHEME_LIGHT = 0, + GHOSTTY_COLOR_SCHEME_DARK = 1, +} GhosttyColorScheme; + +/** + * Primary device attributes (DA1) response data. + * + * Returned as part of GhosttyDeviceAttributes in response to a CSI c query. + * The conformance_level is the Pp parameter and features contains the Ps + * feature codes. + * + * @ingroup terminal + */ +typedef struct { + /** Conformance level (Pp parameter). E.g. 62 for VT220. */ + uint16_t conformance_level; + + /** DA1 feature codes. Only the first num_features entries are valid. */ + uint16_t features[64]; + + /** Number of valid entries in the features array. */ + size_t num_features; +} GhosttyDeviceAttributesPrimary; + +/** + * Secondary device attributes (DA2) response data. + * + * Returned as part of GhosttyDeviceAttributes in response to a CSI > c query. + * Response format: CSI > Pp ; Pv ; Pc c + * + * @ingroup terminal + */ +typedef struct { + /** Terminal type identifier (Pp). E.g. 1 for VT220. */ + uint16_t device_type; + + /** Firmware/patch version number (Pv). */ + uint16_t firmware_version; + + /** ROM cartridge registration number (Pc). Always 0 for emulators. */ + uint16_t rom_cartridge; +} GhosttyDeviceAttributesSecondary; + +/** + * Tertiary device attributes (DA3) response data. + * + * Returned as part of GhosttyDeviceAttributes in response to a CSI = c query. + * Response format: DCS ! | D...D ST (DECRPTUI). + * + * @ingroup terminal + */ +typedef struct { + /** Unit ID encoded as 8 uppercase hex digits in the response. */ + uint32_t unit_id; +} GhosttyDeviceAttributesTertiary; + +/** + * Device attributes response data for all three DA levels. + * + * Filled by the device_attributes callback in response to CSI c, + * CSI > c, or CSI = c queries. The terminal uses whichever sub-struct + * matches the request type. + * + * @ingroup terminal + */ +typedef struct { + GhosttyDeviceAttributesPrimary primary; + GhosttyDeviceAttributesSecondary secondary; + GhosttyDeviceAttributesTertiary tertiary; +} GhosttyDeviceAttributes; + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_DEVICE_H */ diff --git a/include/ghostty/vt/focus.h b/include/ghostty/vt/focus.h new file mode 100644 index 000000000..e29fe6a66 --- /dev/null +++ b/include/ghostty/vt/focus.h @@ -0,0 +1,75 @@ +/** + * @file focus.h + * + * Focus encoding - encode focus in/out events into terminal escape sequences. + */ + +#ifndef GHOSTTY_VT_FOCUS_H +#define GHOSTTY_VT_FOCUS_H + +/** @defgroup focus Focus Encoding + * + * Utilities for encoding focus gained/lost events into terminal escape + * sequences (CSI I / CSI O) for focus reporting mode (mode 1004). + * + * ## Basic Usage + * + * Use ghostty_focus_encode() to encode a focus event into a caller-provided + * buffer. If the buffer is too small, the function returns + * GHOSTTY_OUT_OF_SPACE and sets the required size in the output parameter. + * + * ## Example + * + * @snippet c-vt-encode-focus/src/main.c focus-encode + * + * @{ + */ + +#include <stddef.h> +#include <ghostty/vt/types.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Focus event types for focus reporting mode (mode 1004). + */ +typedef enum { + /** Terminal window gained focus */ + GHOSTTY_FOCUS_GAINED = 0, + /** Terminal window lost focus */ + GHOSTTY_FOCUS_LOST = 1, +} GhosttyFocusEvent; + +/** + * Encode a focus event into a terminal escape sequence. + * + * Encodes a focus gained (CSI I) or focus lost (CSI O) report into the + * provided buffer. + * + * If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE + * and writes the required buffer size to @p out_written. The caller can + * then retry with a sufficiently sized buffer. + * + * @param event The focus event to encode + * @param buf Output buffer to write the encoded sequence into (may be NULL) + * @param buf_len Size of the output buffer in bytes + * @param[out] out_written On success, the number of bytes written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer + * is too small + */ +GhosttyResult ghostty_focus_encode( + GhosttyFocusEvent event, + char* buf, + size_t buf_len, + size_t* out_written); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_FOCUS_H */ diff --git a/include/ghostty/vt/formatter.h b/include/ghostty/vt/formatter.h new file mode 100644 index 000000000..5a9bb524b --- /dev/null +++ b/include/ghostty/vt/formatter.h @@ -0,0 +1,225 @@ +/** + * @file formatter.h + * + * Format terminal content as plain text, VT sequences, or HTML. + */ + +#ifndef GHOSTTY_VT_FORMATTER_H +#define GHOSTTY_VT_FORMATTER_H + +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> +#include <ghostty/vt/allocator.h> +#include <ghostty/vt/types.h> +#include <ghostty/vt/terminal.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup formatter Formatter + * + * Format terminal content as plain text, VT sequences, or HTML. + * + * A formatter captures a reference to a terminal and formatting options. + * It can be used repeatedly to produce output that reflects the current + * terminal state at the time of each format call. + * + * The terminal must outlive the formatter. + * + * @{ + */ + +/** + * Output format. + * + * @ingroup formatter + */ +typedef enum { + /** Plain text (no escape sequences). */ + GHOSTTY_FORMATTER_FORMAT_PLAIN, + + /** VT sequences preserving colors, styles, URLs, etc. */ + GHOSTTY_FORMATTER_FORMAT_VT, + + /** HTML with inline styles. */ + GHOSTTY_FORMATTER_FORMAT_HTML, +} GhosttyFormatterFormat; + +/** + * Extra screen state to include in styled output. + * + * @ingroup formatter + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyFormatterScreenExtra). */ + size_t size; + + /** Emit cursor position using CUP (CSI H). */ + bool cursor; + + /** Emit current SGR style state based on the cursor's active style_id. */ + bool style; + + /** Emit current hyperlink state using OSC 8 sequences. */ + bool hyperlink; + + /** Emit character protection mode using DECSCA. */ + bool protection; + + /** Emit Kitty keyboard protocol state using CSI > u and CSI = sequences. */ + bool kitty_keyboard; + + /** Emit character set designations and invocations. */ + bool charsets; +} GhosttyFormatterScreenExtra; + +/** + * Extra terminal state to include in styled output. + * + * @ingroup formatter + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyFormatterTerminalExtra). */ + size_t size; + + /** Emit the palette using OSC 4 sequences. */ + bool palette; + + /** Emit terminal modes that differ from their defaults using CSI h/l. */ + bool modes; + + /** Emit scrolling region state using DECSTBM and DECSLRM sequences. */ + bool scrolling_region; + + /** Emit tabstop positions by clearing all tabs and setting each one. */ + bool tabstops; + + /** Emit the present working directory using OSC 7. */ + bool pwd; + + /** Emit keyboard modes such as ModifyOtherKeys. */ + bool keyboard; + + /** Screen-level extras. */ + GhosttyFormatterScreenExtra screen; +} GhosttyFormatterTerminalExtra; + +/** + * Opaque handle to a formatter instance. + * + * @ingroup formatter + */ +typedef struct GhosttyFormatter* GhosttyFormatter; + +/** + * Options for creating a terminal formatter. + * + * @ingroup formatter + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyFormatterTerminalOptions). */ + size_t size; + + /** Output format to emit. */ + GhosttyFormatterFormat emit; + + /** Whether to unwrap soft-wrapped lines. */ + bool unwrap; + + /** Whether to trim trailing whitespace on non-blank lines. */ + bool trim; + + /** Extra terminal state to include in styled output. */ + GhosttyFormatterTerminalExtra extra; +} GhosttyFormatterTerminalOptions; + +/** + * Create a formatter for a terminal's active screen. + * + * The terminal must outlive the formatter. The formatter stores a borrowed + * reference to the terminal and reads its current state on each format call. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param formatter Pointer to store the created formatter handle + * @param terminal The terminal to format (must not be NULL) + * @param options Formatting options + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup formatter + */ +GhosttyResult ghostty_formatter_terminal_new( + const GhosttyAllocator* allocator, + GhosttyFormatter* formatter, + GhosttyTerminal terminal, + GhosttyFormatterTerminalOptions options); + +/** + * Run the formatter and produce output into the caller-provided buffer. + * + * Each call formats the current terminal state. Pass NULL for buf to + * query the required buffer size without writing any output; in that case + * out_written receives the required size and the return value is + * GHOSTTY_OUT_OF_SPACE. + * + * If the buffer is too small, returns GHOSTTY_OUT_OF_SPACE and sets + * out_written to the required size. The caller can then retry with a + * larger buffer. + * + * @param formatter The formatter handle (must not be NULL) + * @param buf Pointer to the output buffer, or NULL to query size + * @param buf_len Length of the output buffer in bytes + * @param out_written Pointer to receive the number of bytes written, + * or the required size on failure + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup formatter + */ +GhosttyResult ghostty_formatter_format_buf(GhosttyFormatter formatter, + uint8_t* buf, + size_t buf_len, + size_t* out_written); + +/** + * Run the formatter and return an allocated buffer with the output. + * + * Each call formats the current terminal state. The buffer is allocated + * using the provided allocator (or the default allocator if NULL). + * The caller is responsible for freeing the returned buffer with + * ghostty_free(), passing the same allocator (or NULL for the default) + * that was used for the allocation. + * + * @param formatter The formatter handle (must not be NULL) + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param out_ptr Pointer to receive the allocated buffer + * @param out_len Pointer to receive the length of the output in bytes + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation + * failure + * + * @ingroup formatter + */ +GhosttyResult ghostty_formatter_format_alloc(GhosttyFormatter formatter, + const GhosttyAllocator* allocator, + uint8_t** out_ptr, + size_t* out_len); + +/** + * Free a formatter instance. + * + * Releases all resources associated with the formatter. After this call, + * the formatter handle becomes invalid. + * + * @param formatter The formatter handle to free (may be NULL) + * + * @ingroup formatter + */ +void ghostty_formatter_free(GhosttyFormatter formatter); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_FORMATTER_H */ diff --git a/include/ghostty/vt/grid_ref.h b/include/ghostty/vt/grid_ref.h new file mode 100644 index 000000000..29ecda7b5 --- /dev/null +++ b/include/ghostty/vt/grid_ref.h @@ -0,0 +1,131 @@ +/** + * @file grid_ref.h + * + * Terminal grid reference type for referencing a resolved position in the + * terminal grid. + */ + +#ifndef GHOSTTY_VT_GRID_REF_H +#define GHOSTTY_VT_GRID_REF_H + +#include <stddef.h> +#include <stdint.h> +#include <ghostty/vt/types.h> +#include <ghostty/vt/screen.h> +#include <ghostty/vt/style.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup grid_ref Grid Reference + * + * A grid reference is a resolved reference to a specific cell position in the + * terminal's internal page structure. Obtain a grid reference from + * ghostty_terminal_grid_ref(), then extract the cell or row via + * ghostty_grid_ref_cell() and ghostty_grid_ref_row(). + * + * A grid reference is only valid until the next update to the terminal + * instance. There is no guarantee that a grid reference will remain + * valid after ANY operation, even if a seemingly unrelated part of + * the grid is changed, so any information related to the grid reference + * should be read and cached immediately after obtaining the grid reference. + * + * This API is not meant to be used as the core of render loop. It isn't + * built to sustain the framerates needed for rendering large screens. + * Use the render state API for that. + * + * ## Example + * + * @snippet c-vt-grid-traverse/src/main.c grid-ref-traverse + * + * @{ + */ + +/** + * A resolved reference to a terminal cell position. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * + * @ingroup grid_ref + */ +typedef struct { + size_t size; + void *node; + uint16_t x; + uint16_t y; +} GhosttyGridRef; + +/** + * Get the cell from a grid reference. + * + * @param ref Pointer to the grid reference + * @param[out] out_cell On success, set to the cell at the ref's position (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's + * node is NULL + * + * @ingroup grid_ref + */ +GhosttyResult ghostty_grid_ref_cell(const GhosttyGridRef *ref, + GhosttyCell *out_cell); + +/** + * Get the row from a grid reference. + * + * @param ref Pointer to the grid reference + * @param[out] out_row On success, set to the row at the ref's position (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's + * node is NULL + * + * @ingroup grid_ref + */ +GhosttyResult ghostty_grid_ref_row(const GhosttyGridRef *ref, + GhosttyRow *out_row); + +/** + * Get the grapheme cluster codepoints for the cell at the grid reference's + * position. + * + * Writes the full grapheme cluster (the cell's primary codepoint followed by + * any combining codepoints) into the provided buffer. If the cell has no text, + * out_len is set to 0 and GHOSTTY_SUCCESS is returned. + * + * If the buffer is too small (or NULL), the function returns + * GHOSTTY_OUT_OF_SPACE and writes the required number of codepoints to + * out_len. The caller can then retry with a sufficiently sized buffer. + * + * @param ref Pointer to the grid reference + * @param buf Output buffer of uint32_t codepoints (may be NULL) + * @param buf_len Number of uint32_t elements in the buffer + * @param[out] out_len On success, the number of codepoints written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size in codepoints. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's + * node is NULL, GHOSTTY_OUT_OF_SPACE if the buffer is too small + * + * @ingroup grid_ref + */ +GhosttyResult ghostty_grid_ref_graphemes(const GhosttyGridRef *ref, + uint32_t *buf, + size_t buf_len, + size_t *out_len); + +/** + * Get the style of the cell at the grid reference's position. + * + * @param ref Pointer to the grid reference + * @param[out] out_style On success, set to the cell's style (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's + * node is NULL + * + * @ingroup grid_ref + */ +GhosttyResult ghostty_grid_ref_style(const GhosttyGridRef *ref, + GhosttyStyle *out_style); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_GRID_REF_H */ diff --git a/include/ghostty/vt/key.h b/include/ghostty/vt/key.h index 772b5d43b..61b954753 100644 --- a/include/ghostty/vt/key.h +++ b/include/ghostty/vt/key.h @@ -15,7 +15,9 @@ * ## Basic Usage * * 1. Create an encoder instance with ghostty_key_encoder_new() - * 2. Configure encoder options with ghostty_key_encoder_setopt(). + * 2. Configure encoder options with ghostty_key_encoder_setopt() + * or ghostty_key_encoder_setopt_from_terminal() if you have a + * GhosttyTerminal. * 3. For each key event: * - Create a key event with ghostty_key_event_new() * - Set event properties (action, key, modifiers, etc.) @@ -25,49 +27,40 @@ * changing its properties. * 4. Free the encoder with ghostty_key_encoder_free() when done * + * For a complete working example, see example/c-vt-encode-key in the + * repository. + * * ## Example * - * @code{.c} - * #include <assert.h> - * #include <stdio.h> - * #include <ghostty/vt.h> - * - * int main() { - * // Create encoder - * GhosttyKeyEncoder encoder; - * GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder); - * assert(result == GHOSTTY_SUCCESS); - * - * // Enable Kitty keyboard protocol with all features - * ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS, - * &(uint8_t){GHOSTTY_KITTY_KEY_ALL}); - * - * // Create and configure key event for Ctrl+C press - * GhosttyKeyEvent event; - * result = ghostty_key_event_new(NULL, &event); - * assert(result == GHOSTTY_SUCCESS); - * ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_PRESS); - * ghostty_key_event_set_key(event, GHOSTTY_KEY_C); - * ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL); - * - * // Encode the key event - * char buf[128]; - * size_t written = 0; - * result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); - * assert(result == GHOSTTY_SUCCESS); - * - * // Use the encoded sequence (e.g., write to terminal) - * fwrite(buf, 1, written, stdout); - * - * // Cleanup - * ghostty_key_event_free(event); - * ghostty_key_encoder_free(encoder); - * return 0; - * } - * @endcode + * @snippet c-vt-encode-key/src/main.c key-encode * - * For a complete working example, see example/c-vt-key-encode in the - * repository. + * ## Example: Encoding with Terminal State + * + * When you have a GhosttyTerminal, you can sync its modes (cursor key + * application, Kitty flags, etc.) into the encoder automatically: + * + * @code{.c} + * // Create a terminal and feed it some VT data that changes modes + * GhosttyTerminal terminal; + * ghostty_terminal_new(NULL, &terminal, + * (GhosttyTerminalOptions){.cols = 80, .rows = 24, .max_scrollback = 0}); + * + * // Application might write data that enables Kitty keyboard protocol, etc. + * ghostty_terminal_vt_write(terminal, vt_data, vt_len); + * + * // Create an encoder and sync its options from the terminal + * GhosttyKeyEncoder encoder; + * ghostty_key_encoder_new(NULL, &encoder); + * ghostty_key_encoder_setopt_from_terminal(encoder, terminal); + * + * // Encode a key event using the terminal-derived options + * char buf[128]; + * size_t written = 0; + * ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); + * + * ghostty_key_encoder_free(encoder); + * ghostty_terminal_free(terminal); + * @endcode * * @{ */ diff --git a/include/ghostty/vt/key/encoder.h b/include/ghostty/vt/key/encoder.h index 766a29427..9bfeba98d 100644 --- a/include/ghostty/vt/key/encoder.h +++ b/include/ghostty/vt/key/encoder.h @@ -9,8 +9,9 @@ #include <stddef.h> #include <stdint.h> -#include <ghostty/vt/result.h> +#include <ghostty/vt/types.h> #include <ghostty/vt/allocator.h> +#include <ghostty/vt/terminal.h> #include <ghostty/vt/key/event.h> /** @@ -140,6 +141,10 @@ void ghostty_key_encoder_free(GhosttyKeyEncoder encoder); * protocol selection (Kitty keyboard protocol flags), and platform-specific * behaviors (macOS option-as-alt). * + * If you are using a terminal instance, you can set the key encoding + * options based on the active terminal state (e.g. legacy vs Kitty mode + * and associated flags) with ghostty_key_encoder_setopt_from_terminal(). + * * A null pointer value does nothing. It does not reset the value to the * default. The setopt call will do nothing. * @@ -151,6 +156,25 @@ void ghostty_key_encoder_free(GhosttyKeyEncoder encoder); */ void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value); +/** + * Set encoder options from a terminal's current state. + * + * Reads the terminal's current modes and flags and applies them to the + * encoder's options. This sets cursor key application mode, keypad mode, + * alt escape prefix, modifyOtherKeys state, and Kitty keyboard protocol + * flags from the terminal state. + * + * Note that the `macos_option_as_alt` option cannot be determined from + * terminal state and is reset to `GHOSTTY_OPTION_AS_ALT_FALSE` by this + * call. Use ghostty_key_encoder_setopt() to set it afterward if needed. + * + * @param encoder The encoder handle, must not be NULL + * @param terminal The terminal handle, must not be NULL + * + * @ingroup key + */ +void ghostty_key_encoder_setopt_from_terminal(GhosttyKeyEncoder encoder, GhosttyTerminal terminal); + /** * Encode a key event into a terminal escape sequence. * @@ -161,7 +185,7 @@ void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOpti * typically don't generate escape sequences. Check the out_len parameter to * determine if any data was written. * - * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_MEMORY + * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_SPACE * and out_len will contain the required buffer size. The caller can then * allocate a larger buffer and call the function again. * @@ -170,15 +194,15 @@ void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOpti * @param out_buf Buffer to write the encoded sequence to * @param out_buf_size Size of the output buffer in bytes * @param out_len Pointer to store the number of bytes written (may be NULL) - * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer too small, or other error code + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if buffer too small, or other error code * * ## Example: Calculate required buffer size * * @code{.c} - * // Query the required size with a NULL buffer (always returns OUT_OF_MEMORY) + * // Query the required size with a NULL buffer (always returns OUT_OF_SPACE) * size_t required = 0; * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required); - * assert(result == GHOSTTY_OUT_OF_MEMORY); + * assert(result == GHOSTTY_OUT_OF_SPACE); * * // Allocate buffer of required size * char *buf = malloc(required); @@ -204,7 +228,7 @@ void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOpti * if (result == GHOSTTY_SUCCESS) { * // Write the encoded sequence to the terminal * write(pty_fd, buf, written); - * } else if (result == GHOSTTY_OUT_OF_MEMORY) { + * } else if (result == GHOSTTY_OUT_OF_SPACE) { * // Buffer too small, written contains required size * char *dynamic_buf = malloc(written); * result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written); diff --git a/include/ghostty/vt/key/event.h b/include/ghostty/vt/key/event.h index dbd2e9f84..2fe455112 100644 --- a/include/ghostty/vt/key/event.h +++ b/include/ghostty/vt/key/event.h @@ -10,7 +10,7 @@ #include <stdbool.h> #include <stddef.h> #include <stdint.h> -#include <ghostty/vt/result.h> +#include <ghostty/vt/types.h> #include <ghostty/vt/allocator.h> /** diff --git a/include/ghostty/vt/modes.h b/include/ghostty/vt/modes.h new file mode 100644 index 000000000..53e148dd7 --- /dev/null +++ b/include/ghostty/vt/modes.h @@ -0,0 +1,196 @@ +/** + * @file modes.h + * + * Terminal mode utilities - pack and unpack ANSI/DEC mode identifiers. + */ + +#ifndef GHOSTTY_VT_MODES_H +#define GHOSTTY_VT_MODES_H + +/** @defgroup modes Mode Utilities + * + * Utilities for working with terminal modes. A mode is a compact + * 16-bit representation of a terminal mode identifier that encodes both + * the numeric mode value (up to 15 bits) and whether the mode is an ANSI + * mode or a DEC private mode (?-prefixed). + * + * The packed layout (least-significant bit first) is: + * - Bits 0–14: mode value (u15) + * - Bit 15: ANSI flag (0 = DEC private mode, 1 = ANSI mode) + * + * ## Example + * + * @snippet c-vt-modes/src/main.c modes-pack-unpack + * + * ## DECRPM Report Encoding + * + * Use ghostty_mode_report_encode() to encode a DECRPM response into a + * caller-provided buffer: + * + * @snippet c-vt-modes/src/main.c modes-decrpm + * + * @{ + */ + +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> + +#include <ghostty/vt/types.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/** @name ANSI Modes + * Modes for standard ANSI modes. + * @{ + */ +#define GHOSTTY_MODE_KAM (ghostty_mode_new(2, true)) /**< Keyboard action (disable keyboard) */ +#define GHOSTTY_MODE_INSERT (ghostty_mode_new(4, true)) /**< Insert mode */ +#define GHOSTTY_MODE_SRM (ghostty_mode_new(12, true)) /**< Send/receive mode */ +#define GHOSTTY_MODE_LINEFEED (ghostty_mode_new(20, true)) /**< Linefeed/new line mode */ +/** @} */ + +/** @name DEC Private Modes + * Modes for DEC private modes (?-prefixed). + * @{ + */ +#define GHOSTTY_MODE_DECCKM (ghostty_mode_new(1, false)) /**< Cursor keys */ +#define GHOSTTY_MODE_132_COLUMN (ghostty_mode_new(3, false)) /**< 132/80 column mode */ +#define GHOSTTY_MODE_SLOW_SCROLL (ghostty_mode_new(4, false)) /**< Slow scroll */ +#define GHOSTTY_MODE_REVERSE_COLORS (ghostty_mode_new(5, false)) /**< Reverse video */ +#define GHOSTTY_MODE_ORIGIN (ghostty_mode_new(6, false)) /**< Origin mode */ +#define GHOSTTY_MODE_WRAPAROUND (ghostty_mode_new(7, false)) /**< Auto-wrap mode */ +#define GHOSTTY_MODE_AUTOREPEAT (ghostty_mode_new(8, false)) /**< Auto-repeat keys */ +#define GHOSTTY_MODE_X10_MOUSE (ghostty_mode_new(9, false)) /**< X10 mouse reporting */ +#define GHOSTTY_MODE_CURSOR_BLINKING (ghostty_mode_new(12, false)) /**< Cursor blink */ +#define GHOSTTY_MODE_CURSOR_VISIBLE (ghostty_mode_new(25, false)) /**< Cursor visible (DECTCEM) */ +#define GHOSTTY_MODE_ENABLE_MODE_3 (ghostty_mode_new(40, false)) /**< Allow 132 column mode */ +#define GHOSTTY_MODE_REVERSE_WRAP (ghostty_mode_new(45, false)) /**< Reverse wrap */ +#define GHOSTTY_MODE_ALT_SCREEN_LEGACY (ghostty_mode_new(47, false)) /**< Alternate screen (legacy) */ +#define GHOSTTY_MODE_KEYPAD_KEYS (ghostty_mode_new(66, false)) /**< Application keypad */ +#define GHOSTTY_MODE_LEFT_RIGHT_MARGIN (ghostty_mode_new(69, false)) /**< Left/right margin mode */ +#define GHOSTTY_MODE_NORMAL_MOUSE (ghostty_mode_new(1000, false)) /**< Normal mouse tracking */ +#define GHOSTTY_MODE_BUTTON_MOUSE (ghostty_mode_new(1002, false)) /**< Button-event mouse tracking */ +#define GHOSTTY_MODE_ANY_MOUSE (ghostty_mode_new(1003, false)) /**< Any-event mouse tracking */ +#define GHOSTTY_MODE_FOCUS_EVENT (ghostty_mode_new(1004, false)) /**< Focus in/out events */ +#define GHOSTTY_MODE_UTF8_MOUSE (ghostty_mode_new(1005, false)) /**< UTF-8 mouse format */ +#define GHOSTTY_MODE_SGR_MOUSE (ghostty_mode_new(1006, false)) /**< SGR mouse format */ +#define GHOSTTY_MODE_ALT_SCROLL (ghostty_mode_new(1007, false)) /**< Alternate scroll mode */ +#define GHOSTTY_MODE_URXVT_MOUSE (ghostty_mode_new(1015, false)) /**< URxvt mouse format */ +#define GHOSTTY_MODE_SGR_PIXELS_MOUSE (ghostty_mode_new(1016, false)) /**< SGR-Pixels mouse format */ +#define GHOSTTY_MODE_NUMLOCK_KEYPAD (ghostty_mode_new(1035, false)) /**< Ignore keypad with NumLock */ +#define GHOSTTY_MODE_ALT_ESC_PREFIX (ghostty_mode_new(1036, false)) /**< Alt key sends ESC prefix */ +#define GHOSTTY_MODE_ALT_SENDS_ESC (ghostty_mode_new(1039, false)) /**< Alt sends escape */ +#define GHOSTTY_MODE_REVERSE_WRAP_EXT (ghostty_mode_new(1045, false)) /**< Extended reverse wrap */ +#define GHOSTTY_MODE_ALT_SCREEN (ghostty_mode_new(1047, false)) /**< Alternate screen */ +#define GHOSTTY_MODE_SAVE_CURSOR (ghostty_mode_new(1048, false)) /**< Save cursor (DECSC) */ +#define GHOSTTY_MODE_ALT_SCREEN_SAVE (ghostty_mode_new(1049, false)) /**< Alt screen + save cursor + clear */ +#define GHOSTTY_MODE_BRACKETED_PASTE (ghostty_mode_new(2004, false)) /**< Bracketed paste mode */ +#define GHOSTTY_MODE_SYNC_OUTPUT (ghostty_mode_new(2026, false)) /**< Synchronized output */ +#define GHOSTTY_MODE_GRAPHEME_CLUSTER (ghostty_mode_new(2027, false)) /**< Grapheme cluster mode */ +#define GHOSTTY_MODE_COLOR_SCHEME_REPORT (ghostty_mode_new(2031, false)) /**< Report color scheme */ +#define GHOSTTY_MODE_IN_BAND_RESIZE (ghostty_mode_new(2048, false)) /**< In-band size reports */ +/** @} */ + +/** + * A packed 16-bit terminal mode. + * + * Encodes a mode value (bits 0–14) and an ANSI flag (bit 15) into a + * single 16-bit integer. Use the inline helper functions to construct + * and inspect modes rather than manipulating bits directly. + */ +typedef uint16_t GhosttyMode; + +/** + * Create a mode from a mode value and ANSI flag. + * + * @param value The numeric mode value (0–32767) + * @param ansi true for an ANSI mode, false for a DEC private mode + * @return The packed mode + * + * @ingroup modes + */ +static inline GhosttyMode ghostty_mode_new(uint16_t value, bool ansi) { + return (GhosttyMode)((value & 0x7FFF) | ((uint16_t)ansi << 15)); +} + +/** + * Extract the numeric mode value from a mode. + * + * @param mode The mode + * @return The mode value (0–32767) + * + * @ingroup modes + */ +static inline uint16_t ghostty_mode_value(GhosttyMode mode) { + return mode & 0x7FFF; +} + +/** + * Check whether a mode represents an ANSI mode. + * + * @param mode The mode + * @return true if this is an ANSI mode, false if it is a DEC private mode + * + * @ingroup modes + */ +static inline bool ghostty_mode_ansi(GhosttyMode mode) { + return (mode >> 15) != 0; +} + +/** + * DECRPM report state values. + * + * These correspond to the Ps2 parameter in a DECRPM response + * sequence (CSI ? Ps1 ; Ps2 $ y). + */ +typedef enum { + /** Mode is not recognized */ + GHOSTTY_MODE_REPORT_NOT_RECOGNIZED = 0, + /** Mode is set (enabled) */ + GHOSTTY_MODE_REPORT_SET = 1, + /** Mode is reset (disabled) */ + GHOSTTY_MODE_REPORT_RESET = 2, + /** Mode is permanently set */ + GHOSTTY_MODE_REPORT_PERMANENTLY_SET = 3, + /** Mode is permanently reset */ + GHOSTTY_MODE_REPORT_PERMANENTLY_RESET = 4, +} GhosttyModeReportState; + +/** + * Encode a DECRPM (DEC Private Mode Report) response sequence. + * + * Writes a mode report escape sequence into the provided buffer. + * The generated sequence has the form: + * - DEC private mode: CSI ? Ps1 ; Ps2 $ y + * - ANSI mode: CSI Ps1 ; Ps2 $ y + * + * If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE + * and writes the required buffer size to @p out_written. The caller can + * then retry with a sufficiently sized buffer. + * + * @param mode The mode identifying the mode to report on + * @param state The report state for this mode + * @param buf Output buffer to write the encoded sequence into (may be NULL) + * @param buf_len Size of the output buffer in bytes + * @param[out] out_written On success, the number of bytes written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer + * is too small + */ +GhosttyResult ghostty_mode_report_encode( + GhosttyMode mode, + GhosttyModeReportState state, + char* buf, + size_t buf_len, + size_t* out_written); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_MODES_H */ diff --git a/include/ghostty/vt/mouse.h b/include/ghostty/vt/mouse.h new file mode 100644 index 000000000..4ba5f52e3 --- /dev/null +++ b/include/ghostty/vt/mouse.h @@ -0,0 +1,70 @@ +/** + * @file mouse.h + * + * Mouse encoding module - encode mouse events into terminal escape sequences. + */ + +#ifndef GHOSTTY_VT_MOUSE_H +#define GHOSTTY_VT_MOUSE_H + +/** @defgroup mouse Mouse Encoding + * + * Utilities for encoding mouse events into terminal escape sequences, + * supporting X10, UTF-8, SGR, URxvt, and SGR-Pixels mouse protocols. + * + * ## Basic Usage + * + * 1. Create an encoder instance with ghostty_mouse_encoder_new(). + * 2. Configure encoder options with ghostty_mouse_encoder_setopt() or + * ghostty_mouse_encoder_setopt_from_terminal(). + * 3. For each mouse event: + * - Create a mouse event with ghostty_mouse_event_new(). + * - Set event properties (action, button, modifiers, position). + * - Encode with ghostty_mouse_encoder_encode(). + * - Free the event with ghostty_mouse_event_free() or reuse it. + * 4. Free the encoder with ghostty_mouse_encoder_free() when done. + * + * For a complete working example, see example/c-vt-encode-mouse in the + * repository. + * + * ## Example + * + * @snippet c-vt-encode-mouse/src/main.c mouse-encode + * + * ## Example: Encoding with Terminal State + * + * When you have a GhosttyTerminal, you can sync its tracking mode and + * output format into the encoder automatically: + * + * @code{.c} + * // Create a terminal and feed it some VT data that enables mouse tracking + * GhosttyTerminal terminal; + * ghostty_terminal_new(NULL, &terminal, + * (GhosttyTerminalOptions){.cols = 80, .rows = 24, .max_scrollback = 0}); + * + * // Application might write data that enables mouse reporting, etc. + * ghostty_terminal_vt_write(terminal, vt_data, vt_len); + * + * // Create an encoder and sync its options from the terminal + * GhosttyMouseEncoder encoder; + * ghostty_mouse_encoder_new(NULL, &encoder); + * ghostty_mouse_encoder_setopt_from_terminal(encoder, terminal); + * + * // Encode a mouse event using the terminal-derived options + * char buf[128]; + * size_t written = 0; + * ghostty_mouse_encoder_encode(encoder, event, buf, sizeof(buf), &written); + * + * ghostty_mouse_encoder_free(encoder); + * ghostty_terminal_free(terminal); + * @endcode + * + * @{ + */ + +#include <ghostty/vt/mouse/event.h> +#include <ghostty/vt/mouse/encoder.h> + +/** @} */ + +#endif /* GHOSTTY_VT_MOUSE_H */ diff --git a/include/ghostty/vt/mouse/encoder.h b/include/ghostty/vt/mouse/encoder.h new file mode 100644 index 000000000..63fb3e075 --- /dev/null +++ b/include/ghostty/vt/mouse/encoder.h @@ -0,0 +1,211 @@ +/** + * @file encoder.h + * + * Mouse event encoding to terminal escape sequences. + */ + +#ifndef GHOSTTY_VT_MOUSE_ENCODER_H +#define GHOSTTY_VT_MOUSE_ENCODER_H + +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> +#include <ghostty/vt/allocator.h> +#include <ghostty/vt/mouse/event.h> +#include <ghostty/vt/terminal.h> +#include <ghostty/vt/types.h> + +/** + * Opaque handle to a mouse encoder instance. + * + * This handle represents a mouse encoder that converts normalized + * mouse events into terminal escape sequences. + * + * @ingroup mouse + */ +typedef struct GhosttyMouseEncoder *GhosttyMouseEncoder; + +/** + * Mouse tracking mode. + * + * @ingroup mouse + */ +typedef enum { + /** Mouse reporting disabled. */ + GHOSTTY_MOUSE_TRACKING_NONE = 0, + + /** X10 mouse mode. */ + GHOSTTY_MOUSE_TRACKING_X10 = 1, + + /** Normal mouse mode (button press/release only). */ + GHOSTTY_MOUSE_TRACKING_NORMAL = 2, + + /** Button-event tracking mode. */ + GHOSTTY_MOUSE_TRACKING_BUTTON = 3, + + /** Any-event tracking mode. */ + GHOSTTY_MOUSE_TRACKING_ANY = 4, +} GhosttyMouseTrackingMode; + +/** + * Mouse output format. + * + * @ingroup mouse + */ +typedef enum { + GHOSTTY_MOUSE_FORMAT_X10 = 0, + GHOSTTY_MOUSE_FORMAT_UTF8 = 1, + GHOSTTY_MOUSE_FORMAT_SGR = 2, + GHOSTTY_MOUSE_FORMAT_URXVT = 3, + GHOSTTY_MOUSE_FORMAT_SGR_PIXELS = 4, +} GhosttyMouseFormat; + +/** + * Mouse encoder size and geometry context. + * + * This describes the rendered terminal geometry used to convert + * surface-space positions into encoded coordinates. + * + * @ingroup mouse + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyMouseEncoderSize). */ + size_t size; + + /** Full screen width in pixels. */ + uint32_t screen_width; + + /** Full screen height in pixels. */ + uint32_t screen_height; + + /** Cell width in pixels. Must be non-zero. */ + uint32_t cell_width; + + /** Cell height in pixels. Must be non-zero. */ + uint32_t cell_height; + + /** Top padding in pixels. */ + uint32_t padding_top; + + /** Bottom padding in pixels. */ + uint32_t padding_bottom; + + /** Right padding in pixels. */ + uint32_t padding_right; + + /** Left padding in pixels. */ + uint32_t padding_left; +} GhosttyMouseEncoderSize; + +/** + * Mouse encoder option identifiers. + * + * These values are used with ghostty_mouse_encoder_setopt() to configure + * the behavior of the mouse encoder. + * + * @ingroup mouse + */ +typedef enum { + /** Mouse tracking mode (value: GhosttyMouseTrackingMode). */ + GHOSTTY_MOUSE_ENCODER_OPT_EVENT = 0, + + /** Mouse output format (value: GhosttyMouseFormat). */ + GHOSTTY_MOUSE_ENCODER_OPT_FORMAT = 1, + + /** Renderer size context (value: GhosttyMouseEncoderSize). */ + GHOSTTY_MOUSE_ENCODER_OPT_SIZE = 2, + + /** Whether any mouse button is currently pressed (value: bool). */ + GHOSTTY_MOUSE_ENCODER_OPT_ANY_BUTTON_PRESSED = 3, + + /** Whether to enable motion deduplication by last cell (value: bool). */ + GHOSTTY_MOUSE_ENCODER_OPT_TRACK_LAST_CELL = 4, +} GhosttyMouseEncoderOption; + +/** + * Create a new mouse encoder instance. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param encoder Pointer to store the created encoder handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup mouse + */ +GhosttyResult ghostty_mouse_encoder_new(const GhosttyAllocator *allocator, + GhosttyMouseEncoder *encoder); + +/** + * Free a mouse encoder instance. + * + * @param encoder The encoder handle to free (may be NULL) + * + * @ingroup mouse + */ +void ghostty_mouse_encoder_free(GhosttyMouseEncoder encoder); + +/** + * Set an option on the mouse encoder. + * + * A null pointer value does nothing. It does not reset to defaults. + * + * @param encoder The encoder handle, must not be NULL + * @param option The option to set + * @param value Pointer to option value (type depends on option) + * + * @ingroup mouse + */ +void ghostty_mouse_encoder_setopt(GhosttyMouseEncoder encoder, + GhosttyMouseEncoderOption option, + const void *value); + +/** + * Set encoder options from a terminal's current state. + * + * This sets tracking mode and output format from terminal state. + * It does not modify size or any-button state. + * + * @param encoder The encoder handle, must not be NULL + * @param terminal The terminal handle, must not be NULL + * + * @ingroup mouse + */ +void ghostty_mouse_encoder_setopt_from_terminal(GhosttyMouseEncoder encoder, + GhosttyTerminal terminal); + +/** + * Reset internal encoder state. + * + * This clears motion deduplication state (last tracked cell). + * + * @param encoder The encoder handle (may be NULL) + * + * @ingroup mouse + */ +void ghostty_mouse_encoder_reset(GhosttyMouseEncoder encoder); + +/** + * Encode a mouse event into a terminal escape sequence. + * + * Not all mouse events produce output. In such cases this returns + * GHOSTTY_SUCCESS with out_len set to 0. + * + * If the output buffer is too small, this returns GHOSTTY_OUT_OF_SPACE + * and out_len contains the required size. + * + * @param encoder The encoder handle, must not be NULL + * @param event The mouse event to encode, must not be NULL + * @param out_buf Buffer to write encoded bytes to, or NULL to query required size + * @param out_buf_size Size of out_buf in bytes + * @param out_len Pointer to store bytes written (or required bytes on failure) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if buffer is too small, + * or another error code + * + * @ingroup mouse + */ +GhosttyResult ghostty_mouse_encoder_encode(GhosttyMouseEncoder encoder, + GhosttyMouseEvent event, + char *out_buf, + size_t out_buf_size, + size_t *out_len); + +#endif /* GHOSTTY_VT_MOUSE_ENCODER_H */ diff --git a/include/ghostty/vt/mouse/event.h b/include/ghostty/vt/mouse/event.h new file mode 100644 index 000000000..44132097c --- /dev/null +++ b/include/ghostty/vt/mouse/event.h @@ -0,0 +1,193 @@ +/** + * @file event.h + * + * Mouse event representation and manipulation. + */ + +#ifndef GHOSTTY_VT_MOUSE_EVENT_H +#define GHOSTTY_VT_MOUSE_EVENT_H + +#include <stdbool.h> +#include <ghostty/vt/allocator.h> +#include <ghostty/vt/key/event.h> +#include <ghostty/vt/types.h> + +/** + * Opaque handle to a mouse event. + * + * This handle represents a normalized mouse input event containing + * action, button, modifiers, and surface-space position. + * + * @ingroup mouse + */ +typedef struct GhosttyMouseEvent *GhosttyMouseEvent; + +/** + * Mouse event action type. + * + * @ingroup mouse + */ +typedef enum { + /** Mouse button was pressed. */ + GHOSTTY_MOUSE_ACTION_PRESS = 0, + + /** Mouse button was released. */ + GHOSTTY_MOUSE_ACTION_RELEASE = 1, + + /** Mouse moved. */ + GHOSTTY_MOUSE_ACTION_MOTION = 2, +} GhosttyMouseAction; + +/** + * Mouse button identity. + * + * @ingroup mouse + */ +typedef enum { + GHOSTTY_MOUSE_BUTTON_UNKNOWN = 0, + GHOSTTY_MOUSE_BUTTON_LEFT = 1, + GHOSTTY_MOUSE_BUTTON_RIGHT = 2, + GHOSTTY_MOUSE_BUTTON_MIDDLE = 3, + GHOSTTY_MOUSE_BUTTON_FOUR = 4, + GHOSTTY_MOUSE_BUTTON_FIVE = 5, + GHOSTTY_MOUSE_BUTTON_SIX = 6, + GHOSTTY_MOUSE_BUTTON_SEVEN = 7, + GHOSTTY_MOUSE_BUTTON_EIGHT = 8, + GHOSTTY_MOUSE_BUTTON_NINE = 9, + GHOSTTY_MOUSE_BUTTON_TEN = 10, + GHOSTTY_MOUSE_BUTTON_ELEVEN = 11, +} GhosttyMouseButton; + +/** + * Mouse position in surface-space pixels. + * + * @ingroup mouse + */ +typedef struct { + float x; + float y; +} GhosttyMousePosition; + +/** + * Create a new mouse event instance. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param event Pointer to store the created event handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup mouse + */ +GhosttyResult ghostty_mouse_event_new(const GhosttyAllocator *allocator, + GhosttyMouseEvent *event); + +/** + * Free a mouse event instance. + * + * @param event The mouse event handle to free (may be NULL) + * + * @ingroup mouse + */ +void ghostty_mouse_event_free(GhosttyMouseEvent event); + +/** + * Set the event action. + * + * @param event The event handle, must not be NULL + * @param action The action to set + * + * @ingroup mouse + */ +void ghostty_mouse_event_set_action(GhosttyMouseEvent event, + GhosttyMouseAction action); + +/** + * Get the event action. + * + * @param event The event handle, must not be NULL + * @return The event action + * + * @ingroup mouse + */ +GhosttyMouseAction ghostty_mouse_event_get_action(GhosttyMouseEvent event); + +/** + * Set the event button. + * + * This sets a concrete button identity for the event. + * To represent "no button" (for motion events), use + * ghostty_mouse_event_clear_button(). + * + * @param event The event handle, must not be NULL + * @param button The button to set + * + * @ingroup mouse + */ +void ghostty_mouse_event_set_button(GhosttyMouseEvent event, + GhosttyMouseButton button); + +/** + * Clear the event button. + * + * This sets the event button to "none". + * + * @param event The event handle, must not be NULL + * + * @ingroup mouse + */ +void ghostty_mouse_event_clear_button(GhosttyMouseEvent event); + +/** + * Get the event button. + * + * @param event The event handle, must not be NULL + * @param out_button Output pointer for the button value (may be NULL) + * @return true if a button is set, false if no button is set + * + * @ingroup mouse + */ +bool ghostty_mouse_event_get_button(GhosttyMouseEvent event, + GhosttyMouseButton *out_button); + +/** + * Set keyboard modifiers held during the event. + * + * @param event The event handle, must not be NULL + * @param mods Modifier bitmask + * + * @ingroup mouse + */ +void ghostty_mouse_event_set_mods(GhosttyMouseEvent event, + GhosttyMods mods); + +/** + * Get keyboard modifiers held during the event. + * + * @param event The event handle, must not be NULL + * @return Modifier bitmask + * + * @ingroup mouse + */ +GhosttyMods ghostty_mouse_event_get_mods(GhosttyMouseEvent event); + +/** + * Set the event position in surface-space pixels. + * + * @param event The event handle, must not be NULL + * @param position The position to set + * + * @ingroup mouse + */ +void ghostty_mouse_event_set_position(GhosttyMouseEvent event, + GhosttyMousePosition position); + +/** + * Get the event position in surface-space pixels. + * + * @param event The event handle, must not be NULL + * @return The current event position + * + * @ingroup mouse + */ +GhosttyMousePosition ghostty_mouse_event_get_position(GhosttyMouseEvent event); + +#endif /* GHOSTTY_VT_MOUSE_EVENT_H */ diff --git a/include/ghostty/vt/osc.h b/include/ghostty/vt/osc.h index f53077ab3..69f7d1e55 100644 --- a/include/ghostty/vt/osc.h +++ b/include/ghostty/vt/osc.h @@ -10,7 +10,7 @@ #include <stdbool.h> #include <stddef.h> #include <stdint.h> -#include <ghostty/vt/result.h> +#include <ghostty/vt/types.h> #include <ghostty/vt/allocator.h> /** diff --git a/include/ghostty/vt/paste.h b/include/ghostty/vt/paste.h index d90f303d4..b7212c801 100644 --- a/include/ghostty/vt/paste.h +++ b/include/ghostty/vt/paste.h @@ -18,26 +18,7 @@ * * ## Example * - * @code{.c} - * #include <stdio.h> - * #include <string.h> - * #include <ghostty/vt.h> - * - * int main() { - * const char* safe_data = "hello world"; - * const char* unsafe_data = "rm -rf /\n"; - * - * if (ghostty_paste_is_safe(safe_data, strlen(safe_data))) { - * printf("Safe to paste\n"); - * } - * - * if (!ghostty_paste_is_safe(unsafe_data, strlen(unsafe_data))) { - * printf("Unsafe! Contains newline\n"); - * } - * - * return 0; - * } - * @endcode + * @snippet c-vt-paste/src/main.c paste-safety * * @{ */ diff --git a/include/ghostty/vt/point.h b/include/ghostty/vt/point.h new file mode 100644 index 000000000..f152a5c46 --- /dev/null +++ b/include/ghostty/vt/point.h @@ -0,0 +1,88 @@ +/** + * @file point.h + * + * Terminal point types for referencing locations in the terminal grid. + */ + +#ifndef GHOSTTY_VT_POINT_H +#define GHOSTTY_VT_POINT_H + +#include <stdint.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup point Point + * + * Types for referencing x/y positions in the terminal grid under + * different coordinate systems (active area, viewport, full screen, + * scrollback history). + * + * @{ + */ + +/** + * A coordinate in the terminal grid. + * + * @ingroup point + */ +typedef struct { + /** Column (0-indexed). */ + uint16_t x; + + /** Row (0-indexed). May exceed page size for screen/history tags. */ + uint32_t y; +} GhosttyPointCoordinate; + +/** + * Point reference tag. + * + * Determines which coordinate system a point uses. + * + * @ingroup point + */ +typedef enum { + /** Active area where the cursor can move. */ + GHOSTTY_POINT_TAG_ACTIVE = 0, + + /** Visible viewport (changes when scrolled). */ + GHOSTTY_POINT_TAG_VIEWPORT = 1, + + /** Full screen including scrollback. */ + GHOSTTY_POINT_TAG_SCREEN = 2, + + /** Scrollback history only (before active area). */ + GHOSTTY_POINT_TAG_HISTORY = 3, +} GhosttyPointTag; + +/** + * Point value union. + * + * @ingroup point + */ +typedef union { + /** Coordinate (used for all tag variants). */ + GhosttyPointCoordinate coordinate; + + /** Padding for ABI compatibility. Do not use. */ + uint64_t _padding[2]; +} GhosttyPointValue; + +/** + * Tagged union for a point in the terminal grid. + * + * @ingroup point + */ +typedef struct { + GhosttyPointTag tag; + GhosttyPointValue value; +} GhosttyPoint; + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_POINT_H */ diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h new file mode 100644 index 000000000..0a300dde0 --- /dev/null +++ b/include/ghostty/vt/render.h @@ -0,0 +1,603 @@ +/** + * @file render.h + * + * Render state for creating high performance renderers. + */ + +#ifndef GHOSTTY_VT_RENDER_H +#define GHOSTTY_VT_RENDER_H + +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> +#include <ghostty/vt/allocator.h> +#include <ghostty/vt/color.h> +#include <ghostty/vt/terminal.h> +#include <ghostty/vt/types.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup render Render State + * + * Represents the state required to render a visible screen (a viewport) + * of a terminal instance. This is stateful and optimized for repeated + * updates from a single terminal instance and only updating dirty regions + * of the screen. + * + * The key design principle of this API is that it only needs read/write + * access to the terminal instance during the update call. This allows + * the render state to minimally impact terminal IO performance and also + * allows the renderer to be safely multi-threaded (as long as a lock is + * held during the update call to ensure exclusive access to the terminal + * instance). + * + * The basic usage of this API is: + * + * 1. Create an empty render state + * 2. Update it from a terminal instance whenever you need. + * 3. Read from the render state to get the data needed to draw your frame. + * + * ## Dirty Tracking + * + * Dirty tracking is a key feature of the render state that allows renderers + * to efficiently determine what parts of the screen have changed and only + * redraw changed regions. + * + * The render state API keeps track of dirty state at two independent layers: + * a global dirty state that indicates whether the entire frame is clean, + * partially dirty, or fully dirty, and a per-row dirty state that allows + * tracking which rows in a partially dirty frame have changed. + * + * The user of the render state API is expected to unset both of these. + * The `update` call does not unset dirty state, it only updates it. + * + * An extremely important detail: setting one dirty state doesn't unset + * the other. For example, setting the global dirty state to false does not + * reset the row-level dirty flags. So, the caller of the render state API must + * be careful to manage both layers of dirty state correctly. + * + * ## Examples + * + * ### Creating and updating render state + * @snippet c-vt-render/src/main.c render-state-update + * + * ### Checking dirty state + * @snippet c-vt-render/src/main.c render-dirty-check + * + * ### Reading colors + * @snippet c-vt-render/src/main.c render-colors + * + * ### Reading cursor state + * @snippet c-vt-render/src/main.c render-cursor + * + * ### Iterating rows and cells + * @snippet c-vt-render/src/main.c render-row-iterate + * + * ### Resetting dirty state after rendering + * @snippet c-vt-render/src/main.c render-dirty-reset + * + * @{ + */ + +/** + * Opaque handle to a render state instance. + * + * @ingroup render + */ +typedef struct GhosttyRenderState* GhosttyRenderState; + +/** + * Opaque handle to a render-state row iterator. + * + * @ingroup render + */ +typedef struct GhosttyRenderStateRowIterator* GhosttyRenderStateRowIterator; + +/** + * Opaque handle to render-state row cells. + * + * @ingroup render + */ +typedef struct GhosttyRenderStateRowCells* GhosttyRenderStateRowCells; + +/** + * Dirty state of a render state after update. + * + * @ingroup render + */ +typedef enum { + /** Not dirty at all; rendering can be skipped. */ + GHOSTTY_RENDER_STATE_DIRTY_FALSE = 0, + + /** Some rows changed; renderer can redraw incrementally. */ + GHOSTTY_RENDER_STATE_DIRTY_PARTIAL = 1, + + /** Global state changed; renderer should redraw everything. */ + GHOSTTY_RENDER_STATE_DIRTY_FULL = 2, +} GhosttyRenderStateDirty; + +/** + * Visual style of the cursor. + * + * @ingroup render + */ +typedef enum { + /** Bar cursor (DECSCUSR 5, 6). */ + GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BAR = 0, + + /** Block cursor (DECSCUSR 1, 2). */ + GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BLOCK = 1, + + /** Underline cursor (DECSCUSR 3, 4). */ + GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_UNDERLINE = 2, + + /** Hollow block cursor. */ + GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BLOCK_HOLLOW = 3, +} GhosttyRenderStateCursorVisualStyle; + +/** + * Queryable data kinds for ghostty_render_state_get(). + * + * @ingroup render + */ +typedef enum { + /** Invalid / sentinel value. */ + GHOSTTY_RENDER_STATE_DATA_INVALID = 0, + + /** Viewport width in cells (uint16_t). */ + GHOSTTY_RENDER_STATE_DATA_COLS = 1, + + /** Viewport height in cells (uint16_t). */ + GHOSTTY_RENDER_STATE_DATA_ROWS = 2, + + /** Current dirty state (GhosttyRenderStateDirty). */ + GHOSTTY_RENDER_STATE_DATA_DIRTY = 3, + + /** Populate a pre-allocated GhosttyRenderStateRowIterator with row data + * from the render state (GhosttyRenderStateRowIterator). Row data is + * only valid as long as the underlying render state is not updated. + * It is unsafe to use row data after updating the render state. + * */ + GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR = 4, + + /** Default/current background color (GhosttyColorRgb). */ + GHOSTTY_RENDER_STATE_DATA_COLOR_BACKGROUND = 5, + + /** Default/current foreground color (GhosttyColorRgb). */ + GHOSTTY_RENDER_STATE_DATA_COLOR_FOREGROUND = 6, + + /** Cursor color when explicitly set by terminal state (GhosttyColorRgb). + * Returns GHOSTTY_INVALID_VALUE if no explicit cursor color is set; + * use COLOR_CURSOR_HAS_VALUE to check first. */ + GHOSTTY_RENDER_STATE_DATA_COLOR_CURSOR = 7, + + /** Whether an explicit cursor color is set (bool). */ + GHOSTTY_RENDER_STATE_DATA_COLOR_CURSOR_HAS_VALUE = 8, + + /** The active 256-color palette (GhosttyColorRgb[256]). */ + GHOSTTY_RENDER_STATE_DATA_COLOR_PALETTE = 9, + + /** The visual style of the cursor (GhosttyRenderStateCursorVisualStyle). */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_VISUAL_STYLE = 10, + + /** Whether the cursor is visible based on terminal modes (bool). */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_VISIBLE = 11, + + /** Whether the cursor should blink based on terminal modes (bool). */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_BLINKING = 12, + + /** Whether the cursor is at a password input field (bool). */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_PASSWORD_INPUT = 13, + + /** Whether the cursor is visible within the viewport (bool). + * If false, the cursor viewport position values are undefined. */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_HAS_VALUE = 14, + + /** Cursor viewport x position in cells (uint16_t). + * Only valid when CURSOR_VIEWPORT_HAS_VALUE is true. */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_X = 15, + + /** Cursor viewport y position in cells (uint16_t). + * Only valid when CURSOR_VIEWPORT_HAS_VALUE is true. */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_Y = 16, + + /** Whether the cursor is on the tail of a wide character (bool). + * Only valid when CURSOR_VIEWPORT_HAS_VALUE is true. */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_WIDE_TAIL = 17, +} GhosttyRenderStateData; + +/** + * Settable options for ghostty_render_state_set(). + * + * @ingroup render + */ +typedef enum { + /** Set dirty state (GhosttyRenderStateDirty). */ + GHOSTTY_RENDER_STATE_OPTION_DIRTY = 0, +} GhosttyRenderStateOption; + +/** + * Queryable data kinds for ghostty_render_state_row_get(). + * + * @ingroup render + */ +typedef enum { + /** Invalid / sentinel value. */ + GHOSTTY_RENDER_STATE_ROW_DATA_INVALID = 0, + + /** Whether the current row is dirty (bool). */ + GHOSTTY_RENDER_STATE_ROW_DATA_DIRTY = 1, + + /** The raw row value (GhosttyRow). */ + GHOSTTY_RENDER_STATE_ROW_DATA_RAW = 2, + + /** Populate a pre-allocated GhosttyRenderStateRowCells with cell data for + * the current row (GhosttyRenderStateRowCells). Cell data is only + * valid as long as the underlying render state is not updated. + * It is unsafe to use cell data after updating the render state. */ + GHOSTTY_RENDER_STATE_ROW_DATA_CELLS = 3, +} GhosttyRenderStateRowData; + +/** + * Settable options for ghostty_render_state_row_set(). + * + * @ingroup render + */ +typedef enum { + /** Set dirty state for the current row (bool). */ + GHOSTTY_RENDER_STATE_ROW_OPTION_DIRTY = 0, +} GhosttyRenderStateRowOption; + +/** + * Render-state color information. + * + * This struct uses the sized-struct ABI pattern. Initialize with + * GHOSTTY_INIT_SIZED(GhosttyRenderStateColors) before calling + * ghostty_render_state_colors_get(). + * + * Example: + * @code + * GhosttyRenderStateColors colors = GHOSTTY_INIT_SIZED(GhosttyRenderStateColors); + * GhosttyResult result = ghostty_render_state_colors_get(state, &colors); + * @endcode + * + * @ingroup render + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyRenderStateColors). */ + size_t size; + + /** The default/current background color for the render state. */ + GhosttyColorRgb background; + + /** The default/current foreground color for the render state. */ + GhosttyColorRgb foreground; + + /** The cursor color when explicitly set by terminal state. */ + GhosttyColorRgb cursor; + + /** + * True when cursor contains a valid explicit cursor color value. + * If this is false, the cursor color should be ignored; it will + * contain undefined data. + * */ + bool cursor_has_value; + + /** The active 256-color palette for this render state. */ + GhosttyColorRgb palette[256]; +} GhosttyRenderStateColors; + +/** + * Create a new render state instance. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param state Pointer to store the created render state handle + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation + * failure + * + * @ingroup render + */ +GhosttyResult ghostty_render_state_new(const GhosttyAllocator* allocator, + GhosttyRenderState* state); + +/** + * Free a render state instance. + * + * Releases all resources associated with the render state. After this call, + * the render state handle becomes invalid. + * + * @param state The render state handle to free (may be NULL) + * + * @ingroup render + */ +void ghostty_render_state_free(GhosttyRenderState state); + +/** + * Update a render state instance from a terminal. + * + * This consumes terminal/screen dirty state in the same way as the internal + * render state update path. + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal The terminal handle to read from (NULL returns GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or + * `terminal` is NULL, GHOSTTY_OUT_OF_MEMORY if updating the state requires + * allocation and that allocation fails + * + * @ingroup render + */ +GhosttyResult ghostty_render_state_update(GhosttyRenderState state, + GhosttyTerminal terminal); + +/** + * Get a value from a render state. + * + * The `out` pointer must point to a value of the type corresponding to the + * requested data kind (see GhosttyRenderStateData). + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param data The data kind to query + * @param[out] out Pointer to receive the queried value + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` is + * NULL or `data` is not a recognized enum value + * + * @ingroup render + */ +GhosttyResult ghostty_render_state_get(GhosttyRenderState state, + GhosttyRenderStateData data, + void* out); + +/** + * Set an option on a render state. + * + * The `value` pointer must point to a value of the type corresponding to the + * requested option kind (see GhosttyRenderStateOption). + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param option The option to set + * @param[in] value Pointer to the value to set (NULL returns + * GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or + * `value` is NULL + * + * @ingroup render + */ +GhosttyResult ghostty_render_state_set(GhosttyRenderState state, + GhosttyRenderStateOption option, + const void* value); + +/** + * Get the current color information from a render state. + * + * This writes as many fields as fit in the caller-provided sized struct. + * `out_colors->size` must be set by the caller (typically via + * GHOSTTY_INIT_SIZED(GhosttyRenderStateColors)). + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param[out] out_colors Sized output struct to receive render-state colors + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or + * `out_colors` is NULL, or if `out_colors->size` is smaller than + * `sizeof(size_t)` + * + * @ingroup render + */ +GhosttyResult ghostty_render_state_colors_get(GhosttyRenderState state, + GhosttyRenderStateColors* out_colors); + +/** + * Create a new row iterator instance. + * + * All fields except the allocator are left undefined until populated + * via ghostty_render_state_get() with + * GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param[out] out_iterator On success, receives the created iterator handle + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation + * failure + * + * @ingroup render + */ +GhosttyResult ghostty_render_state_row_iterator_new( + const GhosttyAllocator* allocator, + GhosttyRenderStateRowIterator* out_iterator); + +/** + * Free a render-state row iterator. + * + * @param iterator The iterator handle to free (may be NULL) + * + * @ingroup render + */ +void ghostty_render_state_row_iterator_free(GhosttyRenderStateRowIterator iterator); + +/** + * Move a render-state row iterator to the next row. + * + * Returns true if the iterator moved successfully and row data is + * available to read at the new position. + * + * @param iterator The iterator handle to advance (may be NULL) + * @return true if advanced to the next row, false if `iterator` is + * NULL or if the iterator has reached the end + * + * @ingroup render + */ +bool ghostty_render_state_row_iterator_next(GhosttyRenderStateRowIterator iterator); + +/** + * Get a value from the current row in a render-state row iterator. + * + * The `out` pointer must point to a value of the type corresponding to the + * requested data kind (see GhosttyRenderStateRowData). + * Call ghostty_render_state_row_iterator_next() at least once before + * calling this function. + * + * @param iterator The iterator handle to query (NULL returns GHOSTTY_INVALID_VALUE) + * @param data The data kind to query + * @param[out] out Pointer to receive the queried value + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if + * `iterator` is NULL or the iterator is not positioned on a row + * + * @ingroup render + */ +GhosttyResult ghostty_render_state_row_get( + GhosttyRenderStateRowIterator iterator, + GhosttyRenderStateRowData data, + void* out); + +/** + * Set an option on the current row in a render-state row iterator. + * + * The `value` pointer must point to a value of the type corresponding to the + * requested option kind (see GhosttyRenderStateRowOption). + * Call ghostty_render_state_row_iterator_next() at least once before + * calling this function. + * + * @param iterator The iterator handle to update (NULL returns GHOSTTY_INVALID_VALUE) + * @param option The option to set + * @param[in] value Pointer to the value to set (NULL returns + * GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if + * `iterator` is NULL or the iterator is not positioned on a row + * + * @ingroup render + */ +GhosttyResult ghostty_render_state_row_set( + GhosttyRenderStateRowIterator iterator, + GhosttyRenderStateRowOption option, + const void* value); + +/** + * Create a new row cells instance. + * + * All fields except the allocator are left undefined until populated + * via ghostty_render_state_row_get() with + * GHOSTTY_RENDER_STATE_ROW_DATA_CELLS. + * + * You can reuse this value repeatedly with ghostty_render_state_row_get() to + * avoid allocating a new cells container for every row. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param[out] out_cells On success, receives the created row cells handle + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation + * failure + * + * @ingroup render + */ +GhosttyResult ghostty_render_state_row_cells_new( + const GhosttyAllocator* allocator, + GhosttyRenderStateRowCells* out_cells); + +/** + * Queryable data kinds for ghostty_render_state_row_cells_get(). + * + * @ingroup render + */ +typedef enum { + /** Invalid / sentinel value. */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_INVALID = 0, + + /** The raw cell value (GhosttyCell). */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW = 1, + + /** The style for the current cell (GhosttyStyle). */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE = 2, + + /** The total number of grapheme codepoints including the base codepoint + * (uint32_t). Returns 0 if the cell has no text. */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN = 3, + + /** Write grapheme codepoints into a caller-provided buffer (uint32_t*). + * The buffer must be at least graphemes_len elements. The base codepoint + * is written first, followed by any extra codepoints. */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF = 4, + + /** The resolved background color of the cell (GhosttyColorRgb). + * Flattens the three possible sources: content-tag bg_color_rgb, + * content-tag bg_color_palette (looked up in the palette), or the + * style's bg_color. Returns GHOSTTY_INVALID_VALUE if the cell has + * no background color, in which case the caller should use whatever + * default background color it wants (e.g. the terminal background). */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_COLOR = 5, + + /** The resolved foreground color of the cell (GhosttyColorRgb). + * Resolves palette indices through the palette. Bold color handling + * is not applied; the caller should handle bold styling separately. + * Returns GHOSTTY_INVALID_VALUE if the cell has no explicit foreground + * color, in which case the caller should use whatever default foreground + * color it wants (e.g. the terminal foreground). */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR = 6, +} GhosttyRenderStateRowCellsData; + +/** + * Move a render-state row cells iterator to the next cell. + * + * Returns true if the iterator moved successfully and cell data is + * available to read at the new position. + * + * @param cells The row cells handle to advance (may be NULL) + * @return true if advanced to the next cell, false if `cells` is + * NULL or if the iterator has reached the end + * + * @ingroup render + */ +bool ghostty_render_state_row_cells_next(GhosttyRenderStateRowCells cells); + +/** + * Move a render-state row cells iterator to a specific column. + * + * Positions the iterator at the given x (column) index so that + * subsequent reads return data for that cell. + * + * @param cells The row cells handle to reposition (NULL returns + * GHOSTTY_INVALID_VALUE) + * @param x The zero-based column index to select + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `cells` + * is NULL or `x` is out of range + * + * @ingroup render + */ +GhosttyResult ghostty_render_state_row_cells_select( + GhosttyRenderStateRowCells cells, uint16_t x); + +/** + * Get a value from the current cell in a render-state row cells iterator. + * + * The `out` pointer must point to a value of the type corresponding to the + * requested data kind (see GhosttyRenderStateRowCellsData). + * Call ghostty_render_state_row_cells_next() or + * ghostty_render_state_row_cells_select() at least once before + * calling this function. + * + * @param cells The row cells handle to query (NULL returns GHOSTTY_INVALID_VALUE) + * @param data The data kind to query + * @param[out] out Pointer to receive the queried value + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if + * `cells` is NULL or the iterator is not positioned on a cell + * + * @ingroup render + */ +GhosttyResult ghostty_render_state_row_cells_get( + GhosttyRenderStateRowCells cells, + GhosttyRenderStateRowCellsData data, + void* out); + +/** + * Free a row cells instance. + * + * @param cells The row cells handle to free (may be NULL) + * + * @ingroup render + */ +void ghostty_render_state_row_cells_free(GhosttyRenderStateRowCells cells); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_RENDER_H */ diff --git a/include/ghostty/vt/result.h b/include/ghostty/vt/result.h deleted file mode 100644 index 65938ee76..000000000 --- a/include/ghostty/vt/result.h +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @file result.h - * - * Result codes for libghostty-vt operations. - */ - -#ifndef GHOSTTY_VT_RESULT_H -#define GHOSTTY_VT_RESULT_H - -/** - * Result codes for libghostty-vt operations. - */ -typedef enum { - /** Operation completed successfully */ - GHOSTTY_SUCCESS = 0, - /** Operation failed due to failed allocation */ - GHOSTTY_OUT_OF_MEMORY = -1, - /** Operation failed due to invalid value */ - GHOSTTY_INVALID_VALUE = -2, -} GhosttyResult; - -#endif /* GHOSTTY_VT_RESULT_H */ diff --git a/include/ghostty/vt/screen.h b/include/ghostty/vt/screen.h new file mode 100644 index 000000000..f1f9dd1ca --- /dev/null +++ b/include/ghostty/vt/screen.h @@ -0,0 +1,340 @@ +/** + * @file screen.h + * + * Terminal screen cell and row types. + */ + +#ifndef GHOSTTY_VT_SCREEN_H +#define GHOSTTY_VT_SCREEN_H + +#include <stdbool.h> +#include <stdint.h> +#include <ghostty/vt/color.h> +#include <ghostty/vt/types.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup screen Screen + * + * Terminal screen cell and row types. + * + * These types represent the contents of a terminal screen. A GhosttyCell + * is a single grid cell and a GhosttyRow is a single row. Both are opaque + * values whose fields are accessed via ghostty_cell_get() and + * ghostty_row_get() respectively. + * + * @{ + */ + +/** + * Opaque cell value. + * + * Represents a single terminal cell. The internal layout is opaque and + * must be queried via ghostty_cell_get(). Obtain cell values from + * terminal query APIs. + * + * @ingroup screen + */ +typedef uint64_t GhosttyCell; + +/** + * Opaque row value. + * + * Represents a single terminal row. The internal layout is opaque and + * must be queried via ghostty_row_get(). Obtain row values from + * terminal query APIs. + * + * @ingroup screen + */ +typedef uint64_t GhosttyRow; + +/** + * Cell content tag. + * + * Describes what kind of content a cell holds. + * + * @ingroup screen + */ +typedef enum { + /** A single codepoint (may be zero for empty). */ + GHOSTTY_CELL_CONTENT_CODEPOINT = 0, + + /** A codepoint that is part of a multi-codepoint grapheme cluster. */ + GHOSTTY_CELL_CONTENT_CODEPOINT_GRAPHEME = 1, + + /** No text; background color from palette. */ + GHOSTTY_CELL_CONTENT_BG_COLOR_PALETTE = 2, + + /** No text; background color as RGB. */ + GHOSTTY_CELL_CONTENT_BG_COLOR_RGB = 3, +} GhosttyCellContentTag; + +/** + * Cell wide property. + * + * Describes the width behavior of a cell. + * + * @ingroup screen + */ +typedef enum { + /** Not a wide character, cell width 1. */ + GHOSTTY_CELL_WIDE_NARROW = 0, + + /** Wide character, cell width 2. */ + GHOSTTY_CELL_WIDE_WIDE = 1, + + /** Spacer after wide character. Do not render. */ + GHOSTTY_CELL_WIDE_SPACER_TAIL = 2, + + /** Spacer at end of soft-wrapped line for a wide character. */ + GHOSTTY_CELL_WIDE_SPACER_HEAD = 3, +} GhosttyCellWide; + +/** + * Semantic content type of a cell. + * + * Set by semantic prompt sequences (OSC 133) to distinguish between + * command output, user input, and shell prompt text. + * + * @ingroup screen + */ +typedef enum { + /** Regular output content, such as command output. */ + GHOSTTY_CELL_SEMANTIC_OUTPUT = 0, + + /** Content that is part of user input. */ + GHOSTTY_CELL_SEMANTIC_INPUT = 1, + + /** Content that is part of a shell prompt. */ + GHOSTTY_CELL_SEMANTIC_PROMPT = 2, +} GhosttyCellSemanticContent; + +/** + * Cell data types. + * + * These values specify what type of data to extract from a cell + * using `ghostty_cell_get`. + * + * @ingroup screen + */ +typedef enum { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_CELL_DATA_INVALID = 0, + + /** + * The codepoint of the cell (0 if empty or bg-color-only). + * + * Output type: uint32_t * + */ + GHOSTTY_CELL_DATA_CODEPOINT = 1, + + /** + * The content tag describing what kind of content is in the cell. + * + * Output type: GhosttyCellContentTag * + */ + GHOSTTY_CELL_DATA_CONTENT_TAG = 2, + + /** + * The wide property of the cell. + * + * Output type: GhosttyCellWide * + */ + GHOSTTY_CELL_DATA_WIDE = 3, + + /** + * Whether the cell has text to render. + * + * Output type: bool * + */ + GHOSTTY_CELL_DATA_HAS_TEXT = 4, + + /** + * Whether the cell has non-default styling. + * + * Output type: bool * + */ + GHOSTTY_CELL_DATA_HAS_STYLING = 5, + + /** + * The style ID for the cell (for use with style lookups). + * + * Output type: uint16_t * + */ + GHOSTTY_CELL_DATA_STYLE_ID = 6, + + /** + * Whether the cell has a hyperlink. + * + * Output type: bool * + */ + GHOSTTY_CELL_DATA_HAS_HYPERLINK = 7, + + /** + * Whether the cell is protected. + * + * Output type: bool * + */ + GHOSTTY_CELL_DATA_PROTECTED = 8, + + /** + * The semantic content type of the cell (from OSC 133). + * + * Output type: GhosttyCellSemanticContent * + */ + GHOSTTY_CELL_DATA_SEMANTIC_CONTENT = 9, + + /** + * The palette index for the cell's background color. + * Only valid when content_tag is GHOSTTY_CELL_CONTENT_BG_COLOR_PALETTE. + * + * Output type: GhosttyColorPaletteIndex * + */ + GHOSTTY_CELL_DATA_COLOR_PALETTE = 10, + + /** + * The RGB value for the cell's background color. + * Only valid when content_tag is GHOSTTY_CELL_CONTENT_BG_COLOR_RGB. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_CELL_DATA_COLOR_RGB = 11, +} GhosttyCellData; + +/** + * Row semantic prompt state. + * + * Indicates whether any cells in a row are part of a shell prompt, + * as reported by OSC 133 sequences. + * + * @ingroup screen + */ +typedef enum { + /** No prompt cells in this row. */ + GHOSTTY_ROW_SEMANTIC_NONE = 0, + + /** Prompt cells exist and this is a primary prompt line. */ + GHOSTTY_ROW_SEMANTIC_PROMPT = 1, + + /** Prompt cells exist and this is a continuation line. */ + GHOSTTY_ROW_SEMANTIC_PROMPT_CONTINUATION = 2, +} GhosttyRowSemanticPrompt; + +/** + * Row data types. + * + * These values specify what type of data to extract from a row + * using `ghostty_row_get`. + * + * @ingroup screen + */ +typedef enum { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_ROW_DATA_INVALID = 0, + + /** + * Whether this row is soft-wrapped. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_WRAP = 1, + + /** + * Whether this row is a continuation of a soft-wrapped row. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_WRAP_CONTINUATION = 2, + + /** + * Whether any cells in this row have grapheme clusters. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_GRAPHEME = 3, + + /** + * Whether any cells in this row have styling (may have false positives). + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_STYLED = 4, + + /** + * Whether any cells in this row have hyperlinks (may have false positives). + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_HYPERLINK = 5, + + /** + * The semantic prompt state of this row. + * + * Output type: GhosttyRowSemanticPrompt * + */ + GHOSTTY_ROW_DATA_SEMANTIC_PROMPT = 6, + + /** + * Whether this row contains a Kitty virtual placeholder. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_KITTY_VIRTUAL_PLACEHOLDER = 7, + + /** + * Whether this row is dirty and requires a redraw. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_DIRTY = 8, +} GhosttyRowData; + +/** + * Get data from a cell. + * + * Extracts typed data from the given cell based on the specified + * data type. The output pointer must be of the appropriate type for the + * requested data kind. Valid data types and output types are documented + * in the `GhosttyCellData` enum. + * + * @param cell The cell value + * @param data The type of data to extract + * @param out Pointer to store the extracted data (type depends on data parameter) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the + * data type is invalid + * + * @ingroup screen + */ +GhosttyResult ghostty_cell_get(GhosttyCell cell, + GhosttyCellData data, + void *out); + +/** + * Get data from a row. + * + * Extracts typed data from the given row based on the specified + * data type. The output pointer must be of the appropriate type for the + * requested data kind. Valid data types and output types are documented + * in the `GhosttyRowData` enum. + * + * @param row The row value + * @param data The type of data to extract + * @param out Pointer to store the extracted data (type depends on data parameter) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the + * data type is invalid + * + * @ingroup screen + */ +GhosttyResult ghostty_row_get(GhosttyRow row, + GhosttyRowData data, + void *out); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_SCREEN_H */ diff --git a/include/ghostty/vt/sgr.h b/include/ghostty/vt/sgr.h index 0c1afc309..3b190a6b8 100644 --- a/include/ghostty/vt/sgr.h +++ b/include/ghostty/vt/sgr.h @@ -31,49 +31,14 @@ * * ## Example * - * @code{.c} - * #include <assert.h> - * #include <stdio.h> - * #include <ghostty/vt.h> - * - * int main() { - * // Create parser - * GhosttySgrParser parser; - * GhosttyResult result = ghostty_sgr_new(NULL, &parser); - * assert(result == GHOSTTY_SUCCESS); - * - * // Parse "bold, red foreground" sequence: ESC[1;31m - * uint16_t params[] = {1, 31}; - * result = ghostty_sgr_set_params(parser, params, NULL, 2); - * assert(result == GHOSTTY_SUCCESS); - * - * // Iterate through attributes - * GhosttySgrAttribute attr; - * while (ghostty_sgr_next(parser, &attr)) { - * switch (attr.tag) { - * case GHOSTTY_SGR_ATTR_BOLD: - * printf("Bold enabled\n"); - * break; - * case GHOSTTY_SGR_ATTR_FG_8: - * printf("Foreground color: %d\n", attr.value.fg_8); - * break; - * default: - * break; - * } - * } - * - * // Cleanup - * ghostty_sgr_free(parser); - * return 0; - * } - * @endcode + * @snippet c-vt-sgr/src/main.c sgr-basic * * @{ */ #include <ghostty/vt/allocator.h> #include <ghostty/vt/color.h> -#include <ghostty/vt/result.h> +#include <ghostty/vt/types.h> #include <stdbool.h> #include <stddef.h> #include <stdint.h> @@ -109,30 +74,29 @@ typedef enum { GHOSTTY_SGR_ATTR_RESET_ITALIC = 5, GHOSTTY_SGR_ATTR_FAINT = 6, GHOSTTY_SGR_ATTR_UNDERLINE = 7, - GHOSTTY_SGR_ATTR_RESET_UNDERLINE = 8, - GHOSTTY_SGR_ATTR_UNDERLINE_COLOR = 9, - GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 = 10, - GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR = 11, - GHOSTTY_SGR_ATTR_OVERLINE = 12, - GHOSTTY_SGR_ATTR_RESET_OVERLINE = 13, - GHOSTTY_SGR_ATTR_BLINK = 14, - GHOSTTY_SGR_ATTR_RESET_BLINK = 15, - GHOSTTY_SGR_ATTR_INVERSE = 16, - GHOSTTY_SGR_ATTR_RESET_INVERSE = 17, - GHOSTTY_SGR_ATTR_INVISIBLE = 18, - GHOSTTY_SGR_ATTR_RESET_INVISIBLE = 19, - GHOSTTY_SGR_ATTR_STRIKETHROUGH = 20, - GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH = 21, - GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG = 22, - GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG = 23, - GHOSTTY_SGR_ATTR_BG_8 = 24, - GHOSTTY_SGR_ATTR_FG_8 = 25, - GHOSTTY_SGR_ATTR_RESET_FG = 26, - GHOSTTY_SGR_ATTR_RESET_BG = 27, - GHOSTTY_SGR_ATTR_BRIGHT_BG_8 = 28, - GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 29, - GHOSTTY_SGR_ATTR_BG_256 = 30, - GHOSTTY_SGR_ATTR_FG_256 = 31, + GHOSTTY_SGR_ATTR_UNDERLINE_COLOR = 8, + GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 = 9, + GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR = 10, + GHOSTTY_SGR_ATTR_OVERLINE = 11, + GHOSTTY_SGR_ATTR_RESET_OVERLINE = 12, + GHOSTTY_SGR_ATTR_BLINK = 13, + GHOSTTY_SGR_ATTR_RESET_BLINK = 14, + GHOSTTY_SGR_ATTR_INVERSE = 15, + GHOSTTY_SGR_ATTR_RESET_INVERSE = 16, + GHOSTTY_SGR_ATTR_INVISIBLE = 17, + GHOSTTY_SGR_ATTR_RESET_INVISIBLE = 18, + GHOSTTY_SGR_ATTR_STRIKETHROUGH = 19, + GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH = 20, + GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG = 21, + GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG = 22, + GHOSTTY_SGR_ATTR_BG_8 = 23, + GHOSTTY_SGR_ATTR_FG_8 = 24, + GHOSTTY_SGR_ATTR_RESET_FG = 25, + GHOSTTY_SGR_ATTR_RESET_BG = 26, + GHOSTTY_SGR_ATTR_BRIGHT_BG_8 = 27, + GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 28, + GHOSTTY_SGR_ATTR_BG_256 = 29, + GHOSTTY_SGR_ATTR_FG_256 = 30, } GhosttySgrAttributeTag; /** diff --git a/include/ghostty/vt/size_report.h b/include/ghostty/vt/size_report.h new file mode 100644 index 000000000..b2eb08995 --- /dev/null +++ b/include/ghostty/vt/size_report.h @@ -0,0 +1,100 @@ +/** + * @file size_report.h + * + * Size report encoding - encode terminal size reports into escape sequences. + */ + +#ifndef GHOSTTY_VT_SIZE_REPORT_H +#define GHOSTTY_VT_SIZE_REPORT_H + +/** @defgroup size_report Size Report Encoding + * + * Utilities for encoding terminal size reports into escape sequences, + * supporting in-band size reports (mode 2048) and XTWINOPS responses + * (CSI 14 t, CSI 16 t, CSI 18 t). + * + * ## Basic Usage + * + * Use ghostty_size_report_encode() to encode a size report into a + * caller-provided buffer. If the buffer is too small, the function + * returns GHOSTTY_OUT_OF_SPACE and sets the required size in the + * output parameter. + * + * ## Example + * + * @snippet c-vt-size-report/src/main.c size-report-encode + * + * @{ + */ + +#include <stddef.h> +#include <stdint.h> +#include <ghostty/vt/types.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Size report style. + * + * Determines the output format for the terminal size report. + */ +typedef enum { + /** In-band size report (mode 2048): ESC [ 48 ; rows ; cols ; height ; width t */ + GHOSTTY_SIZE_REPORT_MODE_2048 = 0, + /** XTWINOPS text area size in pixels: ESC [ 4 ; height ; width t */ + GHOSTTY_SIZE_REPORT_CSI_14_T = 1, + /** XTWINOPS cell size in pixels: ESC [ 6 ; height ; width t */ + GHOSTTY_SIZE_REPORT_CSI_16_T = 2, + /** XTWINOPS text area size in characters: ESC [ 8 ; rows ; cols t */ + GHOSTTY_SIZE_REPORT_CSI_18_T = 3, +} GhosttySizeReportStyle; + +/** + * Terminal size information for encoding size reports. + */ +typedef struct { + /** Terminal row count in cells. */ + uint16_t rows; + /** Terminal column count in cells. */ + uint16_t columns; + /** Width of a single terminal cell in pixels. */ + uint32_t cell_width; + /** Height of a single terminal cell in pixels. */ + uint32_t cell_height; +} GhosttySizeReportSize; + +/** + * Encode a terminal size report into an escape sequence. + * + * Encodes a size report in the format specified by @p style into the + * provided buffer. + * + * If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE + * and writes the required buffer size to @p out_written. The caller can + * then retry with a sufficiently sized buffer. + * + * @param style The size report format to encode + * @param size Terminal size information + * @param buf Output buffer to write the encoded sequence into (may be NULL) + * @param buf_len Size of the output buffer in bytes + * @param[out] out_written On success, the number of bytes written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer + * is too small + */ +GhosttyResult ghostty_size_report_encode( + GhosttySizeReportStyle style, + GhosttySizeReportSize size, + char* buf, + size_t buf_len, + size_t* out_written); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_SIZE_REPORT_H */ diff --git a/include/ghostty/vt/style.h b/include/ghostty/vt/style.h new file mode 100644 index 000000000..ac5cd2ad6 --- /dev/null +++ b/include/ghostty/vt/style.h @@ -0,0 +1,138 @@ +/** + * @file style.h + * + * Terminal cell style types. + */ + +#ifndef GHOSTTY_VT_STYLE_H +#define GHOSTTY_VT_STYLE_H + +#include <ghostty/vt/color.h> +#include <ghostty/vt/types.h> +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup style Style + * + * Terminal cell style attributes. + * + * A style describes the visual attributes of a terminal cell, including + * foreground, background, and underline colors, as well as flags for + * bold, italic, underline, and other text decorations. + * + * @{ + */ + +/** + * Style identifier type. + * + * Used to look up the full style from a grid reference. + * Obtain this from a cell via GHOSTTY_CELL_DATA_STYLE_ID. + * + * @ingroup style + */ +typedef uint16_t GhosttyStyleId; + +/** + * Style color tags. + * + * These values identify the type of color in a style color. + * Use the tag to determine which field in the color value union to access. + * + * @ingroup style + */ +typedef enum { + GHOSTTY_STYLE_COLOR_NONE = 0, + GHOSTTY_STYLE_COLOR_PALETTE = 1, + GHOSTTY_STYLE_COLOR_RGB = 2, +} GhosttyStyleColorTag; + +/** + * Style color value union. + * + * Use the tag to determine which field is active. + * + * @ingroup style + */ +typedef union { + GhosttyColorPaletteIndex palette; + GhosttyColorRgb rgb; + uint64_t _padding; +} GhosttyStyleColorValue; + +/** + * Style color (tagged union). + * + * A color used in a style attribute. Can be unset (none), a palette + * index, or a direct RGB value. + * + * @ingroup style + */ +typedef struct { + GhosttyStyleColorTag tag; + GhosttyStyleColorValue value; +} GhosttyStyleColor; + +/** + * Terminal cell style. + * + * Describes the complete visual style for a terminal cell, including + * foreground, background, and underline colors, as well as text + * decoration flags. The underline field uses the same values as + * GhosttySgrUnderline. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * + * @ingroup style + */ +typedef struct { + size_t size; + GhosttyStyleColor fg_color; + GhosttyStyleColor bg_color; + GhosttyStyleColor underline_color; + bool bold; + bool italic; + bool faint; + bool blink; + bool inverse; + bool invisible; + bool strikethrough; + bool overline; + int underline; /**< One of GHOSTTY_SGR_UNDERLINE_* values */ +} GhosttyStyle; + +/** + * Get the default style. + * + * Initializes the style to the default values (no colors, no flags). + * + * @param style Pointer to the style to initialize + * + * @ingroup style + */ +void ghostty_style_default(GhosttyStyle* style); + +/** + * Check if a style is the default style. + * + * Returns true if all colors are unset and all flags are off. + * + * @param style Pointer to the style to check + * @return true if the style is the default style + * + * @ingroup style + */ +bool ghostty_style_is_default(const GhosttyStyle* style); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_STYLE_H */ diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h new file mode 100644 index 000000000..e23aea93b --- /dev/null +++ b/include/ghostty/vt/terminal.h @@ -0,0 +1,815 @@ +/** + * @file terminal.h + * + * Complete terminal emulator state and rendering. + */ + +#ifndef GHOSTTY_VT_TERMINAL_H +#define GHOSTTY_VT_TERMINAL_H + +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> +#include <ghostty/vt/types.h> +#include <ghostty/vt/allocator.h> +#include <ghostty/vt/device.h> +#include <ghostty/vt/modes.h> +#include <ghostty/vt/size_report.h> +#include <ghostty/vt/grid_ref.h> +#include <ghostty/vt/screen.h> +#include <ghostty/vt/point.h> +#include <ghostty/vt/style.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup terminal Terminal + * + * Complete terminal emulator state and rendering. + * + * A terminal instance manages the full emulator state including the screen, + * scrollback, cursor, styles, modes, and VT stream processing. + * + * Once a terminal session is up and running, you can configure a key encoder + * to write keyboard input via ghostty_key_encoder_setopt_from_terminal(). + * + * ## Effects + * + * By default, the terminal sequence processing with ghostty_terminal_vt_write() + * only process 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. + * + * Each effect is registered with ghostty_terminal_set() using the + * corresponding `GhosttyTerminalOption` identifier. A `NULL` value + * pointer clears the callback and disables the effect. + * + * A userdata pointer can be attached via `GHOSTTY_TERMINAL_OPT_USERDATA` + * and is passed to every callback, allowing callers to route events + * 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). + * And callbacks must be very careful to not block for too long or perform + * expensive operations, since they are blocking further IO processing. + * + * The available effects are: + * + * | Option | Callback Type | Trigger | + * |-----------------------------------------|-----------------------------------|-------------------------------------------| + * | `GHOSTTY_TERMINAL_OPT_WRITE_PTY` | `GhosttyTerminalWritePtyFn` | Query responses written back to the pty | + * | `GHOSTTY_TERMINAL_OPT_BELL` | `GhosttyTerminalBellFn` | BEL character (0x07) | + * | `GHOSTTY_TERMINAL_OPT_TITLE_CHANGED` | `GhosttyTerminalTitleChangedFn` | Title change via OSC 0 / OSC 2 | + * | `GHOSTTY_TERMINAL_OPT_ENQUIRY` | `GhosttyTerminalEnquiryFn` | ENQ character (0x05) | + * | `GHOSTTY_TERMINAL_OPT_XTVERSION` | `GhosttyTerminalXtversionFn` | XTVERSION query (CSI > q) | + * | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS size query (CSI 14/16/18 t) | + * | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` | `GhosttyTerminalColorSchemeFn` | Color scheme query (CSI ? 996 n) | + * | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)| + * + * ### Defining a write_pty callback + * @snippet c-vt-effects/src/main.c effects-write-pty + * + * ### Defining a bell callback + * @snippet c-vt-effects/src/main.c effects-bell + * + * ### Defining a title_changed callback + * @snippet c-vt-effects/src/main.c effects-title-changed + * + * ### Registering effects and processing VT data + * @snippet c-vt-effects/src/main.c effects-register + * + * @{ + */ + +/** + * Opaque handle to a terminal instance. + * + * @ingroup terminal + */ +typedef struct GhosttyTerminal* GhosttyTerminal; + +/** + * Terminal initialization options. + * + * @ingroup terminal + */ +typedef struct { + /** Terminal width in cells. Must be greater than zero. */ + uint16_t cols; + + /** Terminal height in cells. Must be greater than zero. */ + uint16_t rows; + + /** Maximum number of lines to keep in scrollback history. */ + size_t max_scrollback; + + // TODO: Consider ABI compatibility implications of this struct. + // We may want to artificially pad it significantly to support + // future options. +} GhosttyTerminalOptions; + +/** + * Scroll viewport behavior tag. + * + * @ingroup terminal + */ +typedef enum { + /** Scroll to the top of the scrollback. */ + GHOSTTY_SCROLL_VIEWPORT_TOP, + + /** Scroll to the bottom (active area). */ + GHOSTTY_SCROLL_VIEWPORT_BOTTOM, + + /** Scroll by a delta amount (up is negative). */ + GHOSTTY_SCROLL_VIEWPORT_DELTA, +} GhosttyTerminalScrollViewportTag; + +/** + * Scroll viewport value. + * + * @ingroup terminal + */ +typedef union { + /** Scroll delta (only used with GHOSTTY_SCROLL_VIEWPORT_DELTA). Up is negative. */ + intptr_t delta; + + /** Padding for ABI compatibility. Do not use. */ + uint64_t _padding[2]; +} GhosttyTerminalScrollViewportValue; + +/** + * Tagged union for scroll viewport behavior. + * + * @ingroup terminal + */ +typedef struct { + GhosttyTerminalScrollViewportTag tag; + GhosttyTerminalScrollViewportValue value; +} GhosttyTerminalScrollViewport; + +/** + * Terminal screen identifier. + * + * Identifies which screen buffer is active in the terminal. + * + * @ingroup terminal + */ +typedef enum { + /** The primary (normal) screen. */ + GHOSTTY_TERMINAL_SCREEN_PRIMARY = 0, + + /** The alternate screen. */ + GHOSTTY_TERMINAL_SCREEN_ALTERNATE = 1, +} GhosttyTerminalScreen; + +/** + * Scrollbar state for the terminal viewport. + * + * Represents the scrollable area dimensions needed to render a scrollbar. + * + * @ingroup terminal + */ +typedef struct { + /** Total size of the scrollable area in rows. */ + uint64_t total; + + /** Offset into the total area that the viewport is at. */ + uint64_t offset; + + /** Length of the visible area in rows. */ + uint64_t len; +} GhosttyTerminalScrollbar; + +/** + * Callback function type for bell. + * + * Called when the terminal receives a BEL character (0x07). + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalBellFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Callback function type for color scheme queries (CSI ? 996 n). + * + * Called when the terminal receives a color scheme device status report + * query. Return true and fill *out_scheme with the current color scheme, + * or return false to silently ignore the query. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param[out] out_scheme Pointer to store the current color scheme + * @return true if the color scheme was filled, false to ignore the query + * + * @ingroup terminal + */ +typedef bool (*GhosttyTerminalColorSchemeFn)(GhosttyTerminal terminal, + void* userdata, + GhosttyColorScheme* out_scheme); + +/** + * Callback function type for device attributes queries (DA1/DA2/DA3). + * + * Called when the terminal receives a device attributes query (CSI c, + * CSI > c, or CSI = c). Return true and fill *out_attrs with the + * response data, or return false to silently ignore the query. + * + * The terminal uses whichever sub-struct (primary, secondary, tertiary) + * matches the request type, but all three should be filled for simplicity. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param[out] out_attrs Pointer to store the device attributes response + * @return true if attributes were filled, false to ignore the query + * + * @ingroup terminal + */ +typedef bool (*GhosttyTerminalDeviceAttributesFn)(GhosttyTerminal terminal, + void* userdata, + GhosttyDeviceAttributes* out_attrs); + +/** + * Callback function type for enquiry (ENQ, 0x05). + * + * Called when the terminal receives an ENQ character. Return the + * response bytes as a GhosttyString. The memory must remain valid + * until the callback returns. Return a zero-length string to send + * no response. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @return The response bytes to write back to the pty + * + * @ingroup terminal + */ +typedef GhosttyString (*GhosttyTerminalEnquiryFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Callback function type for size queries (XTWINOPS). + * + * Called in response to XTWINOPS size queries (CSI 14/16/18 t). + * Return true and fill *out_size with the current terminal geometry, + * or return false to silently ignore the query. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param[out] out_size Pointer to store the terminal size information + * @return true if size was filled, false to ignore the query + * + * @ingroup terminal + */ +typedef bool (*GhosttyTerminalSizeFn)(GhosttyTerminal terminal, + void* userdata, + GhosttySizeReportSize* out_size); + +/** + * Callback function type for title_changed. + * + * Called when the terminal title changes via escape sequences + * (e.g. OSC 0 or OSC 2). The new title can be queried from the + * terminal after the callback returns. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalTitleChangedFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Callback function type for write_pty. + * + * Called when the terminal needs to write data back to the pty, for + * example in response to a device status report or mode query. The + * data is only valid for the duration of the call; callers must copy + * it if it needs to persist. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param data Pointer to the response bytes + * @param len Length of the response in bytes + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalWritePtyFn)(GhosttyTerminal terminal, + void* userdata, + const uint8_t* data, + size_t len); + +/** + * Callback function type for XTVERSION. + * + * Called when the terminal receives an XTVERSION query (CSI > q). + * Return the version string (e.g. "myterm 1.0") as a GhosttyString. + * The memory must remain valid until the callback returns. Return a + * zero-length string to report the default "libghostty" version. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @return The version string to report + * + * @ingroup terminal + */ +typedef GhosttyString (*GhosttyTerminalXtversionFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Terminal option identifiers. + * + * These values are used with ghostty_terminal_set() to configure + * terminal callbacks and associated state. + * + * @ingroup terminal + */ +typedef enum { + /** + * Opaque userdata pointer passed to all callbacks. + * + * Input type: void* + */ + GHOSTTY_TERMINAL_OPT_USERDATA = 0, + + /** + * Callback invoked when the terminal needs to write data back + * to the pty (e.g. in response to a DECRQM query or device + * status report). Set to NULL to ignore such sequences. + * + * Input type: GhosttyTerminalWritePtyFn + */ + GHOSTTY_TERMINAL_OPT_WRITE_PTY = 1, + + /** + * Callback invoked when the terminal receives a BEL character + * (0x07). Set to NULL to ignore bell events. + * + * Input type: GhosttyTerminalBellFn + */ + GHOSTTY_TERMINAL_OPT_BELL = 2, + + /** + * Callback invoked when the terminal receives an ENQ character + * (0x05). Set to NULL to send no response. + * + * Input type: GhosttyTerminalEnquiryFn + */ + GHOSTTY_TERMINAL_OPT_ENQUIRY = 3, + + /** + * Callback invoked when the terminal receives an XTVERSION query + * (CSI > q). Set to NULL to report the default "libghostty" string. + * + * Input type: GhosttyTerminalXtversionFn + */ + GHOSTTY_TERMINAL_OPT_XTVERSION = 4, + + /** + * Callback invoked when the terminal title changes via escape + * sequences (e.g. OSC 0 or OSC 2). Set to NULL to ignore title + * change events. + * + * Input type: GhosttyTerminalTitleChangedFn + */ + GHOSTTY_TERMINAL_OPT_TITLE_CHANGED = 5, + + /** + * Callback invoked in response to XTWINOPS size queries + * (CSI 14/16/18 t). Set to NULL to silently ignore size queries. + * + * Input type: GhosttyTerminalSizeFn + */ + GHOSTTY_TERMINAL_OPT_SIZE = 6, + + /** + * Callback invoked in response to a color scheme device status + * report query (CSI ? 996 n). Return true and fill the out pointer + * to report the current scheme, or return false to silently ignore. + * Set to NULL to ignore color scheme queries. + * + * Input type: GhosttyTerminalColorSchemeFn + */ + GHOSTTY_TERMINAL_OPT_COLOR_SCHEME = 7, + + /** + * Callback invoked in response to a device attributes query + * (CSI c, CSI > c, or CSI = c). Return true and fill the out + * pointer with response data, or return false to silently ignore. + * Set to NULL to ignore device attributes queries. + * + * Input type: GhosttyTerminalDeviceAttributesFn + */ + GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES = 8, + + /** + * Set the terminal title manually. + * + * The string data is copied into the terminal. A NULL value pointer + * clears the title (equivalent to setting an empty string). + * + * Input type: GhosttyString* + */ + GHOSTTY_TERMINAL_OPT_TITLE = 9, + + /** + * Set the terminal working directory manually. + * + * The string data is copied into the terminal. A NULL value pointer + * clears the pwd (equivalent to setting an empty string). + * + * Input type: GhosttyString* + */ + GHOSTTY_TERMINAL_OPT_PWD = 10, +} GhosttyTerminalOption; + +/** + * Terminal data types. + * + * These values specify what type of data to extract from a terminal + * using `ghostty_terminal_get`. + * + * @ingroup terminal + */ +typedef enum { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_TERMINAL_DATA_INVALID = 0, + + /** + * Terminal width in cells. + * + * Output type: uint16_t * + */ + GHOSTTY_TERMINAL_DATA_COLS = 1, + + /** + * Terminal height in cells. + * + * Output type: uint16_t * + */ + GHOSTTY_TERMINAL_DATA_ROWS = 2, + + /** + * Cursor column position (0-indexed). + * + * Output type: uint16_t * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_X = 3, + + /** + * Cursor row position within the active area (0-indexed). + * + * Output type: uint16_t * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_Y = 4, + + /** + * Whether the cursor has a pending wrap (next print will soft-wrap). + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_PENDING_WRAP = 5, + + /** + * The currently active screen. + * + * Output type: GhosttyTerminalScreen * + */ + GHOSTTY_TERMINAL_DATA_ACTIVE_SCREEN = 6, + + /** + * Whether the cursor is visible (DEC mode 25). + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE = 7, + + /** + * Current Kitty keyboard protocol flags. + * + * Output type: GhosttyKittyKeyFlags * (uint8_t *) + */ + GHOSTTY_TERMINAL_DATA_KITTY_KEYBOARD_FLAGS = 8, + + /** + * Scrollbar state for the terminal viewport. + * + * This may be expensive to calculate depending on where the viewport + * is (arbitrary pins are expensive). The caller should take care to only + * call this as needed and not too frequently. + * + * Output type: GhosttyTerminalScrollbar * + */ + GHOSTTY_TERMINAL_DATA_SCROLLBAR = 9, + + /** + * The current SGR style of the cursor. + * + * This is the style that will be applied to newly printed characters. + * + * Output type: GhosttyStyle * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_STYLE = 10, + + /** + * Whether any mouse tracking mode is active. + * + * Returns true if any of the mouse tracking modes (X10, normal, button, + * or any-event) are enabled. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_MOUSE_TRACKING = 11, + + /** + * 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. + * + * Output type: GhosttyString * + */ + GHOSTTY_TERMINAL_DATA_TITLE = 12, + + /** + * 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. + * + * Output type: GhosttyString * + */ + GHOSTTY_TERMINAL_DATA_PWD = 13, + + /** + * The total number of rows in the active screen including scrollback. + * + * Output type: size_t * + */ + GHOSTTY_TERMINAL_DATA_TOTAL_ROWS = 14, + + /** + * The number of scrollback rows (total rows minus viewport rows). + * + * Output type: size_t * + */ + GHOSTTY_TERMINAL_DATA_SCROLLBACK_ROWS = 15, + + /** + * The total width of the terminal in pixels. + * + * This is cols * cell_width_px as set by ghostty_terminal_resize(). + * + * Output type: uint32_t * + */ + GHOSTTY_TERMINAL_DATA_WIDTH_PX = 16, + + /** + * The total height of the terminal in pixels. + * + * This is rows * cell_height_px as set by ghostty_terminal_resize(). + * + * Output type: uint32_t * + */ + GHOSTTY_TERMINAL_DATA_HEIGHT_PX = 17, +} GhosttyTerminalData; + +/** + * Create a new terminal instance. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param terminal Pointer to store the created terminal handle + * @param options Terminal initialization options + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup terminal + */ +GhosttyResult ghostty_terminal_new(const GhosttyAllocator* allocator, + GhosttyTerminal* terminal, + GhosttyTerminalOptions options); + +/** + * Free a terminal instance. + * + * Releases all resources associated with the terminal. After this call, + * the terminal handle becomes invalid and must not be used. + * + * @param terminal The terminal handle to free (may be NULL) + * + * @ingroup terminal + */ +void ghostty_terminal_free(GhosttyTerminal terminal); + +/** + * Perform a full reset of the terminal (RIS). + * + * Resets all terminal state back to its initial configuration, including + * modes, scrollback, scrolling region, and screen contents. The terminal + * dimensions are preserved. + * + * @param terminal The terminal handle (may be NULL, in which case this is a no-op) + * + * @ingroup terminal + */ +void ghostty_terminal_reset(GhosttyTerminal terminal); + +/** + * Resize the terminal to the given dimensions. + * + * Changes the number of columns and rows in the terminal. The primary + * screen will reflow content if wraparound mode is enabled; the alternate + * screen does not reflow. If the dimensions are unchanged, this is a no-op. + * + * This also updates the terminal's pixel dimensions (used for image + * protocols and size reports), disables synchronized output mode (allowed + * by the spec so that resize results are shown immediately), and sends an + * in-band size report if mode 2048 is enabled. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param cols New width in cells (must be greater than zero) + * @param rows New height in cells (must be greater than zero) + * @param cell_width_px Width of a single cell in pixels + * @param cell_height_px Height of a single cell in pixels + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup terminal + */ +GhosttyResult ghostty_terminal_resize(GhosttyTerminal terminal, + uint16_t cols, + uint16_t rows, + uint32_t cell_width_px, + uint32_t cell_height_px); + +/** + * Set an option on the terminal. + * + * Configures terminal callbacks and associated state such as the + * write_pty callback and userdata pointer. The value is passed + * directly for pointer types (callbacks, userdata) or as a pointer + * to the value for non-pointer types (e.g. GhosttyString*). + * NULL clears the option to its default. + * + * Callbacks are invoked synchronously during ghostty_terminal_vt_write(). + * Callbacks must not call ghostty_terminal_vt_write() 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 + * @param value Pointer to the value to set (type depends on the option), + * or NULL to clear the option + * + * @ingroup terminal + */ +GhosttyResult ghostty_terminal_set(GhosttyTerminal terminal, + GhosttyTerminalOption option, + const void* value); + +/** + * Write VT-encoded data to the terminal for processing. + * + * Feeds raw bytes through the terminal's VT stream parser, updating + * terminal state accordingly. By default, sequences that require output + * (queries, device status reports) are silently ignored. Use + * ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_WRITE_PTY to install + * a callback that receives response data. + * + * This never fails. Any erroneous input or errors in processing the + * input are logged internally but do not cause this function to fail + * because this input is assumed to be untrusted and from an external + * source; so the primary goal is to keep the terminal state consistent and + * not allow malformed input to corrupt or crash. + * + * @param terminal The terminal handle + * @param data Pointer to the data to write + * @param len Length of the data in bytes + * + * @ingroup terminal + */ +void ghostty_terminal_vt_write(GhosttyTerminal terminal, + const uint8_t* data, + size_t len); + +/** + * Scroll the terminal viewport. + * + * Scrolls the terminal's viewport according to the given behavior. + * When using GHOSTTY_SCROLL_VIEWPORT_DELTA, set the delta field in + * the value union to specify the number of rows to scroll (negative + * for up, positive for down). For other behaviors, the value is ignored. + * + * @param terminal The terminal handle (may be NULL, in which case this is a no-op) + * @param behavior The scroll behavior as a tagged union + * + * @ingroup terminal + */ +void ghostty_terminal_scroll_viewport(GhosttyTerminal terminal, + GhosttyTerminalScrollViewport behavior); + +/** + * 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 + */ +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 + */ +GhosttyResult ghostty_terminal_mode_set(GhosttyTerminal terminal, + GhosttyMode mode, + bool value); + +/** + * Get data from a terminal instance. + * + * Extracts typed data from the given terminal based on the specified + * data type. The output pointer must be of the appropriate type for the + * requested data kind. Valid data types and output types are documented + * in the `GhosttyTerminalData` enum. + * + * @param terminal The terminal handle (may be NULL) + * @param data The type of data to extract + * @param out Pointer to store the extracted data (type depends on data parameter) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal + * is NULL or the data type is invalid + * + * @ingroup terminal + */ +GhosttyResult ghostty_terminal_get(GhosttyTerminal terminal, + GhosttyTerminalData data, + void *out); + +/** + * Resolve a point in the terminal grid to a grid reference. + * + * Resolves the given point (which can be in active, viewport, screen, + * or history coordinates) to a grid reference for that location. Use + * ghostty_grid_ref_cell() and ghostty_grid_ref_row() to extract the cell + * and row. + * + * Lookups using the `active` and `viewport` tags are fast. The `screen` + * and `history` tags may require traversing the full scrollback page list + * to resolve the y coordinate, so they can be expensive for large + * scrollback buffers. + * + * This function isn't meant to be used as the core of render loop. It + * isn't built to sustain the framerates needed for rendering large screens. + * Use the render state API for that. This API is instead meant for less + * strictly performance-sensitive use cases. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param point The point specifying which cell to look up + * @param[out] out_ref On success, set to the grid reference at the given point (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal + * is NULL or the point is out of bounds + * + * @ingroup terminal + */ +GhosttyResult ghostty_terminal_grid_ref(GhosttyTerminal terminal, + GhosttyPoint point, + GhosttyGridRef *out_ref); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_TERMINAL_H */ diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h new file mode 100644 index 000000000..b5b0fa651 --- /dev/null +++ b/include/ghostty/vt/types.h @@ -0,0 +1,62 @@ +/** + * @file types.h + * + * Common types, macros, and utilities for libghostty-vt. + */ + +#ifndef GHOSTTY_VT_TYPES_H +#define GHOSTTY_VT_TYPES_H + +#include <stddef.h> +#include <stdint.h> + +/** + * Result codes for libghostty-vt operations. + */ +typedef enum { + /** Operation completed successfully */ + GHOSTTY_SUCCESS = 0, + /** Operation failed due to failed allocation */ + GHOSTTY_OUT_OF_MEMORY = -1, + /** Operation failed due to invalid value */ + GHOSTTY_INVALID_VALUE = -2, + /** Operation failed because the provided buffer was too small */ + GHOSTTY_OUT_OF_SPACE = -3, +} GhosttyResult; + +/** + * A borrowed byte string (pointer + length). + * + * The memory is not owned by this struct. The pointer is only valid + * for the lifetime documented by the API that produces or consumes it. + */ +typedef struct { + /** Pointer to the string bytes. */ + const uint8_t* ptr; + + /** Length of the string in bytes. */ + size_t len; +} GhosttyString; + +/** + * Initialize a sized struct to zero and set its size field. + * + * Sized structs use a `size` field as the first member for ABI + * compatibility. This macro zero-initializes the struct and sets the + * size field to `sizeof(type)`, which allows the library to detect + * which version of the struct the caller was compiled against. + * + * @param type The struct type to initialize + * @return A zero-initialized struct with the size field set + * + * Example: + * @code + * GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions); + * opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN; + * opts.trim = true; + * @endcode + */ +#define GHOSTTY_INIT_SIZED(type) \ + ((type){ .size = sizeof(type) }) + +#endif /* GHOSTTY_VT_TYPES_H */ diff --git a/macos/AGENTS.md b/macos/AGENTS.md index 50e91781d..1a0c84c32 100644 --- a/macos/AGENTS.md +++ b/macos/AGENTS.md @@ -1,9 +1,34 @@ # macOS Ghostty Application - Use `swiftlint` for formatting and linting Swift code. -- If code outside of this directory is modified, use +- If code outside of `macos/` directory is modified, use `zig build -Demit-macos-app=false` before building the macOS app to update the underlying Ghostty library. -- Use `xcodebuild` to build the macOS app, do not use `zig build` +- Use `macos/build.nu` to build the macOS app, do not use `zig build` (except to build the underlying library as mentioned above). -- Run unit tests directly with `xcodebuild` + - Build: `macos/build.nu [--scheme Ghostty] [--configuration Debug] [--action build]` + - Output: `macos/build/<configuration>/Ghostty.app` (e.g. `macos/build/Debug/Ghostty.app`) +- Run unit tests directly with `macos/build.nu --action test` + +## AppleScript + +- The AppleScript scripting definition is in `macos/Ghostty.sdef`. +- Guard AppleScript entry points and object accessors with the + `macos-applescript` configuration (use `NSApp.isAppleScriptEnabled` + and `NSApp.validateScript(command:)` where applicable). +- In `macos/Ghostty.sdef`, keep top-level definitions in this order: + 1. Classes + 2. Records + 3. Enums + 4. Commands +- Test AppleScript support: + (1) Build with `macos/build.nu` + (2) Launch and activate the app via osascript using the absolute path + to the built app bundle: + `osascript -e 'tell application "<absolute path to build/Debug/Ghostty.app>" to activate'` + (3) Wait a few seconds for the app to fully launch and open a terminal. + (4) Run test scripts with `osascript`, always targeting the app by + its absolute path (not by name) to avoid calling the wrong + application. + (5) When done, quit via: + `osascript -e 'tell application "<absolute path to build/Debug/Ghostty.app>" to quit'` diff --git a/macos/Ghostty-Info.plist b/macos/Ghostty-Info.plist index 4896681b9..7ffe12c39 100644 --- a/macos/Ghostty-Info.plist +++ b/macos/Ghostty-Info.plist @@ -2,6 +2,8 @@ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> + <key>NSAutoFillRequiresTextContentTypeForOneTimeCodeOnMac</key> + <true/> <key>NSDockTilePlugIn</key> <string>DockTilePlugin.plugin</string> <key>CFBundleDocumentTypes</key> @@ -55,8 +57,12 @@ </dict> <key>MDItemKeywords</key> <string>Terminal</string> + <key>NSAppleScriptEnabled</key> + <true/> <key>NSHighResolutionCapable</key> <true/> + <key>OSAScriptingDefinition</key> + <string>Ghostty.sdef</string> <key>NSServices</key> <array> <dict> diff --git a/macos/Ghostty.sdef b/macos/Ghostty.sdef new file mode 100644 index 000000000..bdfc501fb --- /dev/null +++ b/macos/Ghostty.sdef @@ -0,0 +1,318 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE dictionary SYSTEM "file://localhost/System/Library/DTDs/sdef.dtd"> + +<dictionary title="Ghostty Scripting Dictionary"> + <suite name="Ghostty Suite" code="Ghst" description="Ghostty scripting support."> + <class name="application" code="capp" description="The Ghostty application."> + <cocoa class="NSApplication"/> + <property name="name" code="pnam" type="text" access="r" description="The name of the application."/> + <property name="frontmost" code="pisf" type="boolean" access="r" description="Is this the active application?"> + <cocoa key="isActive"/> + </property> + <property name="front window" code="GFWn" type="window" access="r" description="The frontmost Ghostty window."> + <cocoa key="frontWindow"/> + </property> + <property name="version" code="vers" type="text" access="r" description="The version number of the application."/> + <responds-to command="perform action"> + <cocoa method="handlePerformActionScriptCommand:"/> + </responds-to> + <responds-to command="new window"> + <cocoa method="handleNewWindowScriptCommand:"/> + </responds-to> + <responds-to command="new tab"> + <cocoa method="handleNewTabScriptCommand:"/> + </responds-to> + <responds-to command="new surface configuration"> + <cocoa method="handleNewSurfaceConfigurationScriptCommand:"/> + </responds-to> + <responds-to command="quit"> + <cocoa method="handleQuitScriptCommand:"/> + </responds-to> + + <element type="window" access="r"> + <cocoa key="scriptWindows"/> + </element> + + <element type="terminal" access="r"> + <cocoa key="terminals"/> + </element> + </class> + + <class name="window" code="Gwnd" plural="windows" description="A Ghostty window containing one or more tabs."> + <cocoa class="GhosttyScriptWindow"/> + <property name="id" code="ID " type="text" access="r" description="Stable ID for this window."/> + <property name="name" code="pnam" type="text" access="r" description="The title of the window."> + <cocoa key="title"/> + </property> + <property name="selected tab" code="GWsT" type="tab" access="r" description="The selected tab in this window."> + <cocoa key="selectedTab"/> + </property> + <responds-to command="activate window"> + <cocoa method="handleActivateWindowCommand:"/> + </responds-to> + <responds-to command="close window"> + <cocoa method="handleCloseWindowCommand:"/> + </responds-to> + <element type="tab" access="r"> + <cocoa key="tabs"/> + </element> + <element type="terminal" access="r"> + <cocoa key="terminals"/> + </element> + </class> + + <class name="tab" code="Gtab" plural="tabs" description="A tab within a Ghostty window."> + <cocoa class="GhosttyScriptTab"/> + <property name="id" code="ID " type="text" access="r" description="Stable ID for this tab."/> + <property name="name" code="pnam" type="text" access="r" description="The title of the tab."> + <cocoa key="title"/> + </property> + <property name="index" code="pidx" type="integer" access="r" description="1-based index of this tab in its window."/> + <property name="selected" code="GTsl" type="boolean" access="r" description="Whether this tab is selected in its window."/> + <property name="focused terminal" code="GTfT" type="terminal" access="r" description="The currently focused terminal surface in this tab."> + <cocoa key="focusedTerminal"/> + </property> + <responds-to command="select tab"> + <cocoa method="handleSelectTabCommand:"/> + </responds-to> + <responds-to command="close tab"> + <cocoa method="handleCloseTabCommand:"/> + </responds-to> + <element type="terminal" access="r"> + <cocoa key="terminals"/> + </element> + </class> + + <class name="terminal" code="Gtrm" plural="terminals" description="An individual terminal surface."> + <cocoa class="GhosttyScriptTerminal"/> + <property name="id" code="ID " type="text" access="r" description="Stable ID for this terminal surface."/> + <property name="name" code="pnam" type="text" access="r" description="Current terminal title."> + <cocoa key="title"/> + </property> + <property name="working directory" code="Gwdr" type="text" access="r" description="Current working directory for the terminal process."/> + <responds-to command="split"> + <cocoa method="handleSplitCommand:"/> + </responds-to> + <responds-to command="focus"> + <cocoa method="handleFocusCommand:"/> + </responds-to> + <responds-to command="close"> + <cocoa method="handleCloseCommand:"/> + </responds-to> + </class> + + <record-type name="surface configuration" code="GScf" description="Reusable settings applied when creating a terminal surface."> + <property name="font size" code="GScF" type="real" description="Font size in points."> + <cocoa key="fontSize"/> + </property> + <property name="initial working directory" code="GScD" type="text" description="Initial working directory for the terminal process."> + <cocoa key="workingDirectory"/> + </property> + <property name="command" code="GScC" type="text" description="Command to execute instead of the configured shell."> + <cocoa key="command"/> + </property> + <property name="initial input" code="GScI" type="text" description="Input sent to the terminal after launch."> + <cocoa key="initialInput"/> + </property> + <property name="wait after command" code="GScW" type="boolean" description="Keep the terminal open after command exit."> + <cocoa key="waitAfterCommand"/> + </property> + <property name="environment variables" code="GScE" description="Environment variables in KEY=VALUE format."> + <type type="text" list="yes"/> + <cocoa key="environmentVariables"/> + </property> + </record-type> + + <enumeration name="split direction" code="GSpD" description="Direction for a new split."> + <enumerator name="right" code="GSrt" description="Split to the right."/> + <enumerator name="left" code="GSlf" description="Split to the left."/> + <enumerator name="down" code="GSdn" description="Split downward."/> + <enumerator name="up" code="GSup" description="Split upward."/> + </enumeration> + + <enumeration name="input action" code="GIAc" description="Whether an input is pressed or released."> + <enumerator name="press" code="GIpr" description="Press."/> + <enumerator name="release" code="GIrl" description="Release."/> + </enumeration> + + <enumeration name="mouse button" code="GMBt" description="A mouse button."> + <enumerator name="left button" code="GMlf" description="Left mouse button."/> + <enumerator name="right button" code="GMrt" description="Right mouse button."/> + <enumerator name="middle button" code="GMmd" description="Middle mouse button."/> + </enumeration> + + <enumeration name="scroll momentum" code="GSMo" description="Momentum phase for inertial scrolling."> + <enumerator name="none" code="SMno" description="No momentum."/> + <enumerator name="began" code="SMbg" description="Momentum began."/> + <enumerator name="changed" code="SMch" description="Momentum changed."/> + <enumerator name="ended" code="SMen" description="Momentum ended."/> + <enumerator name="cancelled" code="SMcn" description="Momentum cancelled."/> + <enumerator name="may begin" code="SMmb" description="Momentum may begin."/> + <enumerator name="stationary" code="SMst" description="Stationary."/> + </enumeration> + + <command name="perform action" code="GhstPfAc" description="Perform a Ghostty action string on a terminal."> + <direct-parameter type="text" description="The Ghostty action string."/> + <parameter name="on" code="GonT" type="terminal" description="Target terminal."/> + <result type="boolean" description="True when the action was performed."/> + </command> + + <command name="new surface configuration" code="GhstNSCf" description="Create a reusable surface configuration object."> + <parameter name="from" code="GScS" type="surface configuration" optional="yes" description="Surface configuration to copy."> + <cocoa key="configuration"/> + </parameter> + <result type="surface configuration" description="The newly created surface configuration."/> + </command> + + <command name="new window" code="GhstNWin" description="Create a new Ghostty window."> + <parameter name="with configuration" code="GNwS" type="surface configuration" optional="yes" description="Base surface configuration for the initial terminal."> + <cocoa key="configuration"/> + </parameter> + <result type="window" description="The newly created window."/> + </command> + + <command name="new tab" code="GhstNTab" description="Create a new Ghostty tab."> + <parameter name="in" code="GNtW" type="window" optional="yes" description="Target window for the new tab."> + <cocoa key="window"/> + </parameter> + <parameter name="with configuration" code="GNtS" type="surface configuration" optional="yes" description="Base surface configuration for the initial terminal."> + <cocoa key="configuration"/> + </parameter> + <result type="tab" description="The newly created tab."/> + </command> + + <command name="split" code="GhstSplt" description="Split a terminal in the given direction."> + <direct-parameter type="specifier" description="The terminal to split."/> + <parameter name="direction" code="GSpd" type="split direction" description="The direction to split."> + <cocoa key="direction"/> + </parameter> + <parameter name="with configuration" code="GSpS" type="surface configuration" optional="yes" description="Base surface configuration for the new split terminal."> + <cocoa key="configuration"/> + </parameter> + <result type="terminal" description="The newly created terminal."/> + </command> + + <command name="focus" code="GhstFcus" description="Focus a terminal, bringing its window to the front."> + <direct-parameter type="specifier" description="The terminal to focus."/> + </command> + + <command name="close" code="GhstClos" description="Close a terminal."> + <direct-parameter type="specifier" description="The terminal to close."/> + </command> + + <command name="activate window" code="GhstAcWn" description="Activate a Ghostty window, bringing it to the front."> + <direct-parameter type="specifier" description="The window to activate."/> + </command> + + <command name="select tab" code="GhstSlTb" description="Select a tab in its window."> + <direct-parameter type="specifier" description="The tab to select."/> + </command> + + <command name="close tab" code="GhstClTb" description="Close a tab."> + <direct-parameter type="specifier" description="The tab to close."/> + </command> + + <command name="close window" code="GhstClWn" description="Close a window."> + <direct-parameter type="specifier" description="The window to close."/> + </command> + + <command name="input text" code="GhstInTx" description="Input text to a terminal as if it was pasted."> + <cocoa class="GhosttyScriptInputTextCommand"/> + <direct-parameter type="text" description="The text to input."/> + <parameter name="to" code="GItT" type="terminal" description="The terminal to input text to."> + <cocoa key="terminal"/> + </parameter> + </command> + + <command name="send key" code="GhstSKey" description="Send a keyboard event to a terminal."> + <cocoa class="GhosttyScriptKeyEventCommand"/> + <direct-parameter type="text" description="The key name (e.g. "enter", "a", "space")."/> + <parameter name="action" code="GKeA" type="input action" optional="yes" description="Press or release (default: press)."> + <cocoa key="action"/> + </parameter> + <parameter name="modifiers" code="GKeM" type="text" optional="yes" description="Comma-separated modifier keys: shift, control, option, command."> + <cocoa key="modifiers"/> + </parameter> + <parameter name="to" code="GKeT" type="terminal" description="The terminal to send the key event to."> + <cocoa key="terminal"/> + </parameter> + </command> + + <command name="send mouse button" code="GhstSMBt" description="Send a mouse button event to a terminal."> + <cocoa class="GhosttyScriptMouseButtonCommand"/> + <direct-parameter type="mouse button" description="The mouse button."/> + <parameter name="action" code="GMbA" type="input action" optional="yes" description="Press or release (default: press)."> + <cocoa key="action"/> + </parameter> + <parameter name="modifiers" code="GMbM" type="text" optional="yes" description="Comma-separated modifier keys: shift, control, option, command."> + <cocoa key="modifiers"/> + </parameter> + <parameter name="to" code="GMbT" type="terminal" description="The terminal to send the event to."> + <cocoa key="terminal"/> + </parameter> + </command> + + <command name="send mouse position" code="GhstSMPs" description="Send a mouse position event to a terminal."> + <cocoa class="GhosttyScriptMousePosCommand"/> + <parameter name="x" code="GMpX" type="real" description="Horizontal position in pixels."> + <cocoa key="x"/> + </parameter> + <parameter name="y" code="GMpY" type="real" description="Vertical position in pixels."> + <cocoa key="y"/> + </parameter> + <parameter name="modifiers" code="GMpM" type="text" optional="yes" description="Comma-separated modifier keys: shift, control, option, command."> + <cocoa key="modifiers"/> + </parameter> + <parameter name="to" code="GMpT" type="terminal" description="The terminal to send the event to."> + <cocoa key="terminal"/> + </parameter> + </command> + + <command name="send mouse scroll" code="GhstSMSc" description="Send a mouse scroll event to a terminal."> + <cocoa class="GhosttyScriptMouseScrollCommand"/> + <parameter name="x" code="GMsX" type="real" description="Horizontal scroll delta."> + <cocoa key="x"/> + </parameter> + <parameter name="y" code="GMsY" type="real" description="Vertical scroll delta."> + <cocoa key="y"/> + </parameter> + <parameter name="precision" code="GMsP" type="boolean" optional="yes" description="High-precision scroll (e.g. trackpad). Default: false."> + <cocoa key="precision"/> + </parameter> + <parameter name="momentum" code="GMsM" type="scroll momentum" optional="yes" description="Momentum phase for inertial scrolling. Default: none."> + <cocoa key="momentum"/> + </parameter> + <parameter name="to" code="GMsT" type="terminal" description="The terminal to send the event to."> + <cocoa key="terminal"/> + </parameter> + </command> + </suite> + + <!-- + The Standard Suite definition below is copied from Apple's + /System/Library/ScriptingDefinitions/CocoaStandard.sdef, trimmed to only + include what we need. + --> + <suite name="Standard Suite" code="????" description="Common classes and commands for all applications."> + <command name="count" code="corecnte" description="Return the number of elements of a particular class within an object."> + <cocoa class="NSCountCommand"/> + <access-group identifier="*"/> + <direct-parameter type="specifier" requires-access="r" description="The objects to be counted."/> + <parameter name="each" code="kocl" type="type" optional="yes" description="The class of objects to be counted." hidden="yes"> + <cocoa key="ObjectClass"/> + </parameter> + <result type="integer" description="The count."/> + </command> + + <command name="exists" code="coredoex" description="Verify that an object exists."> + <cocoa class="NSExistsCommand"/> + <access-group identifier="*"/> + <direct-parameter type="any" requires-access="r" description="The object(s) to check."/> + <result type="boolean" description="Did the object(s) exist?"/> + </command> + + <command name="quit" code="aevtquit" description="Quit the application."> + <cocoa class="NSQuitCommand"/> + </command> + </suite> +</dictionary> diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 5a3e7a52e..3758c325d 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; }; 819324582F24E78800A9ED8F /* DockTilePlugin.plugin in Copy DockTilePlugin */ = {isa = PBXBuildFile; fileRef = 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 819324642F24FF2100A9ED8F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; + 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 */; }; @@ -74,6 +75,7 @@ 552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = "<group>"; }; 810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GhosttyUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DockTilePlugin.plugin; sourceTree = BUILT_PRODUCTS_DIR; }; + 8F3A9B4B2FA6B88000A18D13 /* Ghostty.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = Ghostty.sdef; sourceTree = "<group>"; }; 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = "<group>"; }; A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyDebug.entitlements; sourceTree = "<group>"; }; A546F1132D7B68D7003B11A0 /* locale */ = {isa = PBXFileReference; lastKnownFileType = folder; name = locale; path = "../zig-out/share/locale"; sourceTree = "<group>"; }; @@ -134,6 +136,18 @@ "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, @@ -322,6 +336,7 @@ isa = PBXGroup; children = ( A571AB1C2A206FC600248498 /* Ghostty-Info.plist */, + 8F3A9B4B2FA6B88000A18D13 /* Ghostty.sdef */, A5B30538299BEAAB0047F10C /* Assets.xcassets */, A553F4122E06EB1600257779 /* Ghostty.icon */, A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */, @@ -557,6 +572,7 @@ A553F4142E06EB1600257779 /* Ghostty.icon in Resources */, 29C15B1D2CDC3B2900520DD4 /* bat in Resources */, A586167C2B7703CC009BDB1D /* fish in Resources */, + 8F3A9B4C2FA6B88000A18D13 /* Ghostty.sdef in Resources */, 55154BE02B33911F001622DC /* ghostty in Resources */, A546F1142D7B68D7003B11A0 /* locale in Resources */, A5985CE62C33060F00C57AD3 /* man in Resources */, diff --git a/macos/GhosttyUITests/GhosttyCommandPaletteTests.swift b/macos/GhosttyUITests/GhosttyCommandPaletteTests.swift new file mode 100644 index 000000000..428682b4f --- /dev/null +++ b/macos/GhosttyUITests/GhosttyCommandPaletteTests.swift @@ -0,0 +1,81 @@ +// +// GhosttyCommandPaletteTests.swift +// Ghostty +// +// Created by Lukas on 19.03.2026. +// + +import XCTest + +final class GhosttyCommandPaletteTests: GhosttyCustomConfigCase { + override static var runsForEachTargetApplicationUIConfiguration: Bool { false } + @MainActor func testDismissingCommandPalette() async throws { + let app = try ghosttyApplication() + app.activate() + + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 5), "New window should appear") + + app.menuItems["Command Palette"].firstMatch.click() + + let clearScreenButton = app.buttons + .containing(NSPredicate(format: "label CONTAINS[c] 'Clear Screen'")) + .firstMatch + + XCTAssertTrue(clearScreenButton.waitForExistence(timeout: 5), "Command Palette should appear") + + clearScreenButton.coordinate(withNormalizedOffset: .zero) + .withOffset(.init(dx: -30, dy: 0)) + .click() + + XCTAssertTrue(clearScreenButton.waitForNonExistence(timeout: 5), "Command Palette should disappear after clicking outside") + + app.typeKey("p", modifierFlags: [.command, .shift]) + + XCTAssertTrue(clearScreenButton.waitForExistence(timeout: 5), "Command Palette should appear") + + app.typeKey(.escape, modifierFlags: []) + + XCTAssertTrue(clearScreenButton.waitForNonExistence(timeout: 5), "Command Palette should disappear after typing escape") + + app.typeKey("p", modifierFlags: [.command, .shift]) + + XCTAssertTrue(clearScreenButton.waitForExistence(timeout: 5), "Command Palette should appear") + + app.typeKey(.enter, modifierFlags: []) + + XCTAssertTrue(clearScreenButton.waitForNonExistence(timeout: 5), "Command Palette should disappear after submitting query") + + app.typeKey("p", modifierFlags: [.command, .shift]) + + XCTAssertTrue(clearScreenButton.waitForExistence(timeout: 5), "Command Palette should appear") + + app.typeText("Clear Screen") + app.typeKey(.enter, modifierFlags: []) + + XCTAssertTrue(clearScreenButton.waitForNonExistence(timeout: 5), "Command Palette should disappear after selecting a command by keyboard") + + app.typeKey("p", modifierFlags: [.command, .shift]) + app.typeKey(.delete, modifierFlags: []) + + XCTAssertTrue(clearScreenButton.waitForExistence(timeout: 5), "Command Palette should appear") + clearScreenButton.click() + + XCTAssertTrue(clearScreenButton.waitForNonExistence(timeout: 5), "Command Palette should disappear after selecting a command by mouse") + } + + @MainActor func testSelectCommandWithMouse() async throws { + let app = try ghosttyApplication() + app.activate() + + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 5), "New window should appear") + + app.menuItems["Command Palette"].firstMatch.click() + + app.buttons + .containing(NSPredicate(format: "label CONTAINS[c] 'Close All Windows'")) + .firstMatch.click() + + XCTAssertTrue(app.windows.firstMatch.waitForNonExistence(timeout: 2), "All windows should be closed") + } +} + diff --git a/macos/GhosttyUITests/GhosttyCustomConfigCase.swift b/macos/GhosttyUITests/GhosttyCustomConfigCase.swift index 41993247a..ca3f56677 100644 --- a/macos/GhosttyUITests/GhosttyCustomConfigCase.swift +++ b/macos/GhosttyUITests/GhosttyCustomConfigCase.swift @@ -27,6 +27,8 @@ class GhosttyCustomConfigCase: XCTestCase { true } + static let defaultsSuiteName: String = "GHOSTTY_UI_TESTS" + var configFile: URL? override func setUpWithError() throws { continueAfterFailure = false @@ -47,13 +49,14 @@ class GhosttyCustomConfigCase: XCTestCase { try newConfig.write(to: configFile!, atomically: true, encoding: .utf8) } - func ghosttyApplication() throws -> XCUIApplication { + func ghosttyApplication(defaultsSuite: String = GhosttyCustomConfigCase.defaultsSuiteName) throws -> XCUIApplication { let app = XCUIApplication() app.launchArguments.append(contentsOf: ["-ApplePersistenceIgnoreState", "YES"]) guard let configFile else { return app } app.launchEnvironment["GHOSTTY_CONFIG_PATH"] = configFile.path + app.launchEnvironment["GHOSTTY_USER_DEFAULTS_SUITE"] = defaultsSuite return app } } diff --git a/macos/GhosttyUITests/GhosttyMouseStateTests.swift b/macos/GhosttyUITests/GhosttyMouseStateTests.swift new file mode 100644 index 000000000..9bb270b8e --- /dev/null +++ b/macos/GhosttyUITests/GhosttyMouseStateTests.swift @@ -0,0 +1,53 @@ +// +// GhosttyMouseStateTests.swift +// Ghostty +// +// Created by Lukas on 19.03.2026. +// + +import XCTest + +final class GhosttyMouseStateTests: GhosttyCustomConfigCase { + override static var runsForEachTargetApplicationUIConfiguration: Bool { false } + + // https://github.com/ghostty-org/ghostty/pull/11276 + @MainActor func testSelectionFocusChange() async throws { + let app = XCUIApplication() + app.activate() + // Write dummy text to a temp file, cat it into the terminal, then clean up + let lines = (1...200).map { "Line \($0): The quick brown fox jumps over the lazy dog. Lorem ipsum dolor sit amet, consectetur adipiscing elit." } + let text = lines.joined(separator: "\n") + "\n" + let tmpFile = NSTemporaryDirectory() + "ghostty_test_dummy.txt" + try text.write(toFile: tmpFile, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(atPath: tmpFile) } + + app.typeText("cat \(tmpFile)\r") + app.menuItems["Command Palette"].firstMatch.click() + + let finder = XCUIApplication(bundleIdentifier: "com.apple.finder") + finder.activate() + + app.activate() + + app.buttons + .containing(NSPredicate(format: "label CONTAINS[c] 'Clear Screen'")) + .firstMatch + .click() + let surface = app.groups["Terminal pane"] + surface + .coordinate(withNormalizedOffset: .zero) + .withOffset(.init(dx: 20, dy: 10)) + .click() + + surface + .coordinate(withNormalizedOffset: .zero) + .withOffset(.init(dx: 20, dy: surface.frame.height * 0.5)) + .hover() + + NSPasteboard.general.clearContents() + app.typeKey("c", modifierFlags: .command) + + XCTAssertEqual(NSPasteboard.general.string(forType: .string), nil, "Moving mouse shouldn't select any texts") + } +} + diff --git a/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift b/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift new file mode 100644 index 000000000..399c2531a --- /dev/null +++ b/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift @@ -0,0 +1,331 @@ +// +// GhosttyWindowPositionUITests.swift +// GhosttyUITests +// +// Created by Claude on 2026-03-11. +// + +import XCTest + +final class GhosttyWindowPositionUITests: GhosttyCustomConfigCase { + override static var runsForEachTargetApplicationUIConfiguration: Bool { false } + + // MARK: - Cascading + + @MainActor func testWindowCascading() async throws { + try updateConfig( + """ + window-width = 30 + window-height = 10 + title = "GhosttyWindowPositionUITests" + """ + ) + + let app = try ghosttyApplication() + // Suppress Restoration + app.launchArguments += ["-NSQuitAlwaysKeepsWindows", "NO"] + // Clean run + app.launchEnvironment["GHOSTTY_CLEAR_USER_DEFAULTS"] = "YES" + + app.launch() // window in the center + +// app.menuBarItems["Window"].firstMatch.click() +// app.menuItems["_zoomTopLeft:"].firstMatch.click() +// +// // wait for the animation to finish +// try await Task.sleep(for: .seconds(0.5)) + + let window = app.windows.firstMatch + let windowFrame = window.frame +// XCTAssertEqual(windowFrame.minX, 0, "Window should be on the left") + + app.typeKey("n", modifierFlags: [.command]) + + let window2 = app.windows.firstMatch + XCTAssertTrue(window2.waitForExistence(timeout: 5), "New window should appear") + let windowFrame2 = window2.frame + XCTAssertNotEqual(windowFrame, windowFrame2, "New window should have moved") + + XCTAssertEqual(windowFrame2.minX, windowFrame.minX + 30, accuracy: 5, "New window should be on the right") + + XCTAssertEqual(windowFrame2.minY, windowFrame.minY + 30, accuracy: 5, "New window should be on the bottom right") + + app.typeKey("n", modifierFlags: [.command]) + + let window3 = app.windows.firstMatch + XCTAssertTrue(window3.waitForExistence(timeout: 5), "New window should appear") + let windowFrame3 = window3.frame + XCTAssertNotEqual(windowFrame2, windowFrame3, "New window should have moved") + + XCTAssertEqual(windowFrame3.minX, windowFrame2.minX + 30, accuracy: 5, "New window should be on the right") + + XCTAssertEqual(windowFrame3.minY, windowFrame2.minY + 30, accuracy: 5, "New window should be on the bottom right") + + app.typeKey("n", modifierFlags: [.command]) + + let window4 = app.windows.firstMatch + XCTAssertTrue(window4.waitForExistence(timeout: 5), "New window should appear") + let windowFrame4 = window4.frame + XCTAssertNotEqual(windowFrame3, windowFrame4, "New window should have moved") + + XCTAssertEqual(windowFrame4.minX, windowFrame3.minX + 30, accuracy: 5, "New window should be on the right") + + XCTAssertEqual(windowFrame4.minY, windowFrame3.minY + 30, accuracy: 5, "New window should be on the bottom right") + } + + @MainActor func testDragSplitWindowPosition() async throws { + try updateConfig( + """ + window-width = 40 + window-height = 20 + title = "GhosttyWindowPositionUITests" + macos-titlebar-style = hidden + """ + ) + + let app = try ghosttyApplication() + // Suppress Restoration + app.launchArguments += ["-NSQuitAlwaysKeepsWindows", "NO"] + // Clean run + app.launchEnvironment["GHOSTTY_CLEAR_USER_DEFAULTS"] = "YES" + + app.launch() // window in the center + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "New window should appear") + + // remove fixed size + try updateConfig( + """ + title = "GhosttyWindowPositionUITests" + macos-titlebar-style = hidden + """ + ) + app.typeKey(",", modifierFlags: [.command, .shift]) + + app.typeKey("d", modifierFlags: [.command]) + + let rightSplit = app.groups["Right pane"] + let rightFrame = rightSplit.frame + + let sourcePos = rightSplit.coordinate(withNormalizedOffset: .zero) + .withOffset(.init(dx: rightFrame.size.width / 2, dy: 3)) + + let targetPos = rightSplit.coordinate(withNormalizedOffset: .zero) + .withOffset(.init(dx: rightFrame.size.width + 100, dy: 0)) + + sourcePos.click(forDuration: 0.2, thenDragTo: targetPos) + + let window2 = app.windows.firstMatch + XCTAssertTrue(window2.waitForExistence(timeout: 5), "New window should appear") + let windowFrame2 = window2.frame + + try await Task.sleep(for: .seconds(0.5)) + + XCTAssertEqual(windowFrame2.minX, rightFrame.maxX + 100, accuracy: 5, "New window should be target position") + XCTAssertEqual(windowFrame2.minY, rightFrame.minY, accuracy: 5, "New window should be target position") + XCTAssertEqual(windowFrame2.width, rightFrame.width, accuracy: 5, "New window should use size from config") + XCTAssertEqual(windowFrame2.height, rightFrame.height, accuracy: 5, "New window should use size from config") + } + + @MainActor func testDragSplitWindowPositionWithFixedSize() async throws { + try updateConfig( + """ + window-width = 40 + window-height = 20 + title = "GhosttyWindowPositionUITests" + macos-titlebar-style = hidden + """ + ) + + let app = try ghosttyApplication() + // Suppress Restoration + app.launchArguments += ["-NSQuitAlwaysKeepsWindows", "NO"] + // Clean run + app.launchEnvironment["GHOSTTY_CLEAR_USER_DEFAULTS"] = "YES" + + app.launch() // window in the center + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "New window should appear") + let windowFrame = window.frame + + app.typeKey("d", modifierFlags: [.command]) + + let rightSplit = app.groups["Right pane"] + let rightFrame = rightSplit.frame + + let sourcePos = rightSplit.coordinate(withNormalizedOffset: .zero) + .withOffset(.init(dx: rightFrame.size.width / 2, dy: 3)) + + let targetPos = rightSplit.coordinate(withNormalizedOffset: .zero) + .withOffset(.init(dx: rightFrame.size.width + 100, dy: 0)) + + sourcePos.click(forDuration: 0.2, thenDragTo: targetPos) + + let window2 = app.windows.firstMatch + XCTAssertTrue(window2.waitForExistence(timeout: 5), "New window should appear") + let windowFrame2 = window2.frame + + try await Task.sleep(for: .seconds(0.5)) + + XCTAssertEqual(windowFrame2.minX, rightFrame.maxX + 100, accuracy: 5, "New window should be target position") + XCTAssertEqual(windowFrame2.minY, rightFrame.minY, accuracy: 5, "New window should be target position") + XCTAssertEqual(windowFrame2.width, windowFrame.width, accuracy: 5, "New window should use size from config") + // We're still using right frame, because of the debug banner + XCTAssertEqual(windowFrame2.height, rightFrame.height, accuracy: 5, "New window should use size from config") + } + + // MARK: - Restore round-trip per titlebar style + + @MainActor func testRestoredNative() throws { try runRestoreTest(titlebarStyle: "native") } + @MainActor func testRestoredHidden() throws { try runRestoreTest(titlebarStyle: "hidden") } + @MainActor func testRestoredTransparent() throws { try runRestoreTest(titlebarStyle: "transparent") } + @MainActor func testRestoredTabs() throws { try runRestoreTest(titlebarStyle: "tabs") } + + // MARK: - Config overrides cached position/size + + @MainActor + func testConfigOverridesCachedPositionAndSize() async throws { + // Launch maximized so the cached frame is fullscreen-sized. + try updateConfig( + """ + maximize = true + title = "GhosttyWindowPositionUITests" + """ + ) + + let app = try ghosttyApplication() + app.launch() + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "Window should appear") + + let maximizedFrame = window.frame + + // Now update the config with a small explicit size and position, + // reload, and open a new window. It should respect the config, not the cache. + try updateConfig( + """ + window-position-x = 50 + window-position-y = 50 + window-width = 30 + window-height = 30 + title = "GhosttyWindowPositionUITests" + """ + ) + app.typeKey(",", modifierFlags: [.command, .shift]) + try await Task.sleep(for: .seconds(0.5)) + app.typeKey("n", modifierFlags: [.command]) + + XCTAssertEqual(app.windows.count, 2, "Should have 2 windows") + let newWindow = app.windows.element(boundBy: 0) + let newFrame = newWindow.frame + + // The new window should be smaller than the maximized one. + XCTAssertLessThan(newFrame.size.width, maximizedFrame.size.width, + "30 columns should be narrower than maximized") + XCTAssertLessThan(newFrame.size.height, maximizedFrame.size.height, + "30 rows should be shorter than maximized") + + app.terminate() + } + + // MARK: - Size-only config change preserves position + + @MainActor + func testSizeOnlyConfigPreservesPosition() async throws { + // Launch maximized so the window has a known position (top-left of visible frame). + try updateConfig( + """ + maximize = true + title = "GhosttyWindowPositionUITests" + """ + ) + + let app = try ghosttyApplication() + app.launch() + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "Window should appear") + + let initialFrame = window.frame + + // Reload with only size changed, close current window, open new one. + // Position should be restored from cache. + try updateConfig( + """ + window-width = 30 + window-height = 30 + title = "GhosttyWindowPositionUITests" + """ + ) + app.typeKey(",", modifierFlags: [.command, .shift]) + try await Task.sleep(for: .seconds(0.5)) + app.typeKey("w", modifierFlags: [.command]) + app.typeKey("n", modifierFlags: [.command]) + + let newWindow = app.windows.firstMatch + XCTAssertTrue(newWindow.waitForExistence(timeout: 5), "New window should appear") + + let newFrame = newWindow.frame + + // Position should be preserved from the cached value. + // Compare x and maxY since the window is anchored at the top-left + // but AppKit uses bottom-up coordinates (origin.y changes with height). + XCTAssertEqual(newFrame.origin.x, initialFrame.origin.x, accuracy: 2, + "x position should not change with size-only config") + XCTAssertEqual(newFrame.maxY, initialFrame.maxY, accuracy: 2, + "top edge (maxY) should not change with size-only config") + + app.terminate() + } + + // MARK: - Shared round-trip helper + + /// Opens a new window, records its frame, closes it, opens another, + /// and verifies the frame is restored consistently. + private func runRestoreTest(titlebarStyle: String) throws { + try updateConfig( + """ + macos-titlebar-style = \(titlebarStyle) + title = "GhosttyWindowPositionUITests" + """ + ) + + let app = try ghosttyApplication() + // Suppress Restoration + app.launchArguments += ["-NSQuitAlwaysKeepsWindows", "NO"] + // Clean run + app.launchEnvironment["GHOSTTY_CLEAR_USER_DEFAULTS"] = "YES" + app.launch() + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "Window should appear") + + let firstFrame = window.frame + let screenFrame = NSScreen.main?.frame ?? .zero + + XCTAssertEqual(firstFrame.midX, screenFrame.midX, accuracy: 5.0, "First window should be centered horizontally") + + // Close the window and open a new one — it should restore the same frame. + app.typeKey("w", modifierFlags: [.command]) + app.typeKey("n", modifierFlags: [.command]) + + let window2 = app.windows.firstMatch + XCTAssertTrue(window2.waitForExistence(timeout: 5), "New window should appear") + + let restoredFrame = window2.frame + + XCTAssertEqual(restoredFrame.origin.x, firstFrame.origin.x, accuracy: 2, + "[\(titlebarStyle)] x position should be restored") + XCTAssertEqual(restoredFrame.origin.y, firstFrame.origin.y, accuracy: 2, + "[\(titlebarStyle)] y position should be restored") + XCTAssertEqual(restoredFrame.size.width, firstFrame.size.width, accuracy: 2, + "[\(titlebarStyle)] width should be restored") + XCTAssertEqual(restoredFrame.size.height, firstFrame.size.height, accuracy: 2, + "[\(titlebarStyle)] height should be restored") + + app.terminate() + } +} diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index f9e2dc93f..b02337e4b 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -154,6 +154,13 @@ class AppDelegate: NSObject, /// The custom app icon image that is currently in use. @Published private(set) var appIcon: NSImage? + /// Ghostty menu items indexed by their normalized shortcut. This avoids traversing + /// the entire menu tree on every key equivalent event. + /// + /// We store a weak reference so this cache can never be the owner of menu items. + /// If multiple items map to the same shortcut, the most recent one wins. + private var menuItemsByShortcut: [MenuShortcutKey: Weak<NSMenuItem>] = [:] + override init() { #if DEBUG ghostty = Ghostty.App(configPath: ProcessInfo.processInfo.environment["GHOSTTY_CONFIG_PATH"]) @@ -168,7 +175,15 @@ class AppDelegate: NSObject, // MARK: - NSApplicationDelegate func applicationWillFinishLaunching(_ notification: Notification) { - UserDefaults.standard.register(defaults: [ + #if DEBUG + if + let suite = UserDefaults.ghosttySuite, + let clear = ProcessInfo.processInfo.environment["GHOSTTY_CLEAR_USER_DEFAULTS"], + (clear as NSString).boolValue { + UserDefaults.ghostty.removePersistentDomain(forName: suite) + } + #endif + UserDefaults.ghostty.register(defaults: [ // Disable the automatic full screen menu item because we handle // it manually. "NSFullScreenMenuItemEverywhere": false, @@ -187,7 +202,7 @@ class AppDelegate: NSObject, func applicationDidFinishLaunching(_ notification: Notification) { // System settings overrides - UserDefaults.standard.register(defaults: [ + UserDefaults.ghostty.register(defaults: [ // Disable this so that repeated key events make it through to our terminal views. "ApplePressAndHoldEnabled": false, ]) @@ -196,7 +211,7 @@ class AppDelegate: NSObject, applicationLaunchTime = ProcessInfo.processInfo.systemUptime // Check if secure input was enabled when we last quit. - if UserDefaults.standard.bool(forKey: "SecureInput") != SecureInput.shared.enabled { + if UserDefaults.ghostty.bool(forKey: "SecureInput") != SecureInput.shared.enabled { toggleSecureInput(self) } @@ -516,11 +531,6 @@ class AppDelegate: NSObject, return true } - /// This is called for the dock right-click menu. - func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { - return dockMenu - } - /// Setup signal handlers private func setupSignals() { // Register a signal handler for config reloading. It appears that all @@ -549,134 +559,6 @@ class AppDelegate: NSObject, signals.append(sigusr2) } - /// Setup all the images for our menu items. - private func setupMenuImages() { - // Note: This COULD Be done all in the xib file, but I find it easier to - // modify this stuff as code. - self.menuAbout?.setImageIfDesired(systemSymbolName: "info.circle") - self.menuCheckForUpdates?.setImageIfDesired(systemSymbolName: "square.and.arrow.down") - self.menuOpenConfig?.setImageIfDesired(systemSymbolName: "gear") - self.menuReloadConfig?.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise.rotate.90") - self.menuSecureInput?.setImageIfDesired(systemSymbolName: "lock.display") - self.menuNewWindow?.setImageIfDesired(systemSymbolName: "macwindow.badge.plus") - self.menuNewTab?.setImageIfDesired(systemSymbolName: "macwindow") - self.menuSplitRight?.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled") - self.menuSplitLeft?.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled") - self.menuSplitUp?.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled") - self.menuSplitDown?.setImageIfDesired(systemSymbolName: "rectangle.bottomhalf.inset.filled") - self.menuClose?.setImageIfDesired(systemSymbolName: "xmark") - self.menuPasteSelection?.setImageIfDesired(systemSymbolName: "doc.on.clipboard.fill") - self.menuIncreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.larger") - self.menuResetFontSize?.setImageIfDesired(systemSymbolName: "textformat.size") - self.menuDecreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.smaller") - self.menuCommandPalette?.setImageIfDesired(systemSymbolName: "filemenu.and.selection") - self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal") - self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line") - self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope") - self.menuReadonly?.setImageIfDesired(systemSymbolName: "eye.fill") - self.menuSetAsDefaultTerminal?.setImageIfDesired(systemSymbolName: "star.fill") - self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward") - self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye") - self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right") - self.menuPreviousSplit?.setImageIfDesired(systemSymbolName: "chevron.backward.2") - self.menuNextSplit?.setImageIfDesired(systemSymbolName: "chevron.forward.2") - self.menuEqualizeSplits?.setImageIfDesired(systemSymbolName: "inset.filled.topleft.topright.bottomleft.bottomright.rectangle") - self.menuSelectSplitLeft?.setImageIfDesired(systemSymbolName: "arrow.left") - self.menuSelectSplitRight?.setImageIfDesired(systemSymbolName: "arrow.right") - self.menuSelectSplitAbove?.setImageIfDesired(systemSymbolName: "arrow.up") - self.menuSelectSplitBelow?.setImageIfDesired(systemSymbolName: "arrow.down") - self.menuMoveSplitDividerUp?.setImageIfDesired(systemSymbolName: "arrow.up.to.line") - self.menuMoveSplitDividerDown?.setImageIfDesired(systemSymbolName: "arrow.down.to.line") - self.menuMoveSplitDividerLeft?.setImageIfDesired(systemSymbolName: "arrow.left.to.line") - self.menuMoveSplitDividerRight?.setImageIfDesired(systemSymbolName: "arrow.right.to.line") - self.menuFloatOnTop?.setImageIfDesired(systemSymbolName: "square.filled.on.square") - self.menuFindParent?.setImageIfDesired(systemSymbolName: "text.page.badge.magnifyingglass") - } - - /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. - private func syncMenuShortcuts(_ config: Ghostty.Config) { - guard ghostty.readiness == .ready else { return } - - syncMenuShortcut(config, action: "check_for_updates", menuItem: self.menuCheckForUpdates) - syncMenuShortcut(config, action: "open_config", menuItem: self.menuOpenConfig) - syncMenuShortcut(config, action: "reload_config", menuItem: self.menuReloadConfig) - syncMenuShortcut(config, action: "quit", menuItem: self.menuQuit) - - syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow) - syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab) - syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose) - syncMenuShortcut(config, action: "close_tab", menuItem: self.menuCloseTab) - syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow) - syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows) - syncMenuShortcut(config, action: "new_split:right", menuItem: self.menuSplitRight) - syncMenuShortcut(config, action: "new_split:left", menuItem: self.menuSplitLeft) - syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown) - syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp) - - syncMenuShortcut(config, action: "undo", menuItem: self.menuUndo) - syncMenuShortcut(config, action: "redo", menuItem: self.menuRedo) - syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy) - syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) - syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) - syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) - syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind) - syncMenuShortcut(config, action: "search_selection", menuItem: self.menuSelectionForFind) - syncMenuShortcut(config, action: "scroll_to_selection", menuItem: self.menuScrollToSelection) - syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext) - syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious) - - syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) - syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit) - syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit) - syncMenuShortcut(config, action: "goto_split:up", menuItem: self.menuSelectSplitAbove) - syncMenuShortcut(config, action: "goto_split:down", menuItem: self.menuSelectSplitBelow) - syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft) - syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight) - syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp) - syncMenuShortcut(config, action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown) - syncMenuShortcut(config, action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight) - syncMenuShortcut(config, action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft) - syncMenuShortcut(config, action: "equalize_splits", menuItem: self.menuEqualizeSplits) - syncMenuShortcut(config, action: "reset_window_size", menuItem: self.menuReturnToDefaultSize) - - syncMenuShortcut(config, action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize) - syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) - syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize) - syncMenuShortcut(config, action: "prompt_surface_title", menuItem: self.menuChangeTitle) - syncMenuShortcut(config, action: "prompt_tab_title", menuItem: self.menuChangeTabTitle) - syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) - syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility) - syncMenuShortcut(config, action: "toggle_window_float_on_top", menuItem: self.menuFloatOnTop) - syncMenuShortcut(config, action: "inspector:toggle", menuItem: self.menuTerminalInspector) - syncMenuShortcut(config, action: "toggle_command_palette", menuItem: self.menuCommandPalette) - - syncMenuShortcut(config, action: "toggle_secure_input", menuItem: self.menuSecureInput) - - // This menu item is NOT synced with the configuration because it disables macOS - // global fullscreen keyboard shortcut. The shortcut in the Ghostty config will continue - // to work but it won't be reflected in the menu item. - // - // syncMenuShortcut(config, action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen) - - // Dock menu - reloadDockMenu() - } - - /// Syncs a single menu shortcut for the given action. The action string is the same - /// action string used for the Ghostty configuration. - private func syncMenuShortcut(_ config: Ghostty.Config, action: String, menuItem: NSMenuItem?) { - guard let menu = menuItem else { return } - guard let shortcut = config.keyboardShortcut(for: action) else { - // No shortcut, clear the menu item - menu.keyEquivalent = "" - menu.keyEquivalentModifierMask = [] - return - } - - menu.keyEquivalent = shortcut.key.character.description - menu.keyEquivalentModifierMask = .init(swiftUIFlags: shortcut.modifiers) - } - // MARK: Notifications and Events /// This handles events from the NSEvent.addLocalEventMonitor. We use this so we can get @@ -873,10 +755,10 @@ class AppDelegate: NSObject, // configuration. This is the only way to carefully control whether macOS invokes the // state restoration system. switch config.windowSaveState { - case "never": UserDefaults.standard.setValue(false, forKey: "NSQuitAlwaysKeepsWindows") - case "always": UserDefaults.standard.setValue(true, forKey: "NSQuitAlwaysKeepsWindows") + case "never": UserDefaults.ghostty.setValue(false, forKey: "NSQuitAlwaysKeepsWindows") + case "always": UserDefaults.ghostty.setValue(true, forKey: "NSQuitAlwaysKeepsWindows") case "default": fallthrough - default: UserDefaults.standard.removeObject(forKey: "NSQuitAlwaysKeepsWindows") + default: UserDefaults.ghostty.removeObject(forKey: "NSQuitAlwaysKeepsWindows") } // Sync our auto-update settings. If SUEnableAutomaticChecks (in our Info.plist) is @@ -961,9 +843,9 @@ class AppDelegate: NSObject, private func updateAppIcon(from config: Ghostty.Config) { // Since this is called after `DockTilePlugin` has been running, // clean it up here to trigger a correct update of the current config. - UserDefaults.standard.removeObject(forKey: "CustomGhosttyIcon") + UserDefaults.ghostty.removeObject(forKey: "CustomGhosttyIcon") DispatchQueue.global().async { - UserDefaults.standard.appIcon = AppIcon(config: config) + UserDefaults.ghostty.appIcon = AppIcon(config: config) DistributedNotificationCenter.default() .postNotificationName(.ghosttyIconDidChange, object: nil, userInfo: nil, deliverImmediately: true) } @@ -1038,17 +920,6 @@ class AppDelegate: NSObject, return nil } - // MARK: - Dock Menu - - private func reloadDockMenu() { - let newWindow = NSMenuItem(title: "New Window", action: #selector(newWindow), keyEquivalent: "") - let newTab = NSMenuItem(title: "New Tab", action: #selector(newTab), keyEquivalent: "") - - dockMenu.removeAllItems() - dockMenu.addItem(newWindow) - dockMenu.addItem(newTab) - } - // MARK: - Global State func setSecureInput(_ mode: Ghostty.SetSecureInput) { @@ -1064,7 +935,7 @@ class AppDelegate: NSObject, input.global.toggle() } self.menuSecureInput?.state = if input.global { .on } else { .off } - UserDefaults.standard.set(input.global, forKey: "SecureInput") + UserDefaults.ghostty.set(input.global, forKey: "SecureInput") } // MARK: - IB Actions @@ -1211,6 +1082,233 @@ class AppDelegate: NSObject, } } +// MARK: Menu + +extension AppDelegate { + /// This is called for the dock right-click menu. + func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { + return dockMenu + } + + private func reloadDockMenu() { + let newWindow = NSMenuItem(title: "New Window", action: #selector(newWindow), keyEquivalent: "") + let newTab = NSMenuItem(title: "New Tab", action: #selector(newTab), keyEquivalent: "") + + dockMenu.removeAllItems() + dockMenu.addItem(newWindow) + dockMenu.addItem(newTab) + } + + /// Setup all the images for our menu items. + private func setupMenuImages() { + // Note: This COULD Be done all in the xib file, but I find it easier to + // modify this stuff as code. + self.menuAbout?.setImageIfDesired(systemSymbolName: "info.circle") + self.menuCheckForUpdates?.setImageIfDesired(systemSymbolName: "square.and.arrow.down") + self.menuOpenConfig?.setImageIfDesired(systemSymbolName: "gear") + self.menuReloadConfig?.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise.rotate.90") + self.menuSecureInput?.setImageIfDesired(systemSymbolName: "lock.display") + self.menuNewWindow?.setImageIfDesired(systemSymbolName: "macwindow.badge.plus") + self.menuNewTab?.setImageIfDesired(systemSymbolName: "macwindow") + self.menuSplitRight?.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled") + self.menuSplitLeft?.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled") + self.menuSplitUp?.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled") + self.menuSplitDown?.setImageIfDesired(systemSymbolName: "rectangle.bottomhalf.inset.filled") + self.menuClose?.setImageIfDesired(systemSymbolName: "xmark") + self.menuPasteSelection?.setImageIfDesired(systemSymbolName: "doc.on.clipboard.fill") + self.menuIncreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.larger") + self.menuResetFontSize?.setImageIfDesired(systemSymbolName: "textformat.size") + self.menuDecreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.smaller") + self.menuCommandPalette?.setImageIfDesired(systemSymbolName: "filemenu.and.selection") + self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal") + self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line") + self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope") + self.menuReadonly?.setImageIfDesired(systemSymbolName: "eye.fill") + self.menuSetAsDefaultTerminal?.setImageIfDesired(systemSymbolName: "star.fill") + self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward") + self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye") + self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right") + self.menuPreviousSplit?.setImageIfDesired(systemSymbolName: "chevron.backward.2") + self.menuNextSplit?.setImageIfDesired(systemSymbolName: "chevron.forward.2") + self.menuEqualizeSplits?.setImageIfDesired(systemSymbolName: "inset.filled.topleft.topright.bottomleft.bottomright.rectangle") + self.menuSelectSplitLeft?.setImageIfDesired(systemSymbolName: "arrow.left") + self.menuSelectSplitRight?.setImageIfDesired(systemSymbolName: "arrow.right") + self.menuSelectSplitAbove?.setImageIfDesired(systemSymbolName: "arrow.up") + self.menuSelectSplitBelow?.setImageIfDesired(systemSymbolName: "arrow.down") + self.menuMoveSplitDividerUp?.setImageIfDesired(systemSymbolName: "arrow.up.to.line") + self.menuMoveSplitDividerDown?.setImageIfDesired(systemSymbolName: "arrow.down.to.line") + self.menuMoveSplitDividerLeft?.setImageIfDesired(systemSymbolName: "arrow.left.to.line") + self.menuMoveSplitDividerRight?.setImageIfDesired(systemSymbolName: "arrow.right.to.line") + self.menuFloatOnTop?.setImageIfDesired(systemSymbolName: "square.filled.on.square") + self.menuFindParent?.setImageIfDesired(systemSymbolName: "text.page.badge.magnifyingglass") + } + + /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. + private func syncMenuShortcuts(_ config: Ghostty.Config) { + guard ghostty.readiness == .ready else { return } + + // Reset our shortcut index since we're about to rebuild all menu bindings. + menuItemsByShortcut.removeAll(keepingCapacity: true) + + syncMenuShortcut(config, action: "check_for_updates", menuItem: self.menuCheckForUpdates) + syncMenuShortcut(config, action: "open_config", menuItem: self.menuOpenConfig) + syncMenuShortcut(config, action: "reload_config", menuItem: self.menuReloadConfig) + syncMenuShortcut(config, action: "quit", menuItem: self.menuQuit) + + syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow) + syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab) + syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose) + syncMenuShortcut(config, action: "close_tab", menuItem: self.menuCloseTab) + syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow) + syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows) + syncMenuShortcut(config, action: "new_split:right", menuItem: self.menuSplitRight) + syncMenuShortcut(config, action: "new_split:left", menuItem: self.menuSplitLeft) + syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown) + syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp) + + syncMenuShortcut(config, action: "undo", menuItem: self.menuUndo) + syncMenuShortcut(config, action: "redo", menuItem: self.menuRedo) + syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy) + syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) + syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) + syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) + syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind) + syncMenuShortcut(config, action: "search_selection", menuItem: self.menuSelectionForFind) + syncMenuShortcut(config, action: "scroll_to_selection", menuItem: self.menuScrollToSelection) + syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext) + syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious) + + syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) + syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit) + syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit) + syncMenuShortcut(config, action: "goto_split:up", menuItem: self.menuSelectSplitAbove) + syncMenuShortcut(config, action: "goto_split:down", menuItem: self.menuSelectSplitBelow) + syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft) + syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight) + syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp) + syncMenuShortcut(config, action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown) + syncMenuShortcut(config, action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight) + syncMenuShortcut(config, action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft) + syncMenuShortcut(config, action: "equalize_splits", menuItem: self.menuEqualizeSplits) + syncMenuShortcut(config, action: "reset_window_size", menuItem: self.menuReturnToDefaultSize) + + syncMenuShortcut(config, action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize) + syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) + syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize) + syncMenuShortcut(config, action: "prompt_surface_title", menuItem: self.menuChangeTitle) + syncMenuShortcut(config, action: "prompt_tab_title", menuItem: self.menuChangeTabTitle) + syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) + syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility) + syncMenuShortcut(config, action: "toggle_window_float_on_top", menuItem: self.menuFloatOnTop) + syncMenuShortcut(config, action: "inspector:toggle", menuItem: self.menuTerminalInspector) + syncMenuShortcut(config, action: "toggle_command_palette", menuItem: self.menuCommandPalette) + + syncMenuShortcut(config, action: "toggle_secure_input", menuItem: self.menuSecureInput) + + // This menu item is NOT synced with the configuration because it disables macOS + // global fullscreen keyboard shortcut. The shortcut in the Ghostty config will continue + // to work but it won't be reflected in the menu item. + // + // syncMenuShortcut(config, action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen) + + // Dock menu + reloadDockMenu() + } + + /// Syncs a single menu shortcut for the given action. The action string is the same + /// action string used for the Ghostty configuration. + private func syncMenuShortcut(_ config: Ghostty.Config, action: String, menuItem: NSMenuItem?) { + guard let menu = menuItem else { return } + + guard let shortcut = config.keyboardShortcut(for: action) else { + // No shortcut, clear the menu item + menu.keyEquivalent = "" + menu.keyEquivalentModifierMask = [] + return + } + + let keyEquivalent = shortcut.key.character.description + let modifierMask = NSEvent.ModifierFlags(swiftUIFlags: shortcut.modifiers) + menu.keyEquivalent = keyEquivalent + menu.keyEquivalentModifierMask = modifierMask + + // Build a direct lookup for key-equivalent dispatch so we don't need to + // linearly walk the full menu hierarchy at event time. + guard let key = MenuShortcutKey( + keyEquivalent: keyEquivalent, + modifiers: modifierMask + ) else { + return + } + + // Later registrations intentionally override earlier ones for the same key. + menuItemsByShortcut[key] = .init(menu) + } + + /// Attempts to perform a menu key equivalent only for menu items that represent + /// Ghostty keybind actions. This is important because it lets our surface dispatch + /// bindings through the menu so they flash but also lets our surface override macOS built-ins + /// like Cmd+H. + func performGhosttyBindingMenuKeyEquivalent(with event: NSEvent) -> Bool { + // Convert this event into the same normalized lookup key we use when + // syncing menu shortcuts from configuration. + guard let key = MenuShortcutKey(event: event) else { + return false + } + + // If we don't have an entry for this key combo, no Ghostty-owned + // menu shortcut exists for this event. + guard let weakItem = menuItemsByShortcut[key] else { + return false + } + + // Weak references can be nil if a menu item was deallocated after sync. + guard let item = weakItem.value else { + menuItemsByShortcut.removeValue(forKey: key) + return false + } + + guard let parentMenu = item.menu else { + return false + } + + // Keep enablement state fresh in case menu validation hasn't run yet. + parentMenu.update() + guard item.isEnabled else { + return false + } + + let index = parentMenu.index(of: item) + guard index >= 0 else { + return false + } + + parentMenu.performActionForItem(at: index) + return true + } + + /// Hashable key for a menu shortcut match, normalized for quick lookup. + private struct MenuShortcutKey: Hashable { + private static let shortcutModifiers: NSEvent.ModifierFlags = [.shift, .control, .option, .command] + + private let keyEquivalent: String + private let modifiersRawValue: UInt + + init?(keyEquivalent: String, modifiers: NSEvent.ModifierFlags) { + let normalized = keyEquivalent.lowercased() + guard !normalized.isEmpty else { return nil } + + self.keyEquivalent = normalized + self.modifiersRawValue = modifiers.intersection(Self.shortcutModifiers).rawValue + } + + init?(event: NSEvent) { + guard let keyEquivalent = event.charactersIgnoringModifiers else { return nil } + self.init(keyEquivalent: keyEquivalent, modifiers: event.modifierFlags) + } + } +} + // MARK: Floating Windows extension AppDelegate { @@ -1231,7 +1329,7 @@ extension AppDelegate { } @IBAction func useAsDefault(_ sender: NSMenuItem) { - let ud = UserDefaults.standard + let ud = UserDefaults.ghostty let key = TerminalWindow.defaultLevelKey if menuFloatOnTop?.state == .on { ud.set(NSWindow.Level.floating, forKey: key) diff --git a/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift b/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift new file mode 100644 index 000000000..983217d60 --- /dev/null +++ b/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift @@ -0,0 +1,351 @@ +import AppKit + +// Application-level Cocoa scripting hooks for the Ghostty AppleScript dictionary. +// +// Cocoa scripting is mostly convention-based: we do not register handlers in +// code, we expose Objective-C selectors with names Cocoa derives from +// `Ghostty.sdef`. +// +// In practical terms: +// - An `<element>` in `sdef` maps to an ObjC collection accessor. +// - Unique-ID element lookup maps to `valueIn...WithUniqueID:`. +// - Some `<command>` declarations map to `handle...ScriptCommand:`. +// +// This file implements the selectors Cocoa expects on `NSApplication`, which is +// the runtime object behind the `application` class in `Ghostty.sdef`. + +// MARK: - Windows + +@MainActor +extension NSApplication { + /// Backing collection for `application.windows`. + /// + /// We expose one scripting window per native tab group so scripts see the + /// expected window/tab hierarchy instead of one AppKit window per tab. + /// + /// Required selector name from the `sdef` element key: `scriptWindows`. + /// + /// Cocoa scripting calls this whenever AppleScript evaluates a window list, + /// such as `windows`, `window 1`, or `every window whose ...`. + @objc(scriptWindows) + var scriptWindows: [ScriptWindow] { + guard isAppleScriptEnabled else { return [] } + + // AppKit exposes one NSWindow per tab. AppleScript users expect one + // top-level window object containing multiple tabs, so we dedupe tab + // siblings into a single ScriptWindow. + var seen: Set<ObjectIdentifier> = [] + var result: [ScriptWindow] = [] + + for controller in orderedTerminalControllers { + // Collapse each controller to one canonical representative for the + // whole tab group. Standalone windows map to themselves. + guard let primary = primaryTerminalController(for: controller) else { + continue + } + + let primaryControllerID = ObjectIdentifier(primary) + guard seen.insert(primaryControllerID).inserted else { + // Another tab from this group already created the scripting + // window object. + continue + } + + result.append(ScriptWindow(primaryController: primary)) + } + + return result + } + + /// Exposed as the AppleScript `front window` property. + /// + /// `scriptWindows` is already ordered front-to-back, so the first item is + /// the frontmost logical Ghostty window. + @objc(frontWindow) + var frontWindow: ScriptWindow? { + guard isAppleScriptEnabled else { return nil } + return scriptWindows.first + } + + /// Enables AppleScript unique-ID lookup for window references. + /// + /// Required selector name pattern for element key `scriptWindows`: + /// `valueInScriptWindowsWithUniqueID:`. + /// + /// Cocoa calls this when a script resolves `window id "..."`. + /// Returning `nil` makes the object specifier fail naturally. + @objc(valueInScriptWindowsWithUniqueID:) + func valueInScriptWindows(uniqueID: String) -> ScriptWindow? { + guard isAppleScriptEnabled else { return nil } + return scriptWindows.first(where: { $0.stableID == uniqueID }) + } +} + +// MARK: - Terminals + +@MainActor +extension NSApplication { + /// Backing collection for `application.terminals`. + /// + /// Required selector name: `terminals`. + @objc(terminals) + var terminals: [ScriptTerminal] { + guard isAppleScriptEnabled else { return [] } + return allSurfaceViews.map(ScriptTerminal.init) + } + + /// Enables AppleScript unique-ID lookup for terminal references. + /// + /// Required selector name pattern for element `terminals`: + /// `valueInTerminalsWithUniqueID:`. + /// + /// This is what lets scripts do stable references like + /// `terminal id "..."` even as windows/tabs change. + @objc(valueInTerminalsWithUniqueID:) + func valueInTerminals(uniqueID: String) -> ScriptTerminal? { + guard isAppleScriptEnabled else { return nil } + return allSurfaceViews + .first(where: { $0.id.uuidString == uniqueID }) + .map(ScriptTerminal.init) + } +} + +// MARK: - Commands + +@MainActor +extension NSApplication { + /// Handler for the `perform action` AppleScript command. + /// + /// Required selector name from the command in `sdef`: + /// `handlePerformActionScriptCommand:`. + /// + /// Cocoa scripting parses script syntax and provides: + /// - `directParameter`: the command string (`perform action "..."`). + /// - `evaluatedArguments["on"]`: the target terminal (`... on terminal ...`). + /// + /// We return a Bool to match the command's declared result type. + @objc(handlePerformActionScriptCommand:) + func handlePerformActionScriptCommand(_ command: NSScriptCommand) -> NSNumber? { + guard validateScript(command: command) else { return nil } + + guard let action = command.directParameter as? String else { + command.scriptErrorNumber = errAEParamMissed + command.scriptErrorString = "Missing action string." + return nil + } + + guard let terminal = command.evaluatedArguments?["on"] as? ScriptTerminal else { + command.scriptErrorNumber = errAEParamMissed + command.scriptErrorString = "Missing terminal target." + return nil + } + + return NSNumber(value: terminal.perform(action: action)) + } + + /// Handler for creating a reusable AppleScript surface configuration object. + @objc(handleNewSurfaceConfigurationScriptCommand:) + func handleNewSurfaceConfigurationScriptCommand(_ command: NSScriptCommand) -> NSDictionary? { + guard validateScript(command: command) else { return nil } + + do { + let configuration = try Ghostty.SurfaceConfiguration( + scriptRecord: command.evaluatedArguments?["configuration"] as? NSDictionary + ) + return configuration.dictionaryRepresentation + } catch { + command.scriptErrorNumber = errAECoercionFail + command.scriptErrorString = error.localizedDescription + return nil + } + } + + /// Handler for the `new window` AppleScript command. + /// + /// Required selector name from the command in `sdef`: + /// `handleNewWindowScriptCommand:`. + /// + /// Accepts an optional reusable surface configuration object. + /// + /// Returns the newly created scripting window object. + @objc(handleNewWindowScriptCommand:) + func handleNewWindowScriptCommand(_ command: NSScriptCommand) -> ScriptWindow? { + guard validateScript(command: command) else { return nil } + + guard let appDelegate = delegate as? AppDelegate else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = "Ghostty app delegate is unavailable." + return nil + } + + let baseConfig: Ghostty.SurfaceConfiguration? + if let scriptRecord = command.evaluatedArguments?["configuration"] as? NSDictionary { + do { + baseConfig = try Ghostty.SurfaceConfiguration(scriptRecord: scriptRecord) + } catch { + command.scriptErrorNumber = errAECoercionFail + command.scriptErrorString = error.localizedDescription + return nil + } + } else { + baseConfig = nil + } + + let controller = TerminalController.newWindow( + appDelegate.ghostty, + withBaseConfig: baseConfig + ) + let createdWindowID = ScriptWindow.stableID(primaryController: controller) + + if let scriptWindow = scriptWindows.first(where: { $0.stableID == createdWindowID }) { + return scriptWindow + } + + // Fall back to wrapping the created controller if AppKit window ordering + // has not refreshed yet in the current run loop. + return ScriptWindow(primaryController: controller) + } + + /// Handler for the `quit` AppleScript command. + /// + /// Required selector name from the command in `sdef`: + /// `handleQuitScriptCommand:`. + @objc(handleQuitScriptCommand:) + func handleQuitScriptCommand(_ command: NSScriptCommand) { + guard validateScript(command: command) else { return } + terminate(nil) + } + + /// Handler for the `new tab` AppleScript command. + /// + /// Required selector name from the command in `sdef`: + /// `handleNewTabScriptCommand:`. + /// + /// Accepts an optional target window and optional surface configuration. + /// If no window is provided, this mirrors App Intents and uses the + /// preferred parent window. + /// + /// Returns the newly created scripting tab object. + @objc(handleNewTabScriptCommand:) + func handleNewTabScriptCommand(_ command: NSScriptCommand) -> ScriptTab? { + guard validateScript(command: command) else { return nil } + + guard let appDelegate = delegate as? AppDelegate else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = "Ghostty app delegate is unavailable." + return nil + } + + let baseConfig: Ghostty.SurfaceConfiguration? + if let scriptRecord = command.evaluatedArguments?["configuration"] as? NSDictionary { + do { + baseConfig = try Ghostty.SurfaceConfiguration(scriptRecord: scriptRecord) + } catch { + command.scriptErrorNumber = errAECoercionFail + command.scriptErrorString = error.localizedDescription + return nil + } + } else { + baseConfig = nil + } + + let targetWindow = command.evaluatedArguments?["window"] as? ScriptWindow + let parentWindow: NSWindow? + if let targetWindow { + guard let resolvedWindow = targetWindow.preferredParentWindow else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = "Target window is no longer available." + return nil + } + + parentWindow = resolvedWindow + } else { + parentWindow = TerminalController.preferredParent?.window + } + + guard let createdController = TerminalController.newTab( + appDelegate.ghostty, + from: parentWindow, + withBaseConfig: baseConfig + ) else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = "Failed to create tab." + return nil + } + + let createdTabID = ScriptTab.stableID(controller: createdController) + + if let targetWindow, + let scriptTab = targetWindow.valueInTabs(uniqueID: createdTabID) { + return scriptTab + } + + for scriptWindow in scriptWindows { + if let scriptTab = scriptWindow.valueInTabs(uniqueID: createdTabID) { + return scriptTab + } + } + + // Fall back to wrapping the created controller if AppKit tab-group + // bookkeeping has not fully refreshed in the current run loop. + let fallbackWindow = ScriptWindow(primaryController: createdController) + return ScriptTab(window: fallbackWindow, controller: createdController) + } +} + +// MARK: - Private Helpers + +@MainActor +extension NSApplication { + /// Whether Ghostty should currently accept AppleScript interactions. + var isAppleScriptEnabled: Bool { + guard let appDelegate = delegate as? AppDelegate else { return true } + return appDelegate.ghostty.config.macosAppleScript + } + + /// Applies a consistent error when scripting is disabled by configuration. + @discardableResult + func validateScript(command: NSScriptCommand) -> Bool { + guard isAppleScriptEnabled else { + command.scriptErrorNumber = errAEEventNotPermitted + command.scriptErrorString = "AppleScript is disabled by the macos-applescript configuration." + return false + } + + return true + } + + /// Discovers all currently alive terminal surfaces across normal and quick + /// terminal windows. This powers both terminal enumeration and ID lookup. + fileprivate var allSurfaceViews: [Ghostty.SurfaceView] { + allTerminalControllers + .flatMap { $0.surfaceTree.root?.leaves() ?? [] } + } + + /// All terminal controllers in undefined order. + fileprivate var allTerminalControllers: [BaseTerminalController] { + NSApp.windows.compactMap { $0.windowController as? BaseTerminalController } + } + + /// All terminal controllers in front-to-back order. + fileprivate var orderedTerminalControllers: [BaseTerminalController] { + NSApp.orderedWindows.compactMap { $0.windowController as? BaseTerminalController } + } + + /// Identifies the primary tab controller for a window's tab group. + /// + /// This gives us one stable representative for all tabs in the same native + /// AppKit tab group. + /// + /// For standalone windows this returns the window's controller directly. + /// For tabbed windows, "primary" is currently the first controller in the + /// tab group's ordered windows list. + fileprivate func primaryTerminalController(for controller: BaseTerminalController) -> BaseTerminalController? { + guard let window = controller.window else { return nil } + guard let tabGroup = window.tabGroup else { return controller } + + return tabGroup.windows + .compactMap { $0.windowController as? BaseTerminalController } + .first + } +} diff --git a/macos/Sources/Features/AppleScript/Ghostty.Input.Mods+AppleScript.swift b/macos/Sources/Features/AppleScript/Ghostty.Input.Mods+AppleScript.swift new file mode 100644 index 000000000..72a274c08 --- /dev/null +++ b/macos/Sources/Features/AppleScript/Ghostty.Input.Mods+AppleScript.swift @@ -0,0 +1,18 @@ +extension Ghostty.Input.Mods { + /// Parses a comma-separated modifier string into `Ghostty.Input.Mods`. + /// + /// Recognized names: `shift`, `control`, `option`, `command`. + /// Returns `nil` if any unrecognized modifier name is encountered. + init?(scriptModifiers string: String) { + self = [] + for part in string.split(separator: ",") { + switch part.trimmingCharacters(in: .whitespaces).lowercased() { + case "shift": insert(.shift) + case "control": insert(.ctrl) + case "option": insert(.alt) + case "command": insert(.super) + default: return nil + } + } + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptInputTextCommand.swift b/macos/Sources/Features/AppleScript/ScriptInputTextCommand.swift new file mode 100644 index 000000000..9662de343 --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptInputTextCommand.swift @@ -0,0 +1,41 @@ +import AppKit + +/// Handler for the `input text` AppleScript command defined in `Ghostty.sdef`. +/// +/// Cocoa scripting instantiates this class because the command's `<cocoa>` element +/// specifies `class="GhosttyScriptInputTextCommand"`. The runtime calls +/// `performDefaultImplementation()` to execute the command. +@MainActor +@objc(GhosttyScriptInputTextCommand) +final class ScriptInputTextCommand: NSScriptCommand { + override func performDefaultImplementation() -> Any? { + guard NSApp.validateScript(command: self) else { return nil } + + guard let text = directParameter as? String else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = "Missing text to input." + return nil + } + + guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = "Missing terminal target." + return nil + } + + guard let surfaceView = terminal.surfaceView else { + scriptErrorNumber = errAEEventFailed + scriptErrorString = "Terminal surface is no longer available." + return nil + } + + guard let surface = surfaceView.surfaceModel else { + scriptErrorNumber = errAEEventFailed + scriptErrorString = "Terminal surface model is not available." + return nil + } + + surface.sendText(text) + return nil + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptKeyEventCommand.swift b/macos/Sources/Features/AppleScript/ScriptKeyEventCommand.swift new file mode 100644 index 000000000..0091098c5 --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptKeyEventCommand.swift @@ -0,0 +1,76 @@ +import AppKit + +/// Handler for the `send key` AppleScript command defined in `Ghostty.sdef`. +/// +/// Cocoa scripting instantiates this class because the command's `<cocoa>` element +/// specifies `class="GhosttyScriptKeyEventCommand"`. The runtime calls +/// `performDefaultImplementation()` to execute the command. +@MainActor +@objc(GhosttyScriptKeyEventCommand) +final class ScriptKeyEventCommand: NSScriptCommand { + override func performDefaultImplementation() -> Any? { + guard NSApp.validateScript(command: self) else { return nil } + + guard let keyName = directParameter as? String else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = "Missing key name." + return nil + } + + guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = "Missing terminal target." + return nil + } + + guard let surfaceView = terminal.surfaceView else { + scriptErrorNumber = errAEEventFailed + scriptErrorString = "Terminal surface is no longer available." + return nil + } + + guard let surface = surfaceView.surfaceModel else { + scriptErrorNumber = errAEEventFailed + scriptErrorString = "Terminal surface model is not available." + return nil + } + + guard let key = Ghostty.Input.Key(rawValue: keyName) else { + scriptErrorNumber = errAECoercionFail + scriptErrorString = "Unknown key name: \(keyName)" + return nil + } + + let action: Ghostty.Input.Action + if let actionCode = evaluatedArguments?["action"] as? UInt32 { + switch actionCode { + case "GIpr".fourCharCode: action = .press + case "GIrl".fourCharCode: action = .release + default: action = .press + } + } else { + action = .press + } + + let mods: Ghostty.Input.Mods + if let modsString = evaluatedArguments?["modifiers"] as? String { + guard let parsed = Ghostty.Input.Mods(scriptModifiers: modsString) else { + scriptErrorNumber = errAECoercionFail + scriptErrorString = "Unknown modifier in: \(modsString)" + return nil + } + mods = parsed + } else { + mods = [] + } + + let keyEvent = Ghostty.Input.KeyEvent( + key: key, + action: action, + mods: mods + ) + surface.sendKeyEvent(keyEvent) + + return nil + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptMouseButtonCommand.swift b/macos/Sources/Features/AppleScript/ScriptMouseButtonCommand.swift new file mode 100644 index 000000000..15fe0fbce --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptMouseButtonCommand.swift @@ -0,0 +1,95 @@ +import AppKit + +/// Handler for the `send mouse button` AppleScript command defined in `Ghostty.sdef`. +/// +/// Cocoa scripting instantiates this class because the command's `<cocoa>` element +/// specifies `class="GhosttyScriptMouseButtonCommand"`. The runtime calls +/// `performDefaultImplementation()` to execute the command. +@MainActor +@objc(GhosttyScriptMouseButtonCommand) +final class ScriptMouseButtonCommand: NSScriptCommand { + override func performDefaultImplementation() -> Any? { + guard NSApp.validateScript(command: self) else { return nil } + + guard let buttonCode = directParameter as? UInt32, + let button = ScriptMouseButtonValue(code: buttonCode) else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = "Missing or unknown mouse button." + return nil + } + + guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = "Missing terminal target." + return nil + } + + guard let surfaceView = terminal.surfaceView else { + scriptErrorNumber = errAEEventFailed + scriptErrorString = "Terminal surface is no longer available." + return nil + } + + guard let surface = surfaceView.surfaceModel else { + scriptErrorNumber = errAEEventFailed + scriptErrorString = "Terminal surface model is not available." + return nil + } + + let action: Ghostty.Input.MouseState + if let actionCode = evaluatedArguments?["action"] as? UInt32 { + switch actionCode { + case "GIpr".fourCharCode: action = .press + case "GIrl".fourCharCode: action = .release + default: action = .press + } + } else { + action = .press + } + + let mods: Ghostty.Input.Mods + if let modsString = evaluatedArguments?["modifiers"] as? String { + guard let parsed = Ghostty.Input.Mods(scriptModifiers: modsString) else { + scriptErrorNumber = errAECoercionFail + scriptErrorString = "Unknown modifier in: \(modsString)" + return nil + } + mods = parsed + } else { + mods = [] + } + + let mouseEvent = Ghostty.Input.MouseButtonEvent( + action: action, + button: button.ghosttyButton, + mods: mods + ) + surface.sendMouseButton(mouseEvent) + + return nil + } +} + +/// Four-character codes matching the `mouse button` enumeration in `Ghostty.sdef`. +private enum ScriptMouseButtonValue { + case left + case right + case middle + + init?(code: UInt32) { + switch code { + case "GMlf".fourCharCode: self = .left + case "GMrt".fourCharCode: self = .right + case "GMmd".fourCharCode: self = .middle + default: return nil + } + } + + var ghosttyButton: Ghostty.Input.MouseButton { + switch self { + case .left: .left + case .right: .right + case .middle: .middle + } + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptMousePosCommand.swift b/macos/Sources/Features/AppleScript/ScriptMousePosCommand.swift new file mode 100644 index 000000000..a044c3b2d --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptMousePosCommand.swift @@ -0,0 +1,65 @@ +import AppKit + +/// Handler for the `send mouse position` AppleScript command defined in `Ghostty.sdef`. +/// +/// Cocoa scripting instantiates this class because the command's `<cocoa>` element +/// specifies `class="GhosttyScriptMousePosCommand"`. The runtime calls +/// `performDefaultImplementation()` to execute the command. +@MainActor +@objc(GhosttyScriptMousePosCommand) +final class ScriptMousePosCommand: NSScriptCommand { + override func performDefaultImplementation() -> Any? { + guard NSApp.validateScript(command: self) else { return nil } + + guard let x = evaluatedArguments?["x"] as? Double else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = "Missing x position." + return nil + } + + guard let y = evaluatedArguments?["y"] as? Double else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = "Missing y position." + return nil + } + + guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = "Missing terminal target." + return nil + } + + guard let surfaceView = terminal.surfaceView else { + scriptErrorNumber = errAEEventFailed + scriptErrorString = "Terminal surface is no longer available." + return nil + } + + guard let surface = surfaceView.surfaceModel else { + scriptErrorNumber = errAEEventFailed + scriptErrorString = "Terminal surface model is not available." + return nil + } + + let mods: Ghostty.Input.Mods + if let modsString = evaluatedArguments?["modifiers"] as? String { + guard let parsed = Ghostty.Input.Mods(scriptModifiers: modsString) else { + scriptErrorNumber = errAECoercionFail + scriptErrorString = "Unknown modifier in: \(modsString)" + return nil + } + mods = parsed + } else { + mods = [] + } + + let mousePosEvent = Ghostty.Input.MousePosEvent( + x: x, + y: y, + mods: mods + ) + surface.sendMousePos(mousePosEvent) + + return nil + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptMouseScrollCommand.swift b/macos/Sources/Features/AppleScript/ScriptMouseScrollCommand.swift new file mode 100644 index 000000000..083937eaf --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptMouseScrollCommand.swift @@ -0,0 +1,71 @@ +import AppKit + +/// Handler for the `send mouse scroll` AppleScript command defined in `Ghostty.sdef`. +/// +/// Cocoa scripting instantiates this class because the command's `<cocoa>` element +/// specifies `class="GhosttyScriptMouseScrollCommand"`. The runtime calls +/// `performDefaultImplementation()` to execute the command. +@MainActor +@objc(GhosttyScriptMouseScrollCommand) +final class ScriptMouseScrollCommand: NSScriptCommand { + override func performDefaultImplementation() -> Any? { + guard NSApp.validateScript(command: self) else { return nil } + + guard let x = evaluatedArguments?["x"] as? Double else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = "Missing x scroll delta." + return nil + } + + guard let y = evaluatedArguments?["y"] as? Double else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = "Missing y scroll delta." + return nil + } + + guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = "Missing terminal target." + return nil + } + + guard let surfaceView = terminal.surfaceView else { + scriptErrorNumber = errAEEventFailed + scriptErrorString = "Terminal surface is no longer available." + return nil + } + + guard let surface = surfaceView.surfaceModel else { + scriptErrorNumber = errAEEventFailed + scriptErrorString = "Terminal surface model is not available." + return nil + } + + let precision = evaluatedArguments?["precision"] as? Bool ?? false + + let momentum: Ghostty.Input.Momentum + if let momentumCode = evaluatedArguments?["momentum"] as? UInt32 { + switch momentumCode { + case "SMno".fourCharCode: momentum = .none + case "SMbg".fourCharCode: momentum = .began + case "SMch".fourCharCode: momentum = .changed + case "SMen".fourCharCode: momentum = .ended + case "SMcn".fourCharCode: momentum = .cancelled + case "SMmb".fourCharCode: momentum = .mayBegin + case "SMst".fourCharCode: momentum = .stationary + default: momentum = .none + } + } else { + momentum = .none + } + + let scrollEvent = Ghostty.Input.MouseScrollEvent( + x: x, + y: y, + mods: .init(precision: precision, momentum: momentum) + ) + surface.sendMouseScroll(scrollEvent) + + return nil + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptRecord.swift b/macos/Sources/Features/AppleScript/ScriptRecord.swift new file mode 100644 index 000000000..7c81b8e29 --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptRecord.swift @@ -0,0 +1,29 @@ +import Cocoa + +/// Protocol to more easily implement AppleScript records in Swift. +protocol ScriptRecord { + /// Initialize a default record. + init() + + /// Initialize a record from the raw value from AppleScript. + init(scriptRecord: NSDictionary?) throws + + /// Encode into the dictionary form for AppleScript. + var dictionaryRepresentation: NSDictionary { get } +} + +/// An error that can be thrown by `ScriptRecord.init(scriptRecord:)`. Any localized error +/// can be thrown but this is a common one. +enum RecordParseError: LocalizedError { + case invalidType(parameter: String, expected: String) + case invalidValue(parameter: String, message: String) + + var errorDescription: String? { + switch self { + case .invalidType(let parameter, let expected): + return "\(parameter) must be \(expected)." + case .invalidValue(let parameter, let message): + return "\(parameter) \(message)." + } + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptSurfaceConfiguration.swift b/macos/Sources/Features/AppleScript/ScriptSurfaceConfiguration.swift new file mode 100644 index 000000000..dfa60da41 --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptSurfaceConfiguration.swift @@ -0,0 +1,140 @@ +import Foundation + +/// AppleScript record support for `Ghostty.SurfaceConfiguration`. +/// +/// This keeps scripting conversion at the data-structure boundary so AppleScript +/// can pass records by value (`new surface configuration`, assign, copy, mutate) +/// without introducing an additional wrapper type. +extension Ghostty.SurfaceConfiguration: ScriptRecord { + init(scriptRecord source: NSDictionary?) throws { + self.init() + + guard let source else { + return + } + + guard let raw = source as? [String: Any] else { + throw RecordParseError.invalidType(parameter: "configuration", expected: "a surface configuration record") + } + + if let rawFontSize = raw["fontSize"] { + guard let number = rawFontSize as? NSNumber else { + throw RecordParseError.invalidType(parameter: "font size", expected: "a number") + } + + let value = number.doubleValue + guard value.isFinite else { + throw RecordParseError.invalidValue(parameter: "font size", message: "must be a finite number") + } + + if value < 0 { + throw RecordParseError.invalidValue(parameter: "font size", message: "must be a positive number") + } + + if value > 0 { + fontSize = Float32(value) + } + } + + if let rawWorkingDirectory = raw["workingDirectory"] { + guard let workingDirectory = rawWorkingDirectory as? String else { + throw RecordParseError.invalidType(parameter: "initial working directory", expected: "text") + } + + if !workingDirectory.isEmpty { + self.workingDirectory = workingDirectory + } + } + + if let rawCommand = raw["command"] { + guard let command = rawCommand as? String else { + throw RecordParseError.invalidType(parameter: "command", expected: "text") + } + + if !command.isEmpty { + self.command = command + } + } + + if let rawInitialInput = raw["initialInput"] { + guard let initialInput = rawInitialInput as? String else { + throw RecordParseError.invalidType(parameter: "initial input", expected: "text") + } + + if !initialInput.isEmpty { + self.initialInput = initialInput + } + } + + if let rawWaitAfterCommand = raw["waitAfterCommand"] { + if let boolValue = rawWaitAfterCommand as? Bool { + waitAfterCommand = boolValue + } else if let numericValue = rawWaitAfterCommand as? NSNumber { + waitAfterCommand = numericValue.boolValue + } else { + throw RecordParseError.invalidType(parameter: "wait after command", expected: "boolean") + } + } + + if let assignments = raw["environmentVariables"] as? [String], !assignments.isEmpty { + environmentVariables = try Self.parseScriptEnvironmentAssignments(assignments) + } + } + + var dictionaryRepresentation: NSDictionary { + var record: [String: Any] = [ + "fontSize": 0, + "workingDirectory": "", + "command": "", + "initialInput": "", + "waitAfterCommand": false, + "environmentVariables": [String](), + ] + + if let fontSize { + record["fontSize"] = NSNumber(value: fontSize) + } + + if let workingDirectory { + record["workingDirectory"] = workingDirectory + } + + if let command { + record["command"] = command + } + + if let initialInput { + record["initialInput"] = initialInput + } + + if waitAfterCommand { + record["waitAfterCommand"] = true + } + + if !environmentVariables.isEmpty { + record["environmentVariables"] = environmentVariables.map { "\($0.key)=\($0.value)" } + } + + return record as NSDictionary + } + + private static func parseScriptEnvironmentAssignments(_ assignments: [String]) throws -> [String: String] { + var result: [String: String] = [:] + + for assignment in assignments { + guard let separator = assignment.firstIndex(of: "=") else { + throw RecordParseError.invalidValue( + parameter: "environment variables", + message: "expected KEY=VALUE, got \"\(assignment)\"" + ) + } + + let key = String(assignment[..<separator]) + let valueStart = assignment.index(after: separator) + let value = String(assignment[valueStart...]) + result[key] = value + } + + return result + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptTab.swift b/macos/Sources/Features/AppleScript/ScriptTab.swift new file mode 100644 index 000000000..97a5ed1e5 --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptTab.swift @@ -0,0 +1,186 @@ +import AppKit + +/// AppleScript-facing wrapper around a single tab in a scripting window. +/// +/// `ScriptWindow.tabs` vends these objects so AppleScript can traverse +/// `window -> tab` without knowing anything about AppKit controllers. +@MainActor +@objc(GhosttyScriptTab) +final class ScriptTab: NSObject { + /// Stable identifier used by AppleScript `tab id "..."` references. + private let stableID: String + + /// Weak back-reference to the scripting window that owns this tab wrapper. + /// + /// We only need this for dynamic properties (`index`, `selected`) and for + /// building an object specifier path. + private weak var window: ScriptWindow? + + /// Live terminal controller for this tab. + /// + /// This can become `nil` if the tab closes while a script is running. + private weak var controller: BaseTerminalController? + + /// Called by `ScriptWindow.tabs` / `ScriptWindow.selectedTab`. + /// + /// The ID is computed once so object specifiers built from this instance keep + /// a consistent tab identity. + init(window: ScriptWindow, controller: BaseTerminalController) { + self.stableID = Self.stableID(controller: controller) + self.window = window + self.controller = controller + } + + /// Exposed as the AppleScript `id` property. + @objc(id) + var idValue: String { + guard NSApp.isAppleScriptEnabled else { return "" } + return stableID + } + + /// Exposed as the AppleScript `title` property. + /// + /// Returns the title of the tab's window. + @objc(title) + var title: String { + guard NSApp.isAppleScriptEnabled else { return "" } + return controller?.window?.title ?? "" + } + + /// Exposed as the AppleScript `index` property. + /// + /// Cocoa scripting expects this to be 1-based for user-facing collections. + @objc(index) + var index: Int { + guard NSApp.isAppleScriptEnabled else { return 0 } + guard let controller else { return 0 } + return window?.tabIndex(for: controller) ?? 0 + } + + /// Exposed as the AppleScript `selected` property. + /// + /// Powers script conditions such as `if selected of tab 1 then ...`. + @objc(selected) + var selected: Bool { + guard NSApp.isAppleScriptEnabled else { return false } + guard let controller else { return false } + return window?.tabIsSelected(controller) ?? false + } + + /// Exposed as the AppleScript `focused terminal` property. + /// + /// Uses the currently focused surface for this tab. + @objc(focusedTerminal) + var focusedTerminal: ScriptTerminal? { + guard NSApp.isAppleScriptEnabled else { return nil } + guard let controller else { return nil } + guard let surface = controller.focusedSurface, + controller.surfaceTree.contains(surface) + else { return nil } + + return ScriptTerminal(surfaceView: surface) + } + + /// Best-effort native window containing this tab. + var parentWindow: NSWindow? { + guard NSApp.isAppleScriptEnabled else { return nil } + return controller?.window + } + + /// Live controller backing this tab wrapper. + var parentController: BaseTerminalController? { + guard NSApp.isAppleScriptEnabled else { return nil } + return controller + } + + /// Exposed as the AppleScript `terminals` element on a tab. + /// + /// Returns all terminal surfaces (split panes) within this tab. + @objc(terminals) + var terminals: [ScriptTerminal] { + guard NSApp.isAppleScriptEnabled else { return [] } + guard let controller else { return [] } + return (controller.surfaceTree.root?.leaves() ?? []) + .map(ScriptTerminal.init) + } + + /// Enables unique-ID lookup for `terminals` references on a tab. + @objc(valueInTerminalsWithUniqueID:) + func valueInTerminals(uniqueID: String) -> ScriptTerminal? { + guard NSApp.isAppleScriptEnabled else { return nil } + guard let controller else { return nil } + return (controller.surfaceTree.root?.leaves() ?? []) + .first(where: { $0.id.uuidString == uniqueID }) + .map(ScriptTerminal.init) + } + + /// Handler for `select tab <tab>`. + @objc(handleSelectTabCommand:) + func handleSelectTab(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard let tabContainerWindow = parentWindow else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = "Tab is no longer available." + return nil + } + + tabContainerWindow.makeKeyAndOrderFront(nil) + return nil + } + + /// Handler for `close tab <tab>`. + @objc(handleCloseTabCommand:) + func handleCloseTab(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard let tabController = parentController else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = "Tab is no longer available." + return nil + } + + if let managedTerminalController = tabController as? TerminalController { + managedTerminalController.closeTabImmediately(registerRedo: false) + return nil + } + + guard let tabContainerWindow = parentWindow else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = "Tab container window is no longer available." + return nil + } + + tabContainerWindow.close() + return nil + } + + /// Provides Cocoa scripting with a canonical "path" back to this object. + override var objectSpecifier: NSScriptObjectSpecifier? { + guard NSApp.isAppleScriptEnabled else { return nil } + guard let window else { return nil } + guard let windowClassDescription = window.classDescription as? NSScriptClassDescription else { + return nil + } + guard let windowSpecifier = window.objectSpecifier else { return nil } + + // This tells Cocoa how to re-find this tab later: + // application -> scriptWindows[id] -> tabs[id]. + return NSUniqueIDSpecifier( + containerClassDescription: windowClassDescription, + containerSpecifier: windowSpecifier, + key: "tabs", + uniqueID: stableID + ) + } +} + +extension ScriptTab { + /// Stable ID for one tab controller. + /// + /// Tab identity belongs to `ScriptTab`, so both tab creation and tab ID + /// lookups in `ScriptWindow` call this helper. + static func stableID(controller: BaseTerminalController) -> String { + "tab-\(ObjectIdentifier(controller).hexString)" + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptTerminal.swift b/macos/Sources/Features/AppleScript/ScriptTerminal.swift new file mode 100644 index 000000000..2cdde382e --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptTerminal.swift @@ -0,0 +1,206 @@ +import AppKit + +/// AppleScript-facing wrapper around a live Ghostty terminal surface. +/// +/// This class is intentionally ObjC-visible because Cocoa scripting resolves +/// AppleScript objects through Objective-C runtime names/selectors, not Swift +/// protocol conformance. +/// +/// Mapping from `Ghostty.sdef`: +/// - `class terminal` -> this class (`@objc(GhosttyAppleScriptTerminal)`). +/// - `property id` -> `@objc(id)` getter below. +/// - `property title` -> `@objc(title)` getter below. +/// - `property working directory` -> `@objc(workingDirectory)` getter below. +/// +/// We keep only a weak reference to the underlying `SurfaceView` so this +/// wrapper never extends the terminal's lifetime. +@MainActor +@objc(GhosttyScriptTerminal) +final class ScriptTerminal: NSObject { + /// Weak reference to the underlying surface. Package-visible so that + /// other AppleScript command handlers (e.g. `ScriptSplitCommand`) can + /// access the live surface without exposing it to ObjC/AppleScript. + weak var surfaceView: Ghostty.SurfaceView? + + init(surfaceView: Ghostty.SurfaceView) { + self.surfaceView = surfaceView + } + + /// Exposed as the AppleScript `id` property. + /// + /// This is a stable UUID string for the life of a surface and is also used + /// by `NSUniqueIDSpecifier` to re-identify a terminal object in scripts. + @objc(id) + var stableID: String { + guard NSApp.isAppleScriptEnabled else { return "" } + return surfaceView?.id.uuidString ?? "" + } + + /// Exposed as the AppleScript `title` property. + @objc(title) + var title: String { + guard NSApp.isAppleScriptEnabled else { return "" } + return surfaceView?.title ?? "" + } + + /// Exposed as the AppleScript `working directory` property. + /// + /// The `sdef` uses a spaced name, but Cocoa scripting maps that to the + /// camel-cased selector name `workingDirectory`. + @objc(workingDirectory) + var workingDirectory: String { + guard NSApp.isAppleScriptEnabled else { return "" } + return surfaceView?.pwd ?? "" + } + + /// Used by command handling (`perform action ... on <terminal>`). + func perform(action: String) -> Bool { + guard NSApp.isAppleScriptEnabled else { return false } + guard let surfaceModel = surfaceView?.surfaceModel else { return false } + return surfaceModel.perform(action: action) + } + + /// Handler for `split <terminal> direction <dir>`. + @objc(handleSplitCommand:) + func handleSplit(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard let surfaceView else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = "Terminal surface is no longer available." + return nil + } + + guard let directionCode = command.evaluatedArguments?["direction"] as? UInt32 else { + command.scriptErrorNumber = errAEParamMissed + command.scriptErrorString = "Missing or unknown split direction." + return nil + } + + guard let direction = ScriptSplitDirection(code: directionCode)?.splitDirection else { + command.scriptErrorNumber = errAEParamMissed + command.scriptErrorString = "Missing or unknown split direction." + return nil + } + + let baseConfig: Ghostty.SurfaceConfiguration? + if let scriptRecord = command.evaluatedArguments?["configuration"] as? NSDictionary { + do { + baseConfig = try Ghostty.SurfaceConfiguration(scriptRecord: scriptRecord) + } catch { + command.scriptErrorNumber = errAECoercionFail + command.scriptErrorString = error.localizedDescription + return nil + } + } else { + baseConfig = nil + } + + guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = "Terminal is not in a splittable window." + return nil + } + + guard let newView = controller.newSplit( + at: surfaceView, + direction: direction, + baseConfig: baseConfig + ) else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = "Failed to create split." + return nil + } + + return ScriptTerminal(surfaceView: newView) + } + + /// Handler for `focus <terminal>`. + @objc(handleFocusCommand:) + func handleFocus(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard let surfaceView else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = "Terminal surface is no longer available." + return nil + } + + guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = "Terminal is not in a window." + return nil + } + + controller.focusSurface(surfaceView) + return nil + } + + /// Handler for `close <terminal>`. + @objc(handleCloseCommand:) + func handleClose(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard let surfaceView else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = "Terminal surface is no longer available." + return nil + } + + guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = "Terminal is not in a window." + return nil + } + + controller.closeSurface(surfaceView, withConfirmation: false) + return nil + } + + /// Provides Cocoa scripting with a canonical "path" back to this object. + /// + /// Without an object specifier, returned terminal objects can't be reliably + /// referenced in follow-up script statements because AppleScript cannot + /// express where the object came from (`application.terminals[id]`). + override var objectSpecifier: NSScriptObjectSpecifier? { + guard NSApp.isAppleScriptEnabled else { return nil } + guard let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else { + return nil + } + + return NSUniqueIDSpecifier( + containerClassDescription: appClassDescription, + containerSpecifier: nil, + key: "terminals", + uniqueID: stableID + ) + } +} + +/// Converts four-character codes from the `split direction` enumeration in `Ghostty.sdef` +/// to `SplitTree.NewDirection` values. +enum ScriptSplitDirection { + case right + case left + case down + case up + + init?(code: UInt32) { + switch code { + case "GSrt".fourCharCode: self = .right + case "GSlf".fourCharCode: self = .left + case "GSdn".fourCharCode: self = .down + case "GSup".fourCharCode: self = .up + default: return nil + } + } + + var splitDirection: SplitTree<Ghostty.SurfaceView>.NewDirection { + switch self { + case .right: .right + case .left: .left + case .down: .down + case .up: .up + } + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptWindow.swift b/macos/Sources/Features/AppleScript/ScriptWindow.swift new file mode 100644 index 000000000..c8e4bc8e6 --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptWindow.swift @@ -0,0 +1,260 @@ +import AppKit + +/// AppleScript-facing wrapper around a logical Ghostty window. +/// +/// In AppKit, each tab is often its own `NSWindow`. AppleScript users, however, +/// expect a single window object containing a list of tabs. +/// +/// `ScriptWindow` is that compatibility layer: +/// - It presents one object per tab group. +/// - It translates tab-group state into `tabs` and `selected tab`. +/// - It exposes stable IDs that Cocoa scripting can resolve later. +@MainActor +@objc(GhosttyScriptWindow) +final class ScriptWindow: NSObject { + /// Stable identifier used by AppleScript `window id "..."` references. + /// + /// We precompute this once so the object keeps a consistent ID for its whole + /// lifetime, even if AppKit window bookkeeping changes after creation. + let stableID: String + + /// Canonical representative for this scripting window's tab group. + /// + /// We intentionally keep only one controller reference; full tab membership + /// is derived lazily from current AppKit state whenever needed. + private weak var primaryController: BaseTerminalController? + + /// `scriptWindows` in `AppDelegate+AppleScript` constructs these objects. + /// + /// `stableID` must match the same identity scheme used by + /// `valueInScriptWindowsWithUniqueID:` so Cocoa can re-resolve object + /// specifiers produced earlier in a script. + init(primaryController: BaseTerminalController) { + self.stableID = Self.stableID(primaryController: primaryController) + self.primaryController = primaryController + } + + /// Exposed as the AppleScript `id` property. + /// + /// This is what scripts read with `id of window ...`. + @objc(id) + var idValue: String { + guard NSApp.isAppleScriptEnabled else { return "" } + return stableID + } + + /// Exposed as the AppleScript `title` property. + /// + /// Returns the title of the window (from the selected/primary controller's NSWindow). + @objc(title) + var title: String { + guard NSApp.isAppleScriptEnabled else { return "" } + return selectedController?.window?.title ?? "" + } + + /// Exposed as the AppleScript `tabs` element. + /// + /// Cocoa asks for this collection when a script evaluates `tabs of window ...` + /// or any tab-filter expression. We build wrappers from live controller state + /// so tab additions/removals are reflected immediately. + @objc(tabs) + var tabs: [ScriptTab] { + guard NSApp.isAppleScriptEnabled else { return [] } + return controllers.map { ScriptTab(window: self, controller: $0) } + } + + /// Exposed as the AppleScript `selected tab` property. + /// + /// This powers expressions like `selected tab of window 1`. + @objc(selectedTab) + var selectedTab: ScriptTab? { + guard NSApp.isAppleScriptEnabled else { return nil } + guard let selectedController else { return nil } + return ScriptTab(window: self, controller: selectedController) + } + + /// Enables unique-ID lookup for `tabs` references. + /// + /// Required selector pattern for the `tabs` element key: + /// `valueInTabsWithUniqueID:`. + /// + /// Cocoa uses this when a script resolves `tab id "..." of window ...`. + @objc(valueInTabsWithUniqueID:) + func valueInTabs(uniqueID: String) -> ScriptTab? { + guard NSApp.isAppleScriptEnabled else { return nil } + guard let controller = controller(tabID: uniqueID) else { return nil } + return ScriptTab(window: self, controller: controller) + } + + /// Exposed as the AppleScript `terminals` element on a window. + /// + /// Returns all terminal surfaces across every tab in this window. + @objc(terminals) + var terminals: [ScriptTerminal] { + guard NSApp.isAppleScriptEnabled else { return [] } + return controllers + .flatMap { $0.surfaceTree.root?.leaves() ?? [] } + .map(ScriptTerminal.init) + } + + /// Enables unique-ID lookup for `terminals` references on a window. + @objc(valueInTerminalsWithUniqueID:) + func valueInTerminals(uniqueID: String) -> ScriptTerminal? { + guard NSApp.isAppleScriptEnabled else { return nil } + return controllers + .flatMap { $0.surfaceTree.root?.leaves() ?? [] } + .first(where: { $0.id.uuidString == uniqueID }) + .map(ScriptTerminal.init) + } + + /// AppleScript tab indexes are 1-based, so we add one to Swift's 0-based + /// array index. + func tabIndex(for controller: BaseTerminalController) -> Int? { + guard NSApp.isAppleScriptEnabled else { return nil } + return controllers.firstIndex(where: { $0 === controller }).map { $0 + 1 } + } + + /// Reports whether a given controller maps to this window's selected tab. + func tabIsSelected(_ controller: BaseTerminalController) -> Bool { + guard NSApp.isAppleScriptEnabled else { return false } + return selectedController === controller + } + + /// Best-effort native window to use as a tab parent for AppleScript commands. + var preferredParentWindow: NSWindow? { + guard NSApp.isAppleScriptEnabled else { return nil } + return selectedController?.window ?? controllers.first?.window + } + + /// Best-effort controller to use for window-scoped AppleScript commands. + var preferredController: BaseTerminalController? { + guard NSApp.isAppleScriptEnabled else { return nil } + return selectedController ?? controllers.first + } + + /// Resolves a previously generated tab ID back to a live controller. + private func controller(tabID: String) -> BaseTerminalController? { + controllers.first(where: { ScriptTab.stableID(controller: $0) == tabID }) + } + + /// Live controller list for this scripting window. + /// + /// We recalculate on every access so AppleScript immediately sees tab-group + /// changes (new tabs, closed tabs, tab moves) without rebuilding all objects. + private var controllers: [BaseTerminalController] { + guard NSApp.isAppleScriptEnabled else { return [] } + guard let primaryController else { return [] } + guard let window = primaryController.window else { return [primaryController] } + + if let tabGroup = window.tabGroup { + let groupControllers = tabGroup.windows.compactMap { + $0.windowController as? BaseTerminalController + } + if !groupControllers.isEmpty { + return groupControllers + } + } + + return [primaryController] + } + + /// Live selected controller for this scripting window. + /// + /// AppKit tracks selected tab on `NSWindowTabGroup.selectedWindow`; for + /// non-tabbed windows we fall back to the primary controller. + private var selectedController: BaseTerminalController? { + guard let primaryController else { return nil } + guard let window = primaryController.window else { return primaryController } + + if let tabGroup = window.tabGroup, + let selectedController = tabGroup.selectedWindow?.windowController as? BaseTerminalController { + return selectedController + } + + return controllers.first + } + + /// Handler for `activate window <window>`. + @objc(handleActivateWindowCommand:) + func handleActivateWindow(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + guard let windowContainer = preferredParentWindow else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = "Window is no longer available." + return nil + } + + windowContainer.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return nil + } + + /// Handler for `close window <window>`. + @objc(handleCloseWindowCommand:) + func handleCloseWindow(_ command: NSScriptCommand) -> Any? { + guard NSApp.validateScript(command: command) else { return nil } + + if let managedTerminalController = preferredController as? TerminalController { + managedTerminalController.closeWindowImmediately() + return nil + } + + guard let windowContainer = preferredParentWindow else { + command.scriptErrorNumber = errAEEventFailed + command.scriptErrorString = "Window is no longer available." + return nil + } + + windowContainer.close() + return nil + } + + /// Provides Cocoa scripting with a canonical "path" back to this object. + /// + /// Without this, Cocoa can return data but cannot reliably build object + /// references for later script statements. This specifier encodes: + /// `application -> scriptWindows[id]`. + override var objectSpecifier: NSScriptObjectSpecifier? { + guard NSApp.isAppleScriptEnabled else { return nil } + guard let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else { + return nil + } + + return NSUniqueIDSpecifier( + containerClassDescription: appClassDescription, + containerSpecifier: nil, + key: "scriptWindows", + uniqueID: stableID + ) + } +} + +extension ScriptWindow { + /// Produces the window-level stable ID from the primary controller. + /// + /// - Tabbed windows are keyed by tab-group identity. + /// - Standalone windows are keyed by window identity. + /// - Detached controllers fall back to controller identity. + static func stableID(primaryController: BaseTerminalController) -> String { + guard let window = primaryController.window else { + return "controller-\(ObjectIdentifier(primaryController).hexString)" + } + + if let tabGroup = window.tabGroup { + return stableID(tabGroup: tabGroup) + } + + return stableID(window: window) + } + + /// Stable ID for a standalone native window. + static func stableID(window: NSWindow) -> String { + "window-\(ObjectIdentifier(window).hexString)" + } + + /// Stable ID for a native AppKit tab group. + static func stableID(tabGroup: NSWindowTabGroup) -> String { + "tab-group-\(ObjectIdentifier(tabGroup).hexString)" + } +} diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index eb28942ee..214ff08d3 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -625,6 +625,8 @@ class QuickTerminalController: BaseTerminalController { window.isOpaque = true window.backgroundColor = .windowBackgroundColor } + + terminalViewContainer?.ghosttyConfigDidChange(ghostty.config, preferredBackgroundColor: nil) } private func showNoNewTabAlert() { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index e744e7c38..56b0b40ad 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -19,10 +19,10 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } let nib = switch config.macosTitlebarStyle { - case "native": "Terminal" - case "hidden": "TerminalHiddenTitlebar" - case "transparent": "TerminalTransparentTitlebar" - case "tabs": + case .native: "Terminal" + case .hidden: "TerminalHiddenTitlebar" + case .transparent: "TerminalTransparentTitlebar" + case .tabs: #if compiler(>=6.2) if #available(macOS 26.0, *) { "TerminalTabsTitlebarTahoe" @@ -32,7 +32,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr #else "TerminalTabsTitlebarVentura" #endif - default: defaultValue } return nib @@ -57,9 +56,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr /// The notification cancellable for focused surface property changes. private var surfaceAppearanceCancellables: Set<AnyCancellable> = [] - /// This will be set to the initial frame of the window from the xib on load. - private var initialFrame: NSRect? - init(_ ghostty: Ghostty.App, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, withSurfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil, @@ -204,7 +200,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr if all.count > 1 { lastCascadePoint = window.cascadeTopLeft(from: lastCascadePoint) } else { - lastCascadePoint = window.cascadeTopLeft(from: NSPoint(x: window.frame.minX, y: window.frame.maxY)) + // We assume the window frame is already correct at this point, + // so we pass .zero to let cascade use the current frame position. + lastCascadePoint = window.cascadeTopLeft(from: .zero) } } @@ -260,6 +258,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // take effect. Our best theory is there is some next-event-loop-tick logic // that Cocoa is doing that we need to be after. DispatchQueue.main.async { + c.showWindow(self) + // Only cascade if we aren't fullscreen. if let window = c.window { if !window.styleMask.contains(.fullScreen) { @@ -268,8 +268,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } - c.showWindow(self) - // All new_window actions force our app to be active, so that the new // window is focused and visible. NSApp.activate(ignoringOtherApps: true) @@ -322,6 +320,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let treeSize: CGSize? = tree.root?.viewBounds() DispatchQueue.main.async { + c.showWindow(self) if let window = c.window { // If we have a tree size, resize the window's content to match if let treeSize, treeSize.width > 0, treeSize.height > 0 { @@ -339,8 +338,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } } - - c.showWindow(self) } // Setup our undo @@ -1038,37 +1035,30 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } // Initialize our content view to the SwiftUI root - window.contentView = TerminalViewContainer { + let container = TerminalViewContainer { TerminalView(ghostty: ghostty, viewModel: self, delegate: self) } + // Set the initial content size on the container so that + // intrinsicContentSize returns the correct value immediately, + // without waiting for @FocusedValue to propagate through the + // SwiftUI focus chain. + container.initialContentSize = focusedSurface?.initialSize + + window.contentView = container + // If we have a default size, we want to apply it. if let defaultSize { - switch defaultSize { - case .frame: - // Frames can be applied immediately - defaultSize.apply(to: window) + defaultSize.apply(to: window) - case .contentIntrinsicSize: - // Content intrinsic size requires a short delay so that AppKit - // can layout our SwiftUI views. - DispatchQueue.main.asyncAfter(deadline: .now() + .microseconds(10_000)) { [weak self, weak window] in - guard let self, let window else { return } - defaultSize.apply(to: window) - if let screen = window.screen ?? NSScreen.main { - let frame = self.adjustForWindowPosition(frame: window.frame, on: screen) - window.setFrameOrigin(frame.origin) - } + if case .contentIntrinsicSize = defaultSize { + if let screen = window.screen ?? NSScreen.main { + let frame = self.adjustForWindowPosition(frame: window.frame, on: screen) + window.setFrameOrigin(frame.origin) } } } - // Store our initial frame so we can know our default later. This MUST - // be after the defaultSize call above so that we don't re-apply our frame. - // Note: we probably want to set this on the first frame change or something - // so it respects cascade. - initialFrame = window.frame - // In various situations, macOS automatically tabs new windows. Ghostty handles // its own tabbing so we DONT want this behavior. This detects this scenario and undoes // it. @@ -1095,6 +1085,34 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr syncAppearance(.init(config)) } + /// Setup correct window frame before showing the window + override func showWindow(_ sender: Any?) { + guard let terminalWindow = window as? TerminalWindow else { return } + + // Set the initial window position. This must happen after the window + // is fully set up (content view, toolbar, default size) so that + // decorations added by subclass awakeFromNib (e.g. toolbar for tabs + // style) don't change the frame after the position is restored. + let originChanged = terminalWindow.setInitialWindowPosition( + x: derivedConfig.windowPositionX, + y: derivedConfig.windowPositionY, + ) + let restored = LastWindowPosition.shared.restore( + terminalWindow, + origin: !originChanged, + size: defaultSize == nil, + ) + + // If nothing is changed for the frame, + // we should center the window + if !originChanged, !restored { + // This doesn't work in `windowDidLoad` somehow + terminalWindow.center() + } + + super.showWindow(sender) + } + // Shows the "+" button in the tab bar, responds to that click. override func newWindowForTab(_ sender: Any?) { // Trigger the ghostty core event logic for a new tab. @@ -1173,27 +1191,21 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr self.fixTabBar() // Whenever we move save our last position for the next start. - if let window { - LastWindowPosition.shared.save(window) - } + LastWindowPosition.shared.save(window) } override func windowDidResize(_ notification: Notification) { super.windowDidResize(notification) // Whenever we resize save our last position and size for the next start. - if let window { - LastWindowPosition.shared.save(window) - } + LastWindowPosition.shared.save(window) } func windowDidBecomeMain(_ notification: Notification) { // Whenever we get focused, use that as our last window position for // restart. This differs from Terminal.app but matches iTerm2 behavior // and I think its sensible. - if let window { - LastWindowPosition.shared.save(window) - } + LastWindowPosition.shared.save(window) // Remember our last main Self.lastMain = self @@ -1538,7 +1550,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr struct DerivedConfig { let backgroundColor: Color let macosWindowButtons: Ghostty.MacOSWindowButtons - let macosTitlebarStyle: String + let macosTitlebarStyle: Ghostty.Config.MacOSTitlebarStyle let maximize: Bool let windowPositionX: Int16? let windowPositionY: Int16? @@ -1546,7 +1558,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) self.macosWindowButtons = .visible - self.macosTitlebarStyle = "system" + self.macosTitlebarStyle = .default self.maximize = false self.windowPositionX = nil self.windowPositionY = nil @@ -1645,9 +1657,6 @@ extension TerminalController { // Initial size as requested by the configuration (e.g. `window-width`) // takes next priority. return .contentIntrinsicSize - } else if let initialFrame { - // The initial frame we had when we started otherwise. - return .frame(initialFrame) } else { return nil } diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 697009579..b6e1c637c 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -105,7 +105,7 @@ struct TerminalView<ViewModel: TerminalViewModel>: View { idealHeight: lastFocusedSurface?.value?.initialSize?.height) } // Ignore safe area to extend up in to the titlebar region if we have the "hidden" titlebar style - .ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == "hidden" ? .top : []) + .ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == .hidden ? .top : []) if let surfaceView = lastFocusedSurface?.value { TerminalCommandPaletteView( diff --git a/macos/Sources/Features/Terminal/TerminalViewContainer.swift b/macos/Sources/Features/Terminal/TerminalViewContainer.swift index 92832efec..dd0190c4c 100644 --- a/macos/Sources/Features/Terminal/TerminalViewContainer.swift +++ b/macos/Sources/Features/Terminal/TerminalViewContainer.swift @@ -33,11 +33,23 @@ class TerminalViewContainer: NSView { fatalError("init(coder:) has not been implemented") } - /// To make ``TerminalController/DefaultSize/contentIntrinsicSize`` - /// work in ``TerminalController/windowDidLoad()``, - /// we override this to provide the correct size. + /// The initial content size to use as a fallback before the SwiftUI + /// view hierarchy has completed layout (i.e. before @FocusedValue + /// propagates `lastFocusedSurface`). Once the hosting view reports + /// a valid intrinsic size, this fallback is no longer used. + var initialContentSize: NSSize? + override var intrinsicContentSize: NSSize { - terminalView.intrinsicContentSize + let hostingSize = terminalView.intrinsicContentSize + // The hosting view returns a valid size once SwiftUI has laid out + // with the correct idealWidth/idealHeight. Before that (when + // @FocusedValue hasn't propagated), it returns a tiny default. + // Fall back to initialContentSize in that case. + if let initialContentSize, + hostingSize.width < initialContentSize.width || hostingSize.height < initialContentSize.height { + return initialContentSize + } + return hostingSize } private func setup() { @@ -62,13 +74,6 @@ class TerminalViewContainer: NSView { updateGlassEffectTopInsetIfNeeded() } - @objc private func ghosttyConfigDidChange(_ notification: Notification) { - guard let config = notification.userInfo?[ - Notification.Name.GhosttyConfigChangeKey - ] as? Ghostty.Config else { return } - ghosttyConfigDidChange(config, preferredBackgroundColor: (window as? TerminalWindow)?.preferredBackgroundColor) - } - func ghosttyConfigDidChange(_ config: Ghostty.Config, preferredBackgroundColor: NSColor?) { let newValue = DerivedConfig(config: config, preferredBackgroundColor: preferredBackgroundColor, cornerRadius: windowCornerRadius) guard newValue != derivedConfig else { return } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index dc744180d..e19d6711f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -120,11 +120,10 @@ class TerminalWindow: NSWindow { // If window decorations are disabled, remove our title if !config.windowDecorations { styleMask.remove(.titled) } - // Set our window positioning to coordinates if config value exists, otherwise - // fallback to original centering behavior - setInitialWindowPosition( - x: config.windowPositionX, - y: config.windowPositionY) + // NOTE: setInitialWindowPosition is NOT called here because subclass + // awakeFromNib may add decorations (e.g. toolbar for tabs style) that + // change the frame. It is called from TerminalController.windowDidLoad + // after the window is fully set up. // If our traffic buttons should be hidden, then hide them if config.macosWindowButtons == .hidden { @@ -172,7 +171,7 @@ class TerminalWindow: NSWindow { tab.accessoryView = stackView // Get our saved level - level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal + level = UserDefaults.ghostty.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal } // Both of these must be true for windows without decorations to be able to @@ -537,20 +536,15 @@ class TerminalWindow: NSWindow { terminalController?.updateColorSchemeForSurfaceTree() } - private func setInitialWindowPosition(x: Int16?, y: Int16?) { + func setInitialWindowPosition(x: Int16?, y: Int16?) -> Bool { // If we don't have an X/Y then we try to use the previously saved window pos. guard let x = x, let y = y else { - if !LastWindowPosition.shared.restore(self) { - center() - } - - return + return false } // Prefer the screen our window is being placed on otherwise our primary screen. guard let screen = screen ?? NSScreen.screens.first else { - center() - return + return false } // Convert top-left coordinates to bottom-left origin using our utility extension @@ -566,6 +560,7 @@ class TerminalWindow: NSWindow { safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.height) setFrameOrigin(safeOrigin) + return true } private func hideWindowButtons() { @@ -588,7 +583,7 @@ class TerminalWindow: NSWindow { let backgroundColor: NSColor let backgroundOpacity: Double let macosWindowButtons: Ghostty.MacOSWindowButtons - let macosTitlebarStyle: String + let macosTitlebarStyle: Ghostty.Config.MacOSTitlebarStyle let windowCornerRadius: CGFloat init() { @@ -597,7 +592,7 @@ class TerminalWindow: NSWindow { self.backgroundOpacity = 1 self.macosWindowButtons = .visible self.backgroundBlur = .disabled - self.macosTitlebarStyle = "transparent" + self.macosTitlebarStyle = .default self.windowCornerRadius = 16 } @@ -613,7 +608,7 @@ class TerminalWindow: NSWindow { // Native, transparent, and hidden styles use 16pt radius // Tabs style uses 20pt radius switch config.macosTitlebarStyle { - case "tabs": + case .tabs: self.windowCornerRadius = 20 default: self.windowCornerRadius = 16 @@ -835,4 +830,13 @@ extension TerminalWindow: TabTitleEditorDelegate { guard let targetController = targetWindow.windowController as? BaseTerminalController else { return } targetController.promptTabTitle() } + + func tabTitleEditor(_ editor: TabTitleEditor, didFinishEditing targetWindow: NSWindow) { + // After inline editing, the first responder is the window itself. + // Restore focus to the terminal surface so keyboard input works. + guard let controller = windowController as? BaseTerminalController, + let focusedSurface = controller.focusedSurface + else { return } + makeFirstResponder(focusedSurface) + } } diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index a547d5286..c0e506c34 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -92,8 +92,8 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // For glass background styles, use a transparent titlebar to let the glass effect show through // Only apply this for transparent and tabs titlebar styles let isGlassStyle = derivedConfig.backgroundBlur.isGlassStyle - let isTransparentTitlebar = derivedConfig.macosTitlebarStyle == "transparent" || - derivedConfig.macosTitlebarStyle == "tabs" + let isTransparentTitlebar = derivedConfig.macosTitlebarStyle == .transparent || + derivedConfig.macosTitlebarStyle == .tabs titlebarView.layer?.backgroundColor = (isGlassStyle && isTransparentTitlebar) ? NSColor.clear.cgColor diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 82b3ad35c..2f0644b93 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -269,7 +269,9 @@ extension Ghostty { _ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, state: UnsafeMutableRawPointer? - ) {} + ) -> Bool { + return false + } static func confirmReadClipboard( _ userdata: UnsafeMutableRawPointer?, @@ -321,20 +323,23 @@ extension Ghostty { ]) } - static func readClipboard(_ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, state: UnsafeMutableRawPointer?) { - // If we don't even have a surface, something went terrible wrong so we have - // to leak "state". + static func readClipboard( + _ userdata: UnsafeMutableRawPointer?, + location: ghostty_clipboard_e, + state: UnsafeMutableRawPointer? + ) -> Bool { let surfaceView = self.surfaceUserdata(from: userdata) - guard let surface = surfaceView.surface else { return } + guard let surface = surfaceView.surface else { return false } // Get our pasteboard - guard let pasteboard = NSPasteboard.ghostty(location) else { - return completeClipboardRequest(surface, data: "", state: state) - } + guard let pasteboard = NSPasteboard.ghostty(location) else { return false } + + // Return false if there is no text-like clipboard content so + // performable paste bindings can pass through to the terminal. + guard let str = pasteboard.getOpinionatedStringContents() else { return false } - // Get our string - let str = pasteboard.getOpinionatedStringContents() ?? "" completeClipboardRequest(surface, data: str, state: state) + return true } static func confirmReadClipboard( @@ -534,6 +539,9 @@ extension Ghostty { case GHOSTTY_ACTION_SET_TITLE: setTitle(app, target: target, v: action.action.set_title) + case GHOSTTY_ACTION_SET_TAB_TITLE: + return setTabTitle(app, target: target, v: action.action.set_tab_title) + case GHOSTTY_ACTION_PROMPT_TITLE: return promptTitle(app, target: target, v: action.action.prompt_title) @@ -1597,6 +1605,33 @@ extension Ghostty { } } + private static func setTabTitle( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_set_title_s + ) -> Bool { + switch target.tag { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("set tab title does nothing with an app target") + return false + + case GHOSTTY_TARGET_SURFACE: + guard let title = String(cString: v.title!, encoding: .utf8) else { return false } + let titleOverride = title.isEmpty ? nil : title + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + guard let window = surfaceView.window, + let controller = window.windowController as? BaseTerminalController + else { return false } + controller.titleOverride = titleOverride + return true + + default: + assertionFailure() + return false + } + } + private static func copyTitleToClipboard( _ app: ghostty_app_t, target: ghostty_target_s) -> Bool { @@ -1934,6 +1969,15 @@ extension Ghostty { case GHOSTTY_TARGET_SURFACE: guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } + guard let config = (NSApplication.shared.delegate as? AppDelegate)?.ghostty.config else { return } + + guard config.progressStyle else { + Ghostty.logger.debug("progress_report action blocked by config") + DispatchQueue.main.async { + surfaceView.progressReport = nil + } + return + } let progressReport = Ghostty.Action.ProgressReport(c: v) DispatchQueue.main.async { diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 87ae0511f..160894b18 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -53,7 +53,7 @@ extension Ghostty { /// - Parameters: /// - path: An optional preferred config file path. Pass `nil` to load the default configuration files. /// - finalize: Whether to finalize the configuration to populate default values. - static private func loadConfig(at path: String?, finalize: Bool) -> ghostty_config_t? { + static func loadConfig(at path: String?, finalize: Bool) -> ghostty_config_t? { // Initialize the global configuration. guard let cfg = ghostty_config_new() else { logger.critical("ghostty_config_new failed") @@ -354,14 +354,14 @@ extension Ghostty { return MacOSWindowButtons(rawValue: str) ?? defaultValue } - var macosTitlebarStyle: String { - let defaultValue = "transparent" + var macosTitlebarStyle: MacOSTitlebarStyle { + let defaultValue = MacOSTitlebarStyle.transparent guard let config = self.config else { return defaultValue } var v: UnsafePointer<Int8>? let key = "macos-titlebar-style" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } - return String(cString: ptr) + return MacOSTitlebarStyle(rawValue: String(cString: ptr)) ?? defaultValue } var macosTitlebarProxyIcon: MacOSTitlebarProxyIcon { @@ -678,6 +678,14 @@ extension Ghostty { return v } + var macosAppleScript: Bool { + guard let config = self.config else { return true } + var v = false + let key = "macos-applescript" + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) + return v + } + var maximize: Bool { guard let config = self.config else { return true } var v = false @@ -717,6 +725,14 @@ extension Ghostty { let buffer = UnsafeBufferPointer(start: v.commands, count: v.len) return buffer.map { Ghostty.Command(cValue: $0) } } + + var progressStyle: Bool { + guard let config = self.config else { return true } + var v = true + let key = "progress-style" + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) + return v + } } } @@ -898,4 +914,9 @@ extension Ghostty.Config { static let bell = NotifyOnCommandFinishAction(rawValue: 1 << 0) static let notify = NotifyOnCommandFinishAction(rawValue: 1 << 1) } + + enum MacOSTitlebarStyle: String { + static let `default` = MacOSTitlebarStyle.transparent + case native, transparent, tabs, hidden + } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift index a8555e938..c5ab84124 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift @@ -1,37 +1,81 @@ import SwiftUI extension Ghostty { - /// A grab handle overlay at the top of the surface for dragging the window. + /// A grab handle overlay at the top of the surface for dragging a surface. struct SurfaceGrabHandle: View { + // Size of the actual drag handle; the hover reveal region is larger. + private static let handleSize = CGSize(width: 80, height: 12) + + // Reveal the handle anywhere within the top % of the pane height. + private static let hoverHeightFactor: CGFloat = 0.2 + @ObservedObject var surfaceView: SurfaceView @State private var isHovering: Bool = false @State private var isDragging: Bool = false + private var handleVisible: Bool { + // Handle should always be visible in non-fullscreen + guard let window = surfaceView.window else { return true } + guard window.styleMask.contains(.fullScreen) else { return true } + + // If fullscreen, only show the handle if we have splits + guard let controller = window.windowController as? BaseTerminalController else { return false } + return controller.surfaceTree.isSplit + } + private var ellipsisVisible: Bool { - surfaceView.mouseOverSurface && surfaceView.cursorVisible + // If the cursor isn't visible, never show the handle + guard surfaceView.cursorVisible else { return false } + // If we're hovering or actively dragging, always visible + if isHovering || isDragging { return true } + + // Require our mouse location to be within the top area of the + // surface. + guard let mouseLocation = surfaceView.mouseLocationInSurface else { return false } + return Self.isInHoverRegion(mouseLocation, in: surfaceView.bounds) } var body: some View { - ZStack { - SurfaceDragSource( - surfaceView: surfaceView, - isDragging: $isDragging, - isHovering: $isHovering - ) - .frame(width: 80, height: 12) - .contentShape(Rectangle()) + if handleVisible { + ZStack { + SurfaceDragSource( + surfaceView: surfaceView, + isDragging: $isDragging, + isHovering: $isHovering + ) + .frame(width: Self.handleSize.width, height: Self.handleSize.height) + .contentShape(Rectangle()) - if ellipsisVisible { - Image(systemName: "ellipsis") - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(.primary.opacity(isHovering ? 0.8 : 0.3)) - .offset(y: -3) - .allowsHitTesting(false) - .transition(.opacity) + if ellipsisVisible { + Image(systemName: "ellipsis") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.primary.opacity(isHovering ? 0.8 : 0.3)) + .offset(y: -3) + .allowsHitTesting(false) + .transition(.opacity) + } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + /// The full-width hover band that reveals the drag handle. + private static func hoverRect(in bounds: CGRect) -> CGRect { + guard !bounds.isEmpty else { return .zero } + + let hoverHeight = min(bounds.height, max(handleSize.height, bounds.height * hoverHeightFactor)) + return CGRect( + x: bounds.minX, + y: bounds.maxY - hoverHeight, + width: bounds.width, + height: hoverHeight + ) + } + + /// Returns true when the pointer is inside the top hover band. + private static func isInHoverRegion(_ point: CGPoint, in bounds: CGRect) -> Bool { + hoverRect(in: bounds).contains(point) } } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 060b7990b..2289a3bdd 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -119,6 +119,10 @@ extension Ghostty { // Whether the mouse is currently over this surface @Published private(set) var mouseOverSurface: Bool = false + // The last known mouse location in the surface's local coordinate space, + // used by overlays such as the split drag handle reveal region. + @Published private(set) var mouseLocationInSurface: CGPoint? + // Whether the cursor is currently visible (not hidden by typing, etc.) @Published private(set) var cursorVisible: Bool = true @@ -438,6 +442,15 @@ extension Ghostty { guard let surface = self.surface else { return } guard self.focused != focused else { return } self.focused = focused + + // If we lost our focus then remove the mouse event suppression so + // our mouse release event leaving the surface can properly be + // sent to stop things like mouse selection. + if !focused { + suppressNextLeftMouseUp = false + } + + // Notify libghostty ghostty_surface_set_focus(surface, focused) // Update our secure input state if we are a password input @@ -639,6 +652,14 @@ extension Ghostty { } private func localEventLeftMouseDown(_ event: NSEvent) -> NSEvent? { + let isCommandPaletteVisible = (event.window?.windowController as? BaseTerminalController)? + .commandPaletteIsShowing == true + guard !isCommandPaletteVisible else { + // We don't want to process events that + // are supposed to be handled by CommandPaletteView + return event + } + // We only want to process events that are on this window. guard let window, event.window != nil, @@ -648,9 +669,15 @@ extension Ghostty { let location = convert(event.locationInWindow, from: nil) guard hitTest(location) == self else { return event } + // We always assume that we're resetting our mouse suppression + // unless we see the specific scenario below to set it. + suppressNextLeftMouseUp = false + // If we're already the first responder then no focus transfer is // happening, so the click should continue as normal. - guard window.firstResponder !== self else { return event } + guard window.firstResponder !== self else { + return event + } // If our window/app is already focused, then this click is only // being used to transfer split focus. Consume it so it does not @@ -937,13 +964,15 @@ extension Ghostty { mouseOverSurface = true super.mouseEntered(with: event) + let pos = self.convert(event.locationInWindow, from: nil) + mouseLocationInSurface = pos + guard let surfaceModel else { return } // On mouse enter we need to reset our cursor position. This is // super important because we set it to -1/-1 on mouseExit and // lots of mouse logic (i.e. whether to send mouse reports) depend // on the position being in the viewport if it is. - let pos = self.convert(event.locationInWindow, from: nil) let mouseEvent = Ghostty.Input.MousePosEvent( x: pos.x, y: frame.height - pos.y, @@ -954,6 +983,7 @@ extension Ghostty { override func mouseExited(with event: NSEvent) { mouseOverSurface = false + mouseLocationInSurface = nil guard let surfaceModel else { return } // If the mouse is being dragged then we don't have to emit @@ -973,10 +1003,12 @@ extension Ghostty { } override func mouseMoved(with event: NSEvent) { + let pos = self.convert(event.locationInWindow, from: nil) + mouseLocationInSurface = pos + guard let surfaceModel else { return } // Convert window position to view position. Note (0, 0) is bottom left. - let pos = self.convert(event.locationInWindow, from: nil) let mouseEvent = Ghostty.Input.MousePosEvent( x: pos.x, y: frame.height - pos.y, @@ -1046,7 +1078,7 @@ extension Ghostty { // If the user has force click enabled then we do a quick look. There // is no public API for this as far as I can tell. - guard UserDefaults.standard.bool(forKey: "com.apple.trackpad.forceClick") else { return } + guard UserDefaults.ghostty.bool(forKey: "com.apple.trackpad.forceClick") else { return } quickLook(with: event) } @@ -1241,7 +1273,8 @@ extension Ghostty { keyTables.isEmpty, bindingFlags.isDisjoint(with: [.all, .performable]), bindingFlags.contains(.consumed) { - if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { + if let appDelegate = NSApp.delegate as? AppDelegate, + appDelegate.performGhosttyBindingMenuKeyEquivalent(with: event) { return true } } diff --git a/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift index ca338f102..84553ed34 100644 --- a/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift @@ -18,7 +18,7 @@ extension NSScreen { // AND present on this screen. var hasDock: Bool { // If the dock autohides then we don't have a dock ever. - if let dockAutohide = UserDefaults.standard.persistentDomain(forName: "com.apple.dock")?["autohide"] as? Bool { + if let dockAutohide = UserDefaults.ghostty.persistentDomain(forName: "com.apple.dock")?["autohide"] as? Bool { if dockAutohide { return false } } diff --git a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift index 3c5cbd23a..46758a42d 100644 --- a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift @@ -85,13 +85,17 @@ extension NSWindow { /// Returns the visual tab index and matching tab button at the given screen point. func tabButtonHit(atScreenPoint screenPoint: NSPoint) -> (index: Int, tabButton: NSView)? { - guard let tabBarView else { return nil } - let locationInWindow = convertPoint(fromScreen: screenPoint) - let locationInTabBar = tabBarView.convert(locationInWindow, from: nil) + guard let tabBarView, let tabBarWindow = tabBarView.window else { return nil } + + // In fullscreen, AppKit can host the titlebar and tab bar in a separate + // NSToolbarFullScreenWindow. Hit testing has to use that window's base + // coordinate space or content clicks can be misinterpreted as tab clicks. + let locationInTabBarWindow = tabBarWindow.convertPoint(fromScreen: screenPoint) + let locationInTabBar = tabBarView.convert(locationInTabBarWindow, from: nil) guard tabBarView.bounds.contains(locationInTabBar) else { return nil } for (index, tabButton) in tabButtonsInVisualOrder().enumerated() { - let locationInTabButton = tabButton.convert(locationInWindow, from: nil) + let locationInTabButton = tabButton.convert(locationInTabBarWindow, from: nil) if tabButton.bounds.contains(locationInTabButton) { return (index, tabButton) } diff --git a/macos/Sources/Helpers/Extensions/ObjectIdentifier+Extension.swift b/macos/Sources/Helpers/Extensions/ObjectIdentifier+Extension.swift new file mode 100644 index 000000000..d2440f1d4 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/ObjectIdentifier+Extension.swift @@ -0,0 +1,7 @@ +import Foundation + +extension ObjectIdentifier { + var hexString: String { + String(UInt(bitPattern: self), radix: 16) + } +} diff --git a/macos/Sources/Helpers/Extensions/String+Extension.swift b/macos/Sources/Helpers/Extensions/String+Extension.swift index e28877ca8..4fa61cd78 100644 --- a/macos/Sources/Helpers/Extensions/String+Extension.swift +++ b/macos/Sources/Helpers/Extensions/String+Extension.swift @@ -27,4 +27,13 @@ extension String { } #endif + /// Converts a four-character ASCII string to its `FourCharCode` (`UInt32`) value. + var fourCharCode: UInt32 { + assert(count <= 4, "FourCharCode string must be at most 4 characters") + var result: UInt32 = 0 + for byte in utf8.prefix(4) { + result = (result << 8) | UInt32(byte) + } + return result + } } diff --git a/macos/Sources/Helpers/Extensions/UserDefaults+Extension.swift b/macos/Sources/Helpers/Extensions/UserDefaults+Extension.swift new file mode 100644 index 000000000..7cd0e12ed --- /dev/null +++ b/macos/Sources/Helpers/Extensions/UserDefaults+Extension.swift @@ -0,0 +1,15 @@ +import Foundation + +extension UserDefaults { + static var ghosttySuite: String? { + #if DEBUG + ProcessInfo.processInfo.environment["GHOSTTY_USER_DEFAULTS_SUITE"] + #else + nil + #endif + } + + static var ghostty: UserDefaults { + ghosttySuite.flatMap(UserDefaults.init(suiteName:)) ?? .standard + } +} diff --git a/macos/Sources/Helpers/LastWindowPosition.swift b/macos/Sources/Helpers/LastWindowPosition.swift index 5a9ce1d2c..c7989b6fa 100644 --- a/macos/Sources/Helpers/LastWindowPosition.swift +++ b/macos/Sources/Helpers/LastWindowPosition.swift @@ -6,14 +6,33 @@ class LastWindowPosition { private let positionKey = "NSWindowLastPosition" - func save(_ window: NSWindow) { + @discardableResult + func save(_ window: NSWindow?) -> Bool { + // We should only save the frame if the window is visible. + // This avoids overriding the previously saved one + // with the wrong one when window decorations change while creating, + // e.g. adding a toolbar affects the window's frame. + guard let window, window.isVisible else { return false } let frame = window.frame let rect = [frame.origin.x, frame.origin.y, frame.size.width, frame.size.height] - UserDefaults.standard.set(rect, forKey: positionKey) + UserDefaults.ghostty.set(rect, forKey: positionKey) + return true } - func restore(_ window: NSWindow) -> Bool { - guard let values = UserDefaults.standard.array(forKey: positionKey) as? [Double], + /// Restores a previously saved window frame (or parts of it) onto the given window. + /// + /// - Parameters: + /// - window: The window whose frame should be updated. + /// - restoreOrigin: Whether to restore the saved position. Pass `false` when the + /// config specifies an explicit `window-position-x`/`window-position-y`. + /// - restoreSize: Whether to restore the saved size. Pass `false` when the config + /// specifies an explicit `window-width`/`window-height`. + /// - Returns: `true` if the frame was modified, `false` if there was nothing to restore. + @discardableResult + func restore(_ window: NSWindow, origin restoreOrigin: Bool = true, size restoreSize: Bool = true) -> Bool { + guard restoreOrigin || restoreSize else { return false } + + guard let values = UserDefaults.ghostty.array(forKey: positionKey) as? [Double], values.count >= 2 else { return false } let lastPosition = CGPoint(x: values[0], y: values[1]) @@ -22,14 +41,22 @@ class LastWindowPosition { let visibleFrame = screen.visibleFrame var newFrame = window.frame - newFrame.origin = lastPosition + if restoreOrigin { + newFrame.origin = lastPosition + } - if values.count >= 4 { + if restoreSize, values.count >= 4 { newFrame.size.width = min(values[2], visibleFrame.width) newFrame.size.height = min(values[3], visibleFrame.height) } - if !visibleFrame.contains(newFrame.origin) { + // If the new frame is not constrained to the visible screen, + // we need to shift it a little bit before AppKit does this for us, + // so that we can save the correct size beforehand. + // This fixes restoration while running UI tests, + // where config is modified without switching apps, + // which will not trigger `windowDidBecomeMain`. + if restoreOrigin, !visibleFrame.contains(newFrame) { newFrame.origin.x = max(visibleFrame.minX, min(visibleFrame.maxX - newFrame.width, newFrame.origin.x)) newFrame.origin.y = max(visibleFrame.minY, min(visibleFrame.maxY - newFrame.height, newFrame.origin.y)) } diff --git a/macos/Sources/Helpers/PermissionRequest.swift b/macos/Sources/Helpers/PermissionRequest.swift index 29d1ab6d3..0308a0204 100644 --- a/macos/Sources/Helpers/PermissionRequest.swift +++ b/macos/Sources/Helpers/PermissionRequest.swift @@ -126,7 +126,7 @@ class PermissionRequest { /// - Parameter key: The UserDefaults key to check /// - Returns: The cached decision, or nil if no valid cached decision exists private static func getStoredResult(for key: String) -> Bool? { - let userDefaults = UserDefaults.standard + let userDefaults = UserDefaults.ghostty guard let data = userDefaults.data(forKey: key), let storedPermission = try? NSKeyedUnarchiver.unarchivedObject( ofClass: StoredPermission.self, from: data) else { @@ -151,7 +151,7 @@ class PermissionRequest { let expiryDate = Date().addingTimeInterval(duration.timeInterval) let storedPermission = StoredPermission(result: result, expiry: expiryDate) if let data = try? NSKeyedArchiver.archivedData(withRootObject: storedPermission, requiringSecureCoding: true) { - let userDefaults = UserDefaults.standard + let userDefaults = UserDefaults.ghostty userDefaults.set(data, forKey: key) } } diff --git a/macos/Sources/Helpers/TabTitleEditor.swift b/macos/Sources/Helpers/TabTitleEditor.swift index 0a1efae32..4be2c5306 100644 --- a/macos/Sources/Helpers/TabTitleEditor.swift +++ b/macos/Sources/Helpers/TabTitleEditor.swift @@ -26,6 +26,12 @@ protocol TabTitleEditorDelegate: AnyObject { _ editor: TabTitleEditor, performFallbackRenameFor targetWindow: NSWindow ) + + /// Called after inline editing finishes (whether committed or cancelled). + /// Use this to restore focus to the appropriate responder. + func tabTitleEditor( + _ editor: TabTitleEditor, + didFinishEditing targetWindow: NSWindow) } /// Handles inline tab title editing for native AppKit window tabs. @@ -34,6 +40,8 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { private weak var hostWindow: NSWindow? /// Delegate that provides and commits title data for target tab windows. private weak var delegate: TabTitleEditorDelegate? + /// Local event monitor so fullscreen titlebar-window clicks can also trigger rename. + private var eventMonitor: Any? /// Active inline editor view, if editing is in progress. private weak var inlineTitleEditor: NSTextField? @@ -46,8 +54,24 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { /// Creates a coordinator bound to a host window and rename delegate. init(hostWindow: NSWindow, delegate: TabTitleEditorDelegate) { + super.init() + self.hostWindow = hostWindow self.delegate = delegate + + // This is needed so that fullscreen clicks can register since they won't + // event on the NSWindow. We may want to tighten this up in the future by + // only doing this if we're fullscreen. + self.eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in + guard let self else { return event } + return handleMouseDown(event) ? nil : event + } + } + + deinit { + if let eventMonitor { + NSEvent.removeMonitor(eventMonitor) + } } /// Handles leftMouseDown events from the host window and begins inline edit if possible. If this @@ -58,8 +82,15 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { // If we don't have a host window to look up the click, we do nothing. guard let hostWindow else { return false } + // In native fullscreen, AppKit can route titlebar clicks through a detached + // NSToolbarFullScreenWindow. Only allow clicks from the host window or its + // fullscreen tab bar window so rename handling stays scoped to this tab strip. + let sourceWindow = event.window ?? hostWindow + guard sourceWindow === hostWindow || sourceWindow === hostWindow.tabBarView?.window + else { return false } + // Find the tab window that is being clicked. - let locationInScreen = hostWindow.convertPoint(toScreen: event.locationInWindow) + let locationInScreen = sourceWindow.convertPoint(toScreen: event.locationInWindow) guard let tabIndex = hostWindow.tabIndex(atScreenPoint: locationInScreen), let targetWindow = hostWindow.tabbedWindows?[safe: tabIndex], delegate?.tabTitleEditor(self, canRenameTabFor: targetWindow) == true @@ -165,9 +196,11 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { // Focus after insertion so AppKit has created the field editor for this text field. DispatchQueue.main.async { [weak hostWindow, weak editor] in - guard let hostWindow, let editor else { return } + guard let editor else { return } + let responderWindow = editor.window ?? hostWindow + guard let responderWindow else { return } editor.isHidden = false - hostWindow.makeFirstResponder(editor) + responderWindow.makeFirstResponder(editor) if let fieldEditor = editor.currentEditor() as? NSTextView, let editorFont = editor.font { fieldEditor.font = editorFont @@ -198,11 +231,11 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { inlineTitleTargetWindow = nil // Make sure the window grabs focus again - if let hostWindow { - if let currentEditor = editor.currentEditor(), hostWindow.firstResponder === currentEditor { - hostWindow.makeFirstResponder(nil) - } else if hostWindow.firstResponder === editor { - hostWindow.makeFirstResponder(nil) + if let responderWindow = editor.window ?? hostWindow { + if let currentEditor = editor.currentEditor(), responderWindow.firstResponder === currentEditor { + responderWindow.makeFirstResponder(nil) + } else if responderWindow.firstResponder === editor { + responderWindow.makeFirstResponder(nil) } } @@ -212,8 +245,14 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { previousTabState = nil // Delegate owns title persistence semantics (including empty-title handling). - guard commit, let targetWindow else { return } - delegate?.tabTitleEditor(self, didCommitTitle: editedTitle, for: targetWindow) + guard let targetWindow else { return } + + if commit { + delegate?.tabTitleEditor(self, didCommitTitle: editedTitle, for: targetWindow) + } + + // Notify delegate that editing is done so it can restore focus. + delegate?.tabTitleEditor(self, didFinishEditing: targetWindow) } /// Chooses an editor frame that aligns with the tab title within the tab button. diff --git a/macos/Tests/Ghostty/ConfigTests.swift b/macos/Tests/Ghostty/ConfigTests.swift new file mode 100644 index 000000000..b9c9d6a4a --- /dev/null +++ b/macos/Tests/Ghostty/ConfigTests.swift @@ -0,0 +1,244 @@ +import Testing +@testable import Ghostty +@testable import GhosttyKit +import SwiftUI + +@Suite +struct ConfigTests { + // MARK: - Boolean Properties + + @Test func initialWindowDefaultsToTrue() throws { + let config = try TemporaryConfig("") + #expect(config.initialWindow == true) + } + + @Test func initialWindowSetToFalse() throws { + let config = try TemporaryConfig("initial-window = false") + #expect(config.initialWindow == false) + } + + @Test func quitAfterLastWindowClosedDefaultsToFalse() throws { + let config = try TemporaryConfig("") + #expect(config.shouldQuitAfterLastWindowClosed == false) + } + + @Test func quitAfterLastWindowClosedSetToTrue() throws { + let config = try TemporaryConfig("quit-after-last-window-closed = true") + #expect(config.shouldQuitAfterLastWindowClosed == true) + } + + @Test func windowStepResizeDefaultsToFalse() throws { + let config = try TemporaryConfig("") + #expect(config.windowStepResize == false) + } + + @Test func focusFollowsMouseDefaultsToFalse() throws { + let config = try TemporaryConfig("") + #expect(config.focusFollowsMouse == false) + } + + @Test func focusFollowsMouseSetToTrue() throws { + let config = try TemporaryConfig("focus-follows-mouse = true") + #expect(config.focusFollowsMouse == true) + } + + @Test func windowDecorationsDefaultsToTrue() throws { + let config = try TemporaryConfig("") + #expect(config.windowDecorations == true) + } + + @Test func windowDecorationsNone() throws { + let config = try TemporaryConfig("window-decoration = none") + #expect(config.windowDecorations == false) + } + + @Test func macosWindowShadowDefaultsToTrue() throws { + let config = try TemporaryConfig("") + #expect(config.macosWindowShadow == true) + } + + @Test func maximizeDefaultsToFalse() throws { + let config = try TemporaryConfig("") + #expect(config.maximize == false) + } + + @Test func maximizeSetToTrue() throws { + let config = try TemporaryConfig("maximize = true") + #expect(config.maximize == true) + } + + // MARK: - String / Optional String Properties + + @Test func titleDefaultsToNil() throws { + let config = try TemporaryConfig("") + #expect(config.title == nil) + } + + @Test func titleSetToCustomValue() throws { + let config = try TemporaryConfig("title = My Terminal") + #expect(config.title == "My Terminal") + } + + @Test func windowTitleFontFamilyDefaultsToNil() throws { + let config = try TemporaryConfig("") + #expect(config.windowTitleFontFamily == nil) + } + + @Test func windowTitleFontFamilySetToValue() throws { + let config = try TemporaryConfig("window-title-font-family = Menlo") + #expect(config.windowTitleFontFamily == "Menlo") + } + + // MARK: - Enum Properties + + @Test func macosTitlebarStyleDefaultsToTransparent() throws { + let config = try TemporaryConfig("") + #expect(config.macosTitlebarStyle == .transparent) + } + + @Test(arguments: [ + ("native", Ghostty.Config.MacOSTitlebarStyle.native), + ("transparent", Ghostty.Config.MacOSTitlebarStyle.transparent), + ("tabs", Ghostty.Config.MacOSTitlebarStyle.tabs), + ("hidden", Ghostty.Config.MacOSTitlebarStyle.hidden), + ]) + func macosTitlebarStyleValues(raw: String, expected: Ghostty.Config.MacOSTitlebarStyle) throws { + let config = try TemporaryConfig("macos-titlebar-style = \(raw)") + #expect(config.macosTitlebarStyle == expected) + } + + @Test func resizeOverlayDefaultsToAfterFirst() throws { + let config = try TemporaryConfig("") + #expect(config.resizeOverlay == .after_first) + } + + @Test(arguments: [ + ("always", Ghostty.Config.ResizeOverlay.always), + ("never", Ghostty.Config.ResizeOverlay.never), + ("after-first", Ghostty.Config.ResizeOverlay.after_first), + ]) + func resizeOverlayValues(raw: String, expected: Ghostty.Config.ResizeOverlay) throws { + let config = try TemporaryConfig("resize-overlay = \(raw)") + #expect(config.resizeOverlay == expected) + } + + @Test func resizeOverlayPositionDefaultsToCenter() throws { + let config = try TemporaryConfig("") + #expect(config.resizeOverlayPosition == .center) + } + + @Test func macosIconDefaultsToOfficial() throws { + let config = try TemporaryConfig("") + #expect(config.macosIcon == .official) + } + + @Test func macosIconFrameDefaultsToAluminum() throws { + let config = try TemporaryConfig("") + #expect(config.macosIconFrame == .aluminum) + } + + @Test func macosWindowButtonsDefaultsToVisible() throws { + let config = try TemporaryConfig("") + #expect(config.macosWindowButtons == .visible) + } + + @Test func scrollbarDefaultsToSystem() throws { + let config = try TemporaryConfig("") + #expect(config.scrollbar == .system) + } + + @Test func scrollbarSetToNever() throws { + let config = try TemporaryConfig("scrollbar = never") + #expect(config.scrollbar == .never) + } + + // MARK: - Numeric Properties + + @Test func backgroundOpacityDefaultsToOne() throws { + let config = try TemporaryConfig("") + #expect(config.backgroundOpacity == 1.0) + } + + @Test func backgroundOpacitySetToCustom() throws { + let config = try TemporaryConfig("background-opacity = 0.5") + #expect(config.backgroundOpacity == 0.5) + } + + @Test func windowPositionDefaultsToNil() throws { + let config = try TemporaryConfig("") + #expect(config.windowPositionX == nil) + #expect(config.windowPositionY == nil) + } + + // MARK: - Config Loading + + @Test func loadedIsTrueForValidConfig() throws { + let config = try TemporaryConfig("") + #expect(config.loaded == true) + } + + @Test func unfinalizedConfigIsLoaded() throws { + let config = try TemporaryConfig("", finalize: false) + #expect(config.loaded == true) + } + + @Test func defaultConfigIsLoaded() throws { + let config = try TemporaryConfig("") + #expect(config.optionalAutoUpdateChannel != nil) // release or tip + let config1 = try TemporaryConfig("", finalize: false) + #expect(config1.optionalAutoUpdateChannel == nil) + } + + @Test func errorsEmptyForValidConfig() throws { + let config = try TemporaryConfig("") + #expect(config.errors.isEmpty) + } + + @Test func errorsReportedForInvalidConfig() throws { + let config = try TemporaryConfig("not-a-real-key = value") + #expect(!config.errors.isEmpty) + } + + // MARK: - Multiple Config Lines + + @Test func multipleConfigValues() throws { + let config = try TemporaryConfig(""" + initial-window = false + quit-after-last-window-closed = true + maximize = true + focus-follows-mouse = true + """) + #expect(config.initialWindow == false) + #expect(config.shouldQuitAfterLastWindowClosed == true) + #expect(config.maximize == true) + #expect(config.focusFollowsMouse == true) + } +} + +/// Create a temporary config file and delete it when this is deallocated +class TemporaryConfig: Ghostty.Config { + let temporaryFile: URL + + init(_ configText: String, finalize: Bool = true) throws { + let temporaryFile = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("ghostty") + try configText.write(to: temporaryFile, atomically: true, encoding: .utf8) + self.temporaryFile = temporaryFile + super.init(config: Self.loadConfig(at: temporaryFile.path(), finalize: finalize)) + } + + var optionalAutoUpdateChannel: Ghostty.AutoUpdateChannel? { + guard let config = self.config else { return nil } + var v: UnsafePointer<Int8>? + let key = "auto-update-channel" + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } + guard let ptr = v else { return nil } + let str = String(cString: ptr) + return Ghostty.AutoUpdateChannel(rawValue: str) + } + + deinit { + try? FileManager.default.removeItem(at: temporaryFile) + } +} diff --git a/macos/build.nu b/macos/build.nu new file mode 100755 index 000000000..8c456d9b6 --- /dev/null +++ b/macos/build.nu @@ -0,0 +1,32 @@ +#!/usr/bin/env nu + +# Build the macOS Ghostty app using xcodebuild with a clean environment +# to avoid Nix shell interference (NIX_LDFLAGS, NIX_CFLAGS_COMPILE, etc.). + +def main [ + --scheme: string = "Ghostty" # Xcode scheme (Ghostty, Ghostty-iOS, DockTilePlugin) + --configuration: string = "Debug" # Build configuration (Debug, Release, ReleaseLocal) + --action: string = "build" # xcodebuild action (build, test, clean, etc.) +] { + let project = ($env.FILE_PWD | path join "Ghostty.xcodeproj") + let build_dir = ($env.FILE_PWD | path join "build") + + # Skip UI tests for CLI-based invocations because it requires + # special permissions. + let skip_testing = if $action == "test" { + [-skip-testing GhosttyUITests] + } else { + [] + } + + (^env -i + $"HOME=($env.HOME)" + "PATH=/usr/bin:/bin:/usr/sbin:/sbin" + xcodebuild + -project $project + -scheme $scheme + -configuration $configuration + $"SYMROOT=($build_dir)" + ...$skip_testing + $action) +} diff --git a/nix/devShell.nix b/nix/devShell.nix index c78c9081b..df08fc204 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -8,6 +8,7 @@ appstream, flatpak-builder, gdb, + cmake, #, glxinfo # unused ncurses, nodejs, @@ -91,6 +92,7 @@ in packages = [ # For builds + cmake doxygen jq llvmPackages_latest.llvm diff --git a/nix/package.nix b/nix/package.nix index 1efef4164..8287b0888 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -30,7 +30,7 @@ in stdenv.mkDerivation (finalAttrs: { pname = "ghostty"; - version = "1.3.0-dev"; + version = "1.3.2-dev"; # We limit source like this to try and reduce the amount of rebuilds as possible # thus we only provide the source that is needed for the build diff --git a/pkg/dcimgui/build.zig b/pkg/dcimgui/build.zig index 2a1389834..01a5879d6 100644 --- a/pkg/dcimgui/build.zig +++ b/pkg/dcimgui/build.zig @@ -26,7 +26,14 @@ pub fn build(b: *std.Build) !void { .linkage = .static, }); lib.linkLibC(); - lib.linkLibCpp(); + // On MSVC, we must not use linkLibCpp because Zig unconditionally + // passes -nostdinc++ and then adds its bundled libc++/libc++abi + // include paths, which conflict with MSVC's own C++ runtime headers. + // The MSVC SDK include directories (added via linkLibC) contain + // both C and C++ headers, so linkLibCpp is not needed. + if (target.result.abi != .msvc) { + lib.linkLibCpp(); + } b.installArtifact(lib); // Zig module diff --git a/pkg/freetype/build.zig b/pkg/freetype/build.zig index ecb22cb6c..b85310a5b 100644 --- a/pkg/freetype/build.zig +++ b/pkg/freetype/build.zig @@ -84,11 +84,14 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu "-DFT_CONFIG_OPTION_SYSTEM_ZLIB=1", - "-DHAVE_UNISTD_H", - "-DHAVE_FCNTL_H", - "-fno-sanitize=undefined", }); + if (target.result.os.tag != .windows) { + try flags.appendSlice(b.allocator, &.{ + "-DHAVE_UNISTD_H", + "-DHAVE_FCNTL_H", + }); + } if (target.result.os.tag == .freebsd or target.result.abi == .musl) { try flags.append(b.allocator, "-fPIC"); diff --git a/pkg/freetype/face.zig b/pkg/freetype/face.zig index d4f74b7ee..cd949e357 100644 --- a/pkg/freetype/face.zig +++ b/pkg/freetype/face.zig @@ -52,7 +52,7 @@ pub const Face = struct { /// Select a given charmap by its encoding tag (as listed in freetype.h). pub fn selectCharmap(self: Face, encoding: Encoding) Error!void { - return intToError(c.FT_Select_Charmap(self.handle, @intFromEnum(encoding))); + return intToError(c.FT_Select_Charmap(self.handle, @intCast(@intFromEnum(encoding)))); } /// Call FT_Request_Size to request the nominal size (in points). @@ -99,7 +99,7 @@ pub const Face = struct { pub fn renderGlyph(self: Face, render_mode: RenderMode) Error!void { return intToError(c.FT_Render_Glyph( self.handle.*.glyph, - @intFromEnum(render_mode), + @intCast(@intFromEnum(render_mode)), )); } diff --git a/pkg/glslang/build.zig b/pkg/glslang/build.zig index c41e05217..1dc82a6e3 100644 --- a/pkg/glslang/build.zig +++ b/pkg/glslang/build.zig @@ -51,7 +51,14 @@ fn buildGlslang( .linkage = .static, }); lib.linkLibC(); - lib.linkLibCpp(); + // On MSVC, we must not use linkLibCpp because Zig unconditionally + // passes -nostdinc++ and then adds its bundled libc++/libc++abi + // include paths, which conflict with MSVC's own C++ runtime headers. + // The MSVC SDK include directories (added via linkLibC) contain + // both C and C++ headers, so linkLibCpp is not needed. + if (target.result.abi != .msvc) { + lib.linkLibCpp(); + } if (upstream_) |upstream| lib.addIncludePath(upstream.path("")); lib.addIncludePath(b.path("override")); if (target.result.os.tag.isDarwin()) { @@ -65,6 +72,10 @@ fn buildGlslang( "-fno-sanitize=undefined", "-fno-sanitize-trap=undefined", }); + // MSVC requires explicit std specification otherwise C++17 features + // like std::variant, std::filesystem, and inline variables are + // guarded behind _HAS_CXX17. + try flags.append(b.allocator, "-std=c++17"); if (target.result.os.tag == .freebsd or target.result.abi == .musl) { try flags.append(b.allocator, "-fPIC"); diff --git a/pkg/gtk4-layer-shell/src/main.zig b/pkg/gtk4-layer-shell/src/main.zig index f7848ea94..a15313231 100644 --- a/pkg/gtk4-layer-shell/src/main.zig +++ b/pkg/gtk4-layer-shell/src/main.zig @@ -3,6 +3,7 @@ const std = @import("std"); const c = @cImport({ @cInclude("gtk4-layer-shell.h"); }); +const gdk = @import("gdk"); const gtk = @import("gtk"); pub const ShellLayer = enum(c_uint) { @@ -61,6 +62,10 @@ pub fn setKeyboardMode(window: *gtk.Window, mode: KeyboardMode) void { c.gtk_layer_set_keyboard_mode(@ptrCast(window), @intFromEnum(mode)); } +pub fn setMonitor(window: *gtk.Window, monitor: ?*gdk.Monitor) void { + c.gtk_layer_set_monitor(@ptrCast(window), @ptrCast(monitor)); +} + pub fn setNamespace(window: *gtk.Window, name: [:0]const u8) void { c.gtk_layer_set_namespace(@ptrCast(window), name.ptr); } diff --git a/pkg/harfbuzz/build.zig b/pkg/harfbuzz/build.zig index 8696c0203..6d8f3be70 100644 --- a/pkg/harfbuzz/build.zig +++ b/pkg/harfbuzz/build.zig @@ -103,7 +103,14 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu .linkage = .static, }); lib.linkLibC(); - lib.linkLibCpp(); + // On MSVC, we must not use linkLibCpp because Zig unconditionally + // passes -nostdinc++ and then adds its bundled libc++/libc++abi + // include paths, which conflict with MSVC's own C++ runtime headers. + // The MSVC SDK include directories (added via linkLibC) contain + // both C and C++ headers, so linkLibCpp is not needed. + if (target.result.abi != .msvc) { + lib.linkLibCpp(); + } if (target.result.os.tag.isDarwin()) { try apple_sdk.addPaths(b, lib); diff --git a/pkg/highway/build.zig b/pkg/highway/build.zig index b6e188b13..49656b93e 100644 --- a/pkg/highway/build.zig +++ b/pkg/highway/build.zig @@ -20,7 +20,15 @@ pub fn build(b: *std.Build) !void { }), .linkage = .static, }); - lib.linkLibCpp(); + lib.linkLibC(); + // On MSVC, we must not use linkLibCpp because Zig unconditionally + // passes -nostdinc++ and then adds its bundled libc++/libc++abi + // include paths, which conflict with MSVC's own C++ runtime headers. + // The MSVC SDK include directories (added via linkLibC) contain + // both C and C++ headers, so linkLibCpp is not needed. + if (target.result.abi != .msvc) { + lib.linkLibCpp(); + } if (upstream_) |upstream| { lib.addIncludePath(upstream.path("")); module.addIncludePath(upstream.path("")); diff --git a/pkg/oniguruma/build.zig b/pkg/oniguruma/build.zig index ea39b4814..efc013b43 100644 --- a/pkg/oniguruma/build.zig +++ b/pkg/oniguruma/build.zig @@ -68,6 +68,7 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu .linkage = .static, }); const t = target.result; + const is_windows = t.os.tag == .windows; lib.linkLibC(); if (target.result.os.tag.isDarwin()) { @@ -86,13 +87,13 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu .PACKAGE_VERSION = "6.9.9", .VERSION = "6.9.9", .HAVE_ALLOCA = true, - .HAVE_ALLOCA_H = true, - .USE_CRNL_AS_LINE_TERMINATOR = false, + .HAVE_ALLOCA_H = !is_windows, + .USE_CRNL_AS_LINE_TERMINATOR = is_windows, .HAVE_STDINT_H = true, - .HAVE_SYS_TIMES_H = true, - .HAVE_SYS_TIME_H = true, + .HAVE_SYS_TIMES_H = !is_windows, + .HAVE_SYS_TIME_H = !is_windows, .HAVE_SYS_TYPES_H = true, - .HAVE_UNISTD_H = true, + .HAVE_UNISTD_H = !is_windows, .HAVE_INTTYPES_H = true, .SIZEOF_INT = t.cTypeByteSize(.int), .SIZEOF_LONG = t.cTypeByteSize(.long), diff --git a/pkg/oniguruma/main.zig b/pkg/oniguruma/main.zig index a8e415cfb..2541cc358 100644 --- a/pkg/oniguruma/main.zig +++ b/pkg/oniguruma/main.zig @@ -1,4 +1,5 @@ const initpkg = @import("init.zig"); +const match_param = @import("match_param.zig"); const regex = @import("regex.zig"); const region = @import("region.zig"); const types = @import("types.zig"); @@ -10,6 +11,7 @@ pub const errors = @import("errors.zig"); pub const init = initpkg.init; pub const deinit = initpkg.deinit; pub const Encoding = types.Encoding; +pub const MatchParam = match_param.MatchParam; pub const Regex = regex.Regex; pub const Region = region.Region; pub const Syntax = types.Syntax; diff --git a/pkg/oniguruma/match_param.zig b/pkg/oniguruma/match_param.zig new file mode 100644 index 000000000..b28258ff0 --- /dev/null +++ b/pkg/oniguruma/match_param.zig @@ -0,0 +1,23 @@ +const c = @import("c.zig").c; +const errors = @import("errors.zig"); +const Error = errors.Error; + +pub const MatchParam = struct { + value: *c.OnigMatchParam, + + pub fn init() !MatchParam { + const value = c.onig_new_match_param() orelse return Error.Memory; + return .{ .value = value }; + } + + pub fn deinit(self: *MatchParam) void { + c.onig_free_match_param(self.value); + } + + pub fn setRetryLimitInSearch(self: *MatchParam, limit: usize) !void { + _ = try errors.convertError(c.onig_set_retry_limit_in_search_of_match_param( + self.value, + @intCast(limit), + )); + } +}; diff --git a/pkg/oniguruma/regex.zig b/pkg/oniguruma/regex.zig index a73c7fc10..fd920e01a 100644 --- a/pkg/oniguruma/regex.zig +++ b/pkg/oniguruma/regex.zig @@ -3,6 +3,7 @@ const c = @import("c.zig").c; const types = @import("types.zig"); const errors = @import("errors.zig"); const testEnsureInit = @import("testing.zig").ensureInit; +const MatchParam = @import("match_param.zig").MatchParam; const Region = @import("region.zig").Region; const Error = errors.Error; const ErrorInfo = errors.ErrorInfo; @@ -43,6 +44,17 @@ pub const Regex = struct { self: *Regex, str: []const u8, options: Option, + ) !Region { + return self.searchWithParam(str, options, null); + } + + /// Search an entire string for matches. This always returns a region + /// which may heap allocate (C allocator). + pub fn searchWithParam( + self: *Regex, + str: []const u8, + options: Option, + match_param: ?*MatchParam, ) !Region { var region: Region = .{}; @@ -51,7 +63,14 @@ pub const Regex = struct { // any errors to free that memory. errdefer region.deinit(); - _ = try self.searchAdvanced(str, 0, str.len, ®ion, options); + _ = try self.searchAdvancedWithParam( + str, + 0, + str.len, + ®ion, + options, + match_param, + ); return region; } @@ -64,15 +83,47 @@ pub const Regex = struct { region: *Region, options: Option, ) !usize { - const pos = try errors.convertError(c.onig_search( - self.value, - str.ptr, - str.ptr + str.len, - str.ptr + start, - str.ptr + end, - @ptrCast(region), - options.int(), - )); + return self.searchAdvancedWithParam( + str, + start, + end, + region, + options, + null, + ); + } + + /// onig_search_with_param directly + pub fn searchAdvancedWithParam( + self: *Regex, + str: []const u8, + start: usize, + end: usize, + region: *Region, + options: Option, + match_param: ?*MatchParam, + ) !usize { + const pos = try errors.convertError(if (match_param) |param| + c.onig_search_with_param( + self.value, + str.ptr, + str.ptr + str.len, + str.ptr + start, + str.ptr + end, + @ptrCast(region), + options.int(), + param.value, + ) + else + c.onig_search( + self.value, + str.ptr, + str.ptr + str.len, + str.ptr + start, + str.ptr + end, + @ptrCast(region), + options.int(), + )); return @intCast(pos); } @@ -90,4 +141,12 @@ test { try testing.expectEqual(@as(usize, 1), reg.count()); try testing.expectError(Error.Mismatch, re.search("hello", .{})); + + var match_param = try MatchParam.init(); + defer match_param.deinit(); + try match_param.setRetryLimitInSearch(1000); + + var reg_param = try re.searchWithParam("hello foo bar", .{}, &match_param); + defer reg_param.deinit(); + try testing.expectEqual(@as(usize, 1), reg_param.count()); } diff --git a/pkg/simdutf/build.zig b/pkg/simdutf/build.zig index 8dcd141c1..e132507a1 100644 --- a/pkg/simdutf/build.zig +++ b/pkg/simdutf/build.zig @@ -12,7 +12,15 @@ pub fn build(b: *std.Build) !void { }), .linkage = .static, }); - lib.linkLibCpp(); + lib.linkLibC(); + // On MSVC, we must not use linkLibCpp because Zig unconditionally + // passes -nostdinc++ and then adds its bundled libc++/libc++abi + // include paths, which conflict with MSVC's own C++ runtime headers. + // The MSVC SDK include directories (added via linkLibC) contain + // both C and C++ headers, so linkLibCpp is not needed. + if (target.result.abi != .msvc) { + lib.linkLibCpp(); + } lib.addIncludePath(b.path("vendor")); if (target.result.os.tag.isDarwin()) { diff --git a/pkg/spirv-cross/build.zig b/pkg/spirv-cross/build.zig index f85e74adf..72ce61eb6 100644 --- a/pkg/spirv-cross/build.zig +++ b/pkg/spirv-cross/build.zig @@ -58,7 +58,14 @@ fn buildSpirvCross( .linkage = .static, }); lib.linkLibC(); - lib.linkLibCpp(); + // On MSVC, we must not use linkLibCpp because Zig unconditionally + // passes -nostdinc++ and then adds its bundled libc++/libc++abi + // include paths, which conflict with MSVC's own C++ runtime headers. + // The MSVC SDK include directories (added via linkLibC) contain + // both C and C++ headers, so linkLibCpp is not needed. + if (target.result.abi != .msvc) { + lib.linkLibCpp(); + } if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); try apple_sdk.addPaths(b, lib); diff --git a/pkg/utfcpp/build.zig b/pkg/utfcpp/build.zig index 08efb4ac8..15c652c14 100644 --- a/pkg/utfcpp/build.zig +++ b/pkg/utfcpp/build.zig @@ -12,7 +12,15 @@ pub fn build(b: *std.Build) !void { }), .linkage = .static, }); - lib.linkLibCpp(); + lib.linkLibC(); + // On MSVC, we must not use linkLibCpp because Zig unconditionally + // passes -nostdinc++ and then adds its bundled libc++/libc++abi + // include paths, which conflict with MSVC's own C++ runtime headers. + // The MSVC SDK include directories (added via linkLibC) contain + // both C and C++ headers, so linkLibCpp is not needed. + if (target.result.abi != .msvc) { + lib.linkLibCpp(); + } if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); diff --git a/pkg/zlib/build.zig b/pkg/zlib/build.zig index 246ab1bcb..6bde60ec7 100644 --- a/pkg/zlib/build.zig +++ b/pkg/zlib/build.zig @@ -32,8 +32,16 @@ pub fn build(b: *std.Build) !void { "-DHAVE_SYS_TYPES_H", "-DHAVE_STDINT_H", "-DHAVE_STDDEF_H", - "-DZ_HAVE_UNISTD_H", }); + if (target.result.os.tag != .windows) { + try flags.append(b.allocator, "-DZ_HAVE_UNISTD_H"); + } + if (target.result.abi == .msvc) { + try flags.appendSlice(b.allocator, &.{ + "-D_CRT_SECURE_NO_DEPRECATE", + "-D_CRT_NONSTDC_NO_DEPRECATE", + }); + } lib.addCSourceFiles(.{ .root = upstream.path(""), .files = srcs, diff --git a/po/bg.po b/po/bg.po index 3f5eead4b..e6f86897a 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-09 22:07+0200\n" "Last-Translator: reo101 <pavel.atanasov2001@gmail.com>\n" "Language-Team: Bulgarian <dict@ludost.net>\n" @@ -242,7 +242,7 @@ msgstr "Командна палитра" msgid "Terminal Inspector" msgstr "Инспектор на терминала" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "За Ghostty" @@ -314,15 +314,15 @@ msgstr "Всички терминални сесии в този прозоре msgid "The currently running process in this split will be terminated." msgstr "Текущият процес в това разделяне ще бъде прекратен." -#: src/apprt/gtk/class/surface.zig:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Командата завърши" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Командата завърши успешно" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Командата завърши неуспешно" @@ -346,14 +346,14 @@ msgstr "Смени името на таба" msgid "Reloaded the configuration" msgstr "Конфигурацията е презаредена" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Копирано в клипборда" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Клипбордът е изчистен" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Разработчици на Ghostty" diff --git a/po/ca.po b/po/ca.po index 7e26a4cd3..197307cc7 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2025-08-24 19:22+0200\n" "Last-Translator: Kristofer Soler " "<31729650+KristoferSoler@users.noreply.github.com>\n" @@ -244,7 +244,7 @@ msgstr "Paleta de comandes" msgid "Terminal Inspector" msgstr "Inspector de terminal" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "Sobre Ghostty" @@ -316,15 +316,15 @@ 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:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Comanda finalitzada" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Comanda completada amb èxit" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Comanda fallida" @@ -348,14 +348,14 @@ msgstr "Canvia el títol de la pestanya" msgid "Reloaded the configuration" msgstr "S'ha tornat a carregar la configuració" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Copiat al porta-retalls" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Porta-retalls netejat" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Desenvolupadors de Ghostty" diff --git a/po/com.mitchellh.ghostty.pot b/po/com.mitchellh.ghostty.pot index 06ee5310f..cf797ad82 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -235,7 +235,7 @@ msgstr "" msgid "Terminal Inspector" msgstr "" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "" @@ -301,15 +301,15 @@ msgstr "" msgid "The currently running process in this split will be terminated." msgstr "" -#: src/apprt/gtk/class/surface.zig:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "" @@ -333,14 +333,14 @@ msgstr "" msgid "Reloaded the configuration" msgstr "" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "" diff --git a/po/de.po b/po/de.po index e3eef5ef2..2f8391ea9 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-13 08:05+0100\n" "Last-Translator: Klaus Hipp <khipp@users.noreply.github.com>\n" "Language-Team: German <translation-team-de@lists.sourceforge.net>\n" @@ -247,7 +247,7 @@ msgstr "Befehlspalette" msgid "Terminal Inspector" msgstr "Terminalinspektor" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "Über Ghostty" @@ -319,15 +319,15 @@ 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:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Befehl abgeschlossen" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Befehl erfolgreich" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Befehl fehlgeschlagen" @@ -351,14 +351,14 @@ msgstr "Tab-Titel ändern" msgid "Reloaded the configuration" msgstr "Konfiguration wurde neu geladen" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "In die Zwischenablage kopiert" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Zwischenablage geleert" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Ghostty-Entwickler" diff --git a/po/es_AR.po b/po/es_AR.po index 23f56ebeb..4dd000fd2 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-19 13:34-0300\n" "Last-Translator: Alan Moyano <alanmoyano203@gmail.com>\n" "Language-Team: Argentinian <es@tp.org.es>\n" @@ -108,7 +108,7 @@ msgstr "Uy, no." #: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Unable to acquire an OpenGL context for rendering." -msgstr "No se puedo obtener un contexto de OpenGL para el renderizado" +msgstr "No se pudo obtener un contexto de OpenGL para el renderizado" #: src/apprt/gtk/ui/1.2/surface.blp:97 msgid "" @@ -242,7 +242,7 @@ msgstr "Paleta de comandos" msgid "Terminal Inspector" msgstr "Inspector de la terminal" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "Acerca de Ghostty" @@ -314,15 +314,15 @@ 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:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Comando finalizado" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Comando ejecutado correctamente" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Comando fallido" @@ -346,14 +346,14 @@ msgstr "Cambiar título de la pestaña" msgid "Reloaded the configuration" msgstr "Configuración recargada" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Copiado al portapapeles" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Portapapeles limpiado" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Desarrolladores de Ghostty" diff --git a/po/es_BO.po b/po/es_BO.po index 57b4b1efd..ebefccbf2 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-12 17:46+0200\n" "Last-Translator: Miguel Peredo <miguelp@quientienemail.com>\n" "Language-Team: Spanish <es@tp.org.es>\n" @@ -242,7 +242,7 @@ msgstr "Paleta de comandos" msgid "Terminal Inspector" msgstr "Inspector de la terminal" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "Acerca de Ghostty" @@ -314,15 +314,15 @@ 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:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Comando finalizado" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Comando exitoso" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Comando fallido" @@ -346,14 +346,14 @@ msgstr "Cambiar el título de la pestaña" msgid "Reloaded the configuration" msgstr "Configuración recargada" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Copiado al portapapeles" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "El portapapeles está limpio" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Desarrolladores de Ghostty" diff --git a/po/es_ES.po b/po/es_ES.po new file mode 100644 index 000000000..a793c402c --- /dev/null +++ b/po/es_ES.po @@ -0,0 +1,360 @@ +# Spanish translations for com.mitchellh.ghostty package +# Traducciones al español para el paquete com.mitchellh.ghostty. +# Copyright (C) 2026 "Mitchell Hashimoto, Ghostty contributors" +# This file is distributed under the same license as the com.mitchellh.ghostty package. +# José Miguel Sarasola <alosarjos@gmail.com>, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: com.mitchellh.ghostty\n" +"Report-Msgid-Bugs-To: m@mitchellh.com\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" +"PO-Revision-Date: 2026-02-18 10:57+0100\n" +"Last-Translator: José Miguel Sarasola <alosarjos@gmail.com>\n" +"Language-Team: \n" +"Language: es_ES\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: dist/linux/ghostty_nautilus.py:53 +msgid "Open in Ghostty" +msgstr "Abrir en Ghostty" + +#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197 +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:201 +msgid "Authorize Clipboard Access" +msgstr "Autorizar acceso al portapapeles" + +#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:17 +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:17 +msgid "Deny" +msgstr "Denegar" + +#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:18 +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:18 +msgid "Allow" +msgstr "Permitir" + +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:92 +msgid "Remember choice for this split" +msgstr "Recordar elección para esta división" + +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:93 +msgid "Reload configuration to show this prompt again" +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 +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.3/surface-child-exited.blp:17 +msgid "Close" +msgstr "Cerrar" + +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:6 +msgid "Configuration Errors" +msgstr "Errores en la configuración" + +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +msgid "" +"One or more configuration errors were found. Please review the errors below, " +"and either reload your configuration or ignore these errors." +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 +msgid "Ignore" +msgstr "Ignorar" + +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:300 +msgid "Reload Configuration" +msgstr "Recargar configuración" + +#: src/apprt/gtk/ui/1.2/debug-warning.blp:7 +#: src/apprt/gtk/ui/1.3/debug-warning.blp:6 +msgid "" +"⚠️ You're running a debug build of Ghostty! Performance will be degraded." +msgstr "" +"⚠️ ¡Estás ejecutando una versión de depuración de Ghostty! El rendimiento se " +"verá perjudicado." + +#: src/apprt/gtk/ui/1.5/inspector-window.blp:5 +msgid "Ghostty: Terminal Inspector" +msgstr "Ghostty: Inspector de terminal" + +#: src/apprt/gtk/ui/1.2/search-overlay.blp:29 +msgid "Find…" +msgstr "Buscar…" + +#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +msgid "Previous Match" +msgstr "Resultado previo" + +#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +msgid "Next Match" +msgstr "Resultado siguiente" + +#: src/apprt/gtk/ui/1.2/surface.blp:6 +msgid "Oh, no." +msgstr "Oh, no." + +#: src/apprt/gtk/ui/1.2/surface.blp:7 +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:97 +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 "" +"Este terminal está en modo de solo lectura. Puedes ver, seleccionar y hacer " +"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:107 +msgid "Read-only" +msgstr "Solo lectura" + +#: src/apprt/gtk/ui/1.2/surface.blp:260 src/apprt/gtk/ui/1.5/window.blp:200 +msgid "Copy" +msgstr "Copiar" + +#: src/apprt/gtk/ui/1.2/surface.blp:265 src/apprt/gtk/ui/1.5/window.blp:205 +msgid "Paste" +msgstr "Pegar" + +#: src/apprt/gtk/ui/1.2/surface.blp:270 +msgid "Notify on Next Command Finish" +msgstr "Notificar al finalizar el siguiente comando" + +#: src/apprt/gtk/ui/1.2/surface.blp:277 src/apprt/gtk/ui/1.5/window.blp:273 +msgid "Clear" +msgstr "Limpiar" + +#: src/apprt/gtk/ui/1.2/surface.blp:282 src/apprt/gtk/ui/1.5/window.blp:278 +msgid "Reset" +msgstr "Reiniciar" + +#: src/apprt/gtk/ui/1.2/surface.blp:289 src/apprt/gtk/ui/1.5/window.blp:242 +msgid "Split" +msgstr "Dividir" + +#: src/apprt/gtk/ui/1.2/surface.blp:292 src/apprt/gtk/ui/1.5/window.blp:245 +msgid "Change Title…" +msgstr "Cambiar título…" + +#: src/apprt/gtk/ui/1.2/surface.blp:297 src/apprt/gtk/ui/1.5/window.blp:177 +#: src/apprt/gtk/ui/1.5/window.blp:250 +msgid "Split Up" +msgstr "Dividir arriba" + +#: src/apprt/gtk/ui/1.2/surface.blp:303 src/apprt/gtk/ui/1.5/window.blp:182 +#: src/apprt/gtk/ui/1.5/window.blp:255 +msgid "Split Down" +msgstr "Dividir abajo" + +#: src/apprt/gtk/ui/1.2/surface.blp:309 src/apprt/gtk/ui/1.5/window.blp:187 +#: src/apprt/gtk/ui/1.5/window.blp:260 +msgid "Split Left" +msgstr "Dividir a la izquierda" + +#: src/apprt/gtk/ui/1.2/surface.blp:315 src/apprt/gtk/ui/1.5/window.blp:192 +#: src/apprt/gtk/ui/1.5/window.blp:265 +msgid "Split Right" +msgstr "Dividir a la derecha" + +#: src/apprt/gtk/ui/1.2/surface.blp:321 +msgid "Close Split" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:327 +msgid "Tab" +msgstr "Pestaña" + +#: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:224 +#: src/apprt/gtk/ui/1.5/window.blp:320 +msgid "Change Tab Title…" +msgstr "Cambiar título de la pestaña…" + +#: src/apprt/gtk/ui/1.2/surface.blp:335 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 +msgid "New Tab" +msgstr "Nueva pestaña" + +#: src/apprt/gtk/ui/1.2/surface.blp:340 src/apprt/gtk/ui/1.5/window.blp:234 +msgid "Close Tab" +msgstr "Cerrar pestaña" + +#: src/apprt/gtk/ui/1.2/surface.blp:347 +msgid "Window" +msgstr "Ventana" + +#: src/apprt/gtk/ui/1.2/surface.blp:350 src/apprt/gtk/ui/1.5/window.blp:212 +msgid "New Window" +msgstr "Nueva ventana" + +#: src/apprt/gtk/ui/1.2/surface.blp:355 src/apprt/gtk/ui/1.5/window.blp:217 +msgid "Close Window" +msgstr "Cerrar ventana" + +#: src/apprt/gtk/ui/1.2/surface.blp:363 +msgid "Config" +msgstr "Configuración" + +#: src/apprt/gtk/ui/1.2/surface.blp:366 src/apprt/gtk/ui/1.5/window.blp:295 +msgid "Open Configuration" +msgstr "Abrir configuración" + +#: src/apprt/gtk/ui/1.5/title-dialog.blp:5 +msgid "Leave blank to restore the default title." +msgstr "Dejar en blanco para restaurar el título predeterminado." + +#: src/apprt/gtk/ui/1.5/title-dialog.blp:9 +msgid "OK" +msgstr "Aceptar" + +#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +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 +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 +msgid "Main Menu" +msgstr "Menú principal" + +#: src/apprt/gtk/ui/1.5/window.blp:285 +msgid "Command Palette" +msgstr "Paleta de comandos" + +#: src/apprt/gtk/ui/1.5/window.blp:290 +msgid "Terminal Inspector" +msgstr "Inspector de terminal" + +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 +msgid "About Ghostty" +msgstr "Acerca de Ghostty" + +#: src/apprt/gtk/ui/1.5/window.blp:312 +msgid "Quit" +msgstr "Salir" + +#: src/apprt/gtk/ui/1.5/command-palette.blp:17 +msgid "Execute a command…" +msgstr "Ejecutar un comando…" + +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 +msgid "" +"An application is attempting to write to the clipboard. The current " +"clipboard contents are shown below." +msgstr "" +"Una aplicación está intentando escribir en el portapapeles. El contenido " +"actual del portapapeles se muestra a continuación." + +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:202 +msgid "" +"An application is attempting to read from the clipboard. The current " +"clipboard contents are shown below." +msgstr "" +"Una aplicación está intentando leer desde el portapapeles. El contenido " +"actual del portapapeles se muestra a continuación." + +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:205 +msgid "Warning: Potentially Unsafe Paste" +msgstr "Advertencia: Pegado potencialmente inseguro" + +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:206 +msgid "" +"Pasting this text into the terminal may be dangerous as it looks like some " +"commands may be executed." +msgstr "" +"Pegar este texto en el terminal puede ser peligroso ya que parece que " +"algunos comandos podrían ejecutarse." + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:184 +msgid "Quit Ghostty?" +msgstr "¿Salir de Ghostty?" + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:185 +msgid "Close Tab?" +msgstr "¿Cerrar pestaña?" + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:186 +msgid "Close Window?" +msgstr "¿Cerrar ventana?" + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:187 +msgid "Close Split?" +msgstr "¿Cerrar división?" + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:193 +msgid "All terminal sessions will be terminated." +msgstr "Todas las sesiones del terminal serán finalizadas." + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:194 +msgid "All terminal sessions in this tab will be terminated." +msgstr "Todas las sesiones del terminal en esta pestaña serán finalizadas." + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:195 +msgid "All terminal sessions in this window will be terminated." +msgstr "Todas las sesiones del terminal en esta ventana serán finalizadas." + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:196 +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:1141 +msgid "Command Finished" +msgstr "Comando finalizado" + +#: src/apprt/gtk/class/surface.zig:1142 +msgid "Command Succeeded" +msgstr "Comando completado" + +#: src/apprt/gtk/class/surface.zig:1143 +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 +msgid "Change Terminal Title" +msgstr "Cambiar el título del terminal" + +#: src/apprt/gtk/class/title_dialog.zig:226 +msgid "Change Tab Title" +msgstr "Cambiar el título de la pestaña" + +#: src/apprt/gtk/class/window.zig:1067 +msgid "Reloaded the configuration" +msgstr "Configuración recargada" + +#: src/apprt/gtk/class/window.zig:1611 +msgid "Copied to clipboard" +msgstr "Copiado al portapapeles" + +#: src/apprt/gtk/class/window.zig:1613 +msgid "Cleared clipboard" +msgstr "Portapapeles vaciado" + +#: src/apprt/gtk/class/window.zig:1753 +msgid "Ghostty Developers" +msgstr "Desarrolladores de Ghostty" diff --git a/po/fr.po b/po/fr.po index 00834b2d7..b83e8b8a5 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-18 15:03+0200\n" "Last-Translator: Pangoraw <naydex.mc+github@gmail.com>\n" "Language-Team: French <traduc@traduc.org>\n" @@ -243,7 +243,7 @@ msgstr "Palette de commandes" msgid "Terminal Inspector" msgstr "Inspecteur de terminal" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "À propos de Ghostty" @@ -315,15 +315,15 @@ 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:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Commande terminée" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Commande réussie" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "La commande a échoué" @@ -347,14 +347,14 @@ msgstr "Changer le titre de l'onglet" msgid "Reloaded the configuration" msgstr "Configuration rechargée" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Copié dans le presse-papiers" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Presse-papiers vidé" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Les développeurs de Ghostty" diff --git a/po/ga.po b/po/ga.po index 636bdbc25..d30e7e2d3 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-18 14:32+0000\n" "Last-Translator: Aindriú Mac Giolla Eoin <aindriu80@yahoo.com>\n" "Language-Team: Irish <gaeilge-gnulinux@lists.sourceforge.net>\n" @@ -241,7 +241,7 @@ msgstr "Pailéad ordaithe" msgid "Terminal Inspector" msgstr "Cigire teirminéil" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "Maidir le Ghostty" @@ -314,15 +314,15 @@ 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:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Ordú críochnaithe" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "D’éirigh leis an ordú" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Theip ar an ordú" @@ -346,14 +346,14 @@ msgstr "Athraigh teideal an táb" msgid "Reloaded the configuration" msgstr "Tá an chumraíocht athlódáilte" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Cóipeáilte chuig an ghearrthaisce" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Gearrthaisce glanta" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Forbróirí Ghostty" diff --git a/po/he.po b/po/he.po index bc9005557..a4e74e34f 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-18 18:14+0300\n" "Last-Translator: Sl (Shahaf Levi), Sl's Repository Ltd " "<ghostty@slsrepo.com>\n" @@ -242,7 +242,7 @@ msgstr "לוח פקודות" msgid "Terminal Inspector" msgstr "בודק המסוף" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "אודות Ghostty" @@ -311,15 +311,15 @@ msgstr "כל הפעלות המסוף בחלון זה יסתיימו." msgid "The currently running process in this split will be terminated." msgstr "התהליך שרץ כרגע בפיצול זה יסתיים." -#: src/apprt/gtk/class/surface.zig:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "הפקודה הסתיימה" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "הפקודה הצליחה" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "הפקודה נכשלה" @@ -343,14 +343,14 @@ msgstr "שינוי כותרת הכרטיסייה" msgid "Reloaded the configuration" msgstr "ההגדרות הוטענו מחדש" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "הועתק ללוח ההעתקה" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "לוח ההעתקה רוקן" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "המפתחים של Ghostty" diff --git a/po/hr.po b/po/hr.po index c944e4b59..cc4b2ec74 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-18 21:00+0200\n" "Last-Translator: Filip7 <filipm7@protonmail.com>\n" "Language-Team: Croatian <lokalizacija@linux.hr>\n" @@ -242,7 +242,7 @@ msgstr "Paleta naredbi" msgid "Terminal Inspector" msgstr "Inspektor terminala" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "O Ghosttyju" @@ -314,15 +314,15 @@ 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:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Naredba je završena" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Naredba je uspjela" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Naredba nije uspjela" @@ -346,14 +346,14 @@ msgstr "Promijeni naslov kartice" msgid "Reloaded the configuration" msgstr "Ponovno učitane postavke" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Kopirano u međuspremnik" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Očišćen međuspremnik" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Razvijatelji Ghosttyja" diff --git a/po/hu.po b/po/hu.po index cf0fef6fc..6a98e7f0f 100644 --- a/po/hu.po +++ b/po/hu.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-03-04 21:13-0800\n" -"PO-Revision-Date: 2026-02-10 18:32+0200\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" +"PO-Revision-Date: 2026-02-26 21:00+0100\n" "Last-Translator: Balázs Szücs <bszucs1209@gmail.com>\n" "Language-Team: Hungarian <translation-team-hu@lists.sourceforge.net>\n" "Language: hu\n" @@ -19,7 +19,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "Megnyitás a Ghostty alkalmazásban" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -183,7 +183,7 @@ msgstr "Fül" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "Fül címének módosítása…" #: src/apprt/gtk/ui/1.2/surface.blp:335 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 @@ -242,7 +242,7 @@ msgstr "Parancspaletta" msgid "Terminal Inspector" msgstr "Terminálvizsgáló" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "A Ghostty névjegye" @@ -314,15 +314,15 @@ 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:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Parancs befejeződött" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Parancs sikeres" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Parancs sikertelen" @@ -340,20 +340,20 @@ msgstr "Terminál címének módosítása" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "Fül címének módosítása" #: src/apprt/gtk/class/window.zig:1067 msgid "Reloaded the configuration" msgstr "Konfiguráció frissítve" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Vágólapra másolva" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Vágólap törölve" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Ghostty fejlesztők" diff --git a/po/id.po b/po/id.po index ff9a92616..19e4bfa4b 100644 --- a/po/id.po +++ b/po/id.po @@ -1,25 +1,26 @@ # Indonesian translations for com.mitchellh.ghostty package. # Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. -# Satrio Bayu Aji <halosatrio@gmail.com>, 2025. -# Mikail Muzakki <mikailmmuzakki@gmail.com>, 2025. +# Satrio Bayu Aji <halosatrio@gmail.com>, 2026. +# Mikail Muzakki <mikailmmuzakki@gmail.com>, 2026. # msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-03-04 21:13-0800\n" -"PO-Revision-Date: 2025-08-01 10:15+0700\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" +"PO-Revision-Date: 2026-03-08 20:23+0700\n" "Last-Translator: Mikail Muzakki <mikailmmuzakki@gmail.com>\n" "Language-Team: Indonesian <translation-team-id@lists.sourceforge.net>\n" "Language: id\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "Buka di Ghostty" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -91,23 +92,23 @@ msgstr "Ghostty: Inspektur terminal" #: src/apprt/gtk/ui/1.2/search-overlay.blp:29 msgid "Find…" -msgstr "" +msgstr "Cari…" #: src/apprt/gtk/ui/1.2/search-overlay.blp:64 msgid "Previous Match" -msgstr "" +msgstr "Hasil sebelumnya" #: src/apprt/gtk/ui/1.2/search-overlay.blp:74 msgid "Next Match" -msgstr "" +msgstr "Hasil berikutnya" #: src/apprt/gtk/ui/1.2/surface.blp:6 msgid "Oh, no." -msgstr "" +msgstr "Oh, tidak." #: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Unable to acquire an OpenGL context for rendering." -msgstr "" +msgstr "Tidak dapat memperoleh konteks OpenGL untuk penampilan grafis." #: src/apprt/gtk/ui/1.2/surface.blp:97 msgid "" @@ -115,10 +116,13 @@ msgid "" "through the content, but no input events will be sent to the running " "application." msgstr "" +"Terminal ini sedang dalam model hanya baca. Anda hanya bisa melihat, " +"memilih, dan menggulir konten, tetapi peristiwa input tidak akan dikirim ke " +"aplikasi berjalan." #: src/apprt/gtk/ui/1.2/surface.blp:107 msgid "Read-only" -msgstr "" +msgstr "Hanya baca" #: src/apprt/gtk/ui/1.2/surface.blp:260 src/apprt/gtk/ui/1.5/window.blp:200 msgid "Copy" @@ -130,7 +134,7 @@ msgstr "Tempel" #: src/apprt/gtk/ui/1.2/surface.blp:270 msgid "Notify on Next Command Finish" -msgstr "" +msgstr "Beri tahu saat perintah berikutnya selesai" #: src/apprt/gtk/ui/1.2/surface.blp:277 src/apprt/gtk/ui/1.5/window.blp:273 msgid "Clear" @@ -179,7 +183,7 @@ msgstr "Tab" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "Ubah judul tab…" #: src/apprt/gtk/ui/1.2/surface.blp:335 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 @@ -238,7 +242,7 @@ msgstr "Palet perintah" msgid "Terminal Inspector" msgstr "Inspektur terminal" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "Tentang Ghostty" @@ -310,17 +314,17 @@ 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:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" -msgstr "" +msgstr "Perintah selesai" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" -msgstr "" +msgstr "Perintah berhasil" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" -msgstr "" +msgstr "Perintah gagal" #: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command succeeded" @@ -336,20 +340,20 @@ msgstr "Ubah judul terminal" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "Ubah judul tab" #: src/apprt/gtk/class/window.zig:1067 msgid "Reloaded the configuration" msgstr "Memuat ulang konfigurasi" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Disalin ke papan klip" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Papan klip dibersihkan" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Pengembang Ghostty" diff --git a/po/it.po b/po/it.po index c94bc287a..1ece3d52d 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2025-09-06 19:40+0200\n" "Last-Translator: Giacomo Bettini <giaco.bettini@gmail.com>\n" "Language-Team: Italian <tp@lists.linux.it>\n" @@ -243,7 +243,7 @@ msgstr "Riquadro comandi" msgid "Terminal Inspector" msgstr "Ispettore del terminale" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "Informazioni su Ghostty" @@ -316,15 +316,15 @@ 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:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Comando terminato" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Comando riuscito" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Comando fallito" @@ -348,14 +348,14 @@ msgstr "Cambia il titolo della scheda" msgid "Reloaded the configuration" msgstr "Configurazione ricaricata" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Copiato negli Appunti" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Appunti svuotati" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Sviluppatori di Ghostty" diff --git a/po/ja.po b/po/ja.po index 435fb736a..8242c313d 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-11 12:02+0900\n" "Last-Translator: Takayuki Nagatomi <tnagatomi@okweird.net>\n" "Language-Team: Japanese\n" @@ -241,7 +241,7 @@ msgstr "コマンドパレット" msgid "Terminal Inspector" msgstr "端末インスペクター" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "Ghostty について" @@ -313,15 +313,15 @@ msgstr "ウィンドウ内のすべてのターミナルセッションが終了 msgid "The currently running process in this split will be terminated." msgstr "分割ウィンドウ内のすべてのプロセスが終了します。" -#: src/apprt/gtk/class/surface.zig:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "コマンド実行終了" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "コマンド実行成功" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "コマンド実行失敗" @@ -345,14 +345,14 @@ msgstr "タブのタイトルを変更する" msgid "Reloaded the configuration" msgstr "設定を再読み込みしました" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "クリップボードにコピーしました" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "クリップボードを空にしました" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Ghostty 開発者" diff --git a/po/kk.po b/po/kk.po index 2ef0e867c..d435f3e0d 100644 --- a/po/kk.po +++ b/po/kk.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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-03-04 21:16+0500\n" "Last-Translator: Baurzhan Muftakhidinov <baurthefirst@gmail.com>\n" "Language-Team: Kazakh <kk_KZ@googlegroups.com>\n" @@ -243,7 +243,7 @@ msgstr "Командалар палитрасы" msgid "Terminal Inspector" msgstr "Терминал инспекторы" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "Ghostty туралы" @@ -315,15 +315,15 @@ msgstr "Осы терезедегі барлық терминал сессиял msgid "The currently running process in this split will be terminated." msgstr "Осы бөлудегі ағымдағы орындалып жатқан процесс тоқтатылады." -#: src/apprt/gtk/class/surface.zig:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Команда аяқталды" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Команда сәтті орындалды" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Команда сәтсіз аяқталды" @@ -347,14 +347,14 @@ msgstr "Бет атауын өзгерту" msgid "Reloaded the configuration" msgstr "Конфигурация қайта жүктелді" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Алмасу буферіне көшірілді" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Алмасу буфері тазартылды" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Ghostty әзірлеушілері" diff --git a/po/ko_KR.po b/po/ko_KR.po index 25de54e70..dbbdc20db 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-11 12:50+0900\n" "Last-Translator: GyuYong Jung <obliviscence@gmail.com>\n" "Language-Team: Korean <translation-team-ko@googlegroups.com>\n" @@ -239,7 +239,7 @@ msgstr "명령 팔레트" msgid "Terminal Inspector" msgstr "터미널 인스펙터" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "Ghostty 정보" @@ -311,15 +311,15 @@ msgstr "이 창의 모든 터미널 세션이 종료됩니다." msgid "The currently running process in this split will be terminated." msgstr "이 분할에서 현재 실행 중인 프로세스가 종료됩니다." -#: src/apprt/gtk/class/surface.zig:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "명령 완료" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "명령 성공" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "명령 실패" @@ -343,14 +343,14 @@ msgstr "탭 제목 변경" msgid "Reloaded the configuration" msgstr "설정값을 다시 불러왔습니다" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "클립보드에 복사됨" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "클립보드 지워짐" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Ghostty 개발자들" diff --git a/po/lt.po b/po/lt.po index 1741a0073..c2851831c 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-20 12:13+0100\n" "Last-Translator: Tadas Lotuzas <tdslot@gmail.com>\n" "Language-Team: Language LT\n" @@ -15,6 +15,8 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"(n%100<10 || n%100>=20) ? 1 : 2);\n" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" @@ -240,7 +242,7 @@ msgstr "Komandų paletė" msgid "Terminal Inspector" msgstr "Terminalo inspektorius" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "Apie Ghostty" @@ -312,15 +314,15 @@ 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:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Komanda užbaigta" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Komanda sėkminga" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Komanda nepavyko" @@ -344,14 +346,14 @@ msgstr "Keisti kortelės pavadinimą" msgid "Reloaded the configuration" msgstr "Konfigūracija įkelta iš naujo" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Nukopijuota į iškarpinę" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Iškarpinė išvalyta" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Ghostty kūrėjai" diff --git a/po/lv.po b/po/lv.po index 81815c735..c692c4215 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-09 03:24+0200\n" "Last-Translator: Ēriks Remess <eriks@remess.lv>\n" "Language-Team: Latvian\n" @@ -239,7 +239,7 @@ msgstr "Komandu palete" msgid "Terminal Inspector" msgstr "Termināļa inspektors" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "Par Ghostty" @@ -311,15 +311,15 @@ 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:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Komanda izpildīta" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Komanda izdevās" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Komanda neizdevās" @@ -343,14 +343,14 @@ msgstr "Mainīt cilnes virsrakstu" msgid "Reloaded the configuration" msgstr "Konfigurācija pārlādēta" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Nokopēts starpliktuvē" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Starpliktuve notīrīta" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Ghostty izstrādātāji" diff --git a/po/mk.po b/po/mk.po index 2d5f01a4b..4f2403d15 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-12 17:00+0100\n" "Last-Translator: Andrej Daskalov <andrej.daskalov@gmail.com>\n" "Language-Team: Macedonian\n" @@ -242,7 +242,7 @@ msgstr "Командна палета" msgid "Terminal Inspector" msgstr "Инспектор на терминал" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "За Ghostty" @@ -314,15 +314,15 @@ msgstr "Сите сесии во овој прозорец ќе бидат пр msgid "The currently running process in this split will be terminated." msgstr "Процесот кој моментално се извршува во оваа поделба ќе биде прекинат." -#: src/apprt/gtk/class/surface.zig:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Командата заврши" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Командата успеа" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Командата не успеа" @@ -346,14 +346,14 @@ msgstr "Промени наслов на јазиче" msgid "Reloaded the configuration" msgstr "Конфигурацијата е одново вчитана" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Копирано во привремена меморија" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Исчистена привремена меморија" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Развивачи на Ghostty" diff --git a/po/nb.po b/po/nb.po index 0c624442e..36c0b9f89 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-12 15:50+0000\n" "Last-Translator: Hanna Rose <me@hanna.lol>\n" "Language-Team: Norwegian Bokmal <l10n-no@lister.huftis.org>\n" @@ -243,7 +243,7 @@ msgstr "Kommandopalett" msgid "Terminal Inspector" msgstr "Terminalinspektør" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "Om Ghostty" @@ -315,15 +315,15 @@ 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:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Kommandoen fullført" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Kommandoen lyktes" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Kommandoen mislyktes" @@ -347,14 +347,14 @@ msgstr "Endre fanetittel" msgid "Reloaded the configuration" msgstr "Konfigurasjonen ble lastet på nytt" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Kopiert til utklippstavlen" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Utklippstavle tømt" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Ghostty-utviklere" diff --git a/po/nl.po b/po/nl.po index 9f0a3722f..8245fbe09 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-18 20:59+0100\n" "Last-Translator: Nico Geesink <geesinknico@gmail.com>\n" "Language-Team: Dutch <vertaling@vrijschrift.org>\n" @@ -243,7 +243,7 @@ msgstr "Opdrachtpalet" msgid "Terminal Inspector" msgstr "Terminalinspecteur" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "Over Ghostty" @@ -315,15 +315,15 @@ 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:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Commando afgerond" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Commando succesvol afgerond" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Commando onsuccesvol afgerond" @@ -347,14 +347,14 @@ msgstr "Wijzig tabbladtitel" msgid "Reloaded the configuration" msgstr "De configuratie is herladen" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Gekopieerd naar klembord" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Klembord geleegd" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Ghostty-ontwikkelaars" diff --git a/po/pl.po b/po/pl.po index be7d19a05..07d6efffd 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-11 14:12+0100\n" "Last-Translator: trag1c <dev@jakubr.me>\n" "Language-Team: Polish <translation-team-pl@lists.sourceforge.net>\n" @@ -243,7 +243,7 @@ msgstr "Paleta komend" msgid "Terminal Inspector" msgstr "Inspektor terminala" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "O Ghostty" @@ -315,15 +315,15 @@ 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:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Komenda zakończona" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Komenda wykonana pomyślnie" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Komenda nie powiodła się" @@ -347,14 +347,14 @@ msgstr "Zmień tytuł karty" msgid "Reloaded the configuration" msgstr "Przeładowano konfigurację" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Skopiowano do schowka" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Wyczyszczono schowek" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Twórcy Ghostty" diff --git a/po/pt_BR.po b/po/pt_BR.po index a8c4b8188..2b6a48d03 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-18 10:50-0300\n" "Last-Translator: Guilherme Tiscoski <github@guilhermetiscoski.com>\n" "Language-Team: Brazilian Portuguese <ldpbr-" @@ -245,7 +245,7 @@ msgstr "Paleta de comandos" msgid "Terminal Inspector" msgstr "Inspetor de terminal" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "Sobre o Ghostty" @@ -317,15 +317,15 @@ msgstr "Todas as sessões de terminal nessa janela serão finalizadas." msgid "The currently running process in this split will be terminated." msgstr "O processo atual rodando nessa divisão será finalizado." -#: src/apprt/gtk/class/surface.zig:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Comando finalizado" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Comando bem-sucedido" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Comando falhou" @@ -349,14 +349,14 @@ msgstr "Mudar título da aba" msgid "Reloaded the configuration" msgstr "Configuração recarregada" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Copiado para a área de transferência" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Área de transferência limpa" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Desenvolvedores do Ghostty" diff --git a/po/ru.po b/po/ru.po index 319cd7841..b6dabcc7d 100644 --- a/po/ru.po +++ b/po/ru.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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2025-02-18 10:20+0100\n" "Last-Translator: Ivan Bastrakov <bastaynav@proton.me>\n" "Language-Team: Russian <gnu@d07.ru>\n" @@ -244,7 +244,7 @@ msgstr "Палитра команд" msgid "Terminal Inspector" msgstr "Инспектор терминала" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "О Ghostty" @@ -316,15 +316,15 @@ msgstr "Все сессии терминала в этом окне будут msgid "The currently running process in this split will be terminated." msgstr "Процесс, работающий в этой сплит-области, будет остановлен." -#: src/apprt/gtk/class/surface.zig:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Команда завершилась" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Команда выполнена успешно" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Команда завершилась с ошибкой" @@ -348,14 +348,14 @@ msgstr "Переименовать вкладку" msgid "Reloaded the configuration" msgstr "Конфигурация перезагружена" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Скопировано в буфер обмена" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Буфер обмена очищен" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Разработчики Ghostty" diff --git a/po/tr.po b/po/tr.po index 1766a5850..d52c3f5b3 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-09 22:18+0300\n" "Last-Translator: Emir SARI <emir_sari@icloud.com>\n" "Language-Team: Turkish\n" @@ -243,7 +243,7 @@ msgstr "Komut Paleti" msgid "Terminal Inspector" msgstr "Uçbirim Denetçisi" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "Ghostty Hakkında" @@ -315,15 +315,15 @@ 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:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Komut Bitti" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Komut Başarılı" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Komut Başarısız" @@ -347,14 +347,14 @@ msgstr "Sekme Başlığını Değiştir" msgid "Reloaded the configuration" msgstr "Yapılandırma yeniden yüklendi" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Panoya kopyalandı" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Pano temizlendi" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Ghostty Geliştiricileri" diff --git a/po/uk.po b/po/uk.po index b35186a8b..6ba5a5e1e 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-18 13:14+0100\n" "Last-Translator: Volodymyr Chernetskyi " "<19735328+chernetskyi@users.noreply.github.com>\n" @@ -242,7 +242,7 @@ msgstr "Палітра команд" msgid "Terminal Inspector" msgstr "Інспектор терміналу" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "Про Ghostty" @@ -314,15 +314,15 @@ msgstr "Всі сесії терміналу в цьому вікні будут msgid "The currently running process in this split will be terminated." msgstr "Процес, що виконується в цій панелі, буде завершено." -#: src/apprt/gtk/class/surface.zig:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "Команда завершилась" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "Команда завершилась успішно" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "Команда завершилась з помилкою" @@ -346,14 +346,14 @@ msgstr "Змінити заголовок вкладки" msgid "Reloaded the configuration" msgstr "Налаштування перезавантажено" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "Скопійовано до буферa обміну" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "Буфер обміну очищено" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Розробники Ghostty" diff --git a/po/vi.po b/po/vi.po new file mode 100644 index 000000000..ebbfd425c --- /dev/null +++ b/po/vi.po @@ -0,0 +1,358 @@ +# Vietnamese translations for com.mitchellh.ghostty package. +# Copyright (C) 2026 "Mitchell Hashimoto, Ghostty contributors" +# This file is distributed under the same license as the com.mitchellh.ghostty package. +# Anh Thang <buianhthang89@gmail.com>, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: com.mitchellh.ghostty\n" +"Report-Msgid-Bugs-To: m@mitchellh.com\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" +"PO-Revision-Date: 2026-03-04 09:32+0700\n" +"Last-Translator: Anh Thang <buianhthang89@gmail.com>\n" +"Language-Team: Vietnamese <translation-team-vi@lists.sourceforge.net>\n" +"Language: vi\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: dist/linux/ghostty_nautilus.py:53 +msgid "Open in Ghostty" +msgstr "Mở Ghostty" + +#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197 +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:201 +msgid "Authorize Clipboard Access" +msgstr "Cho phép Truy cập Bảng tạm" + +#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:17 +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:17 +msgid "Deny" +msgstr "Từ chối" + +#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:18 +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:18 +msgid "Allow" +msgstr "Cho phép" + +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:92 +msgid "Remember choice for this split" +msgstr "Ghi nhớ lựa chọn cho chia màn hình này" + +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:93 +msgid "Reload configuration to show this prompt again" +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 +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.3/surface-child-exited.blp:17 +msgid "Close" +msgstr "Đóng" + +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:6 +msgid "Configuration Errors" +msgstr "Lỗi cấu hình" + +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +msgid "" +"One or more configuration errors were found. Please review the errors below, " +"and either reload your configuration or ignore these errors." +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 +msgid "Ignore" +msgstr "Bỏ qua" + +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 +#: src/apprt/gtk/ui/1.2/surface.blp:371 src/apprt/gtk/ui/1.5/window.blp:300 +msgid "Reload Configuration" +msgstr "Tải lại cấu hình" + +#: src/apprt/gtk/ui/1.2/debug-warning.blp:7 +#: src/apprt/gtk/ui/1.3/debug-warning.blp:6 +msgid "" +"⚠️ You're running a debug build of Ghostty! Performance will be degraded." +msgstr "" +"⚠️ Bạn đang chạy bản build thử nghiệm (debug) của Ghostty! Hiệu năng sẽ bị " +"giảm." + +#: src/apprt/gtk/ui/1.5/inspector-window.blp:5 +msgid "Ghostty: Terminal Inspector" +msgstr "Ghostty: Bộ kiểm tra Terminal" + +#: src/apprt/gtk/ui/1.2/search-overlay.blp:29 +msgid "Find…" +msgstr "Tìm kiếm…" + +#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +msgid "Previous Match" +msgstr "Kết quả trước" + +#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +msgid "Next Match" +msgstr "Kết quả tiếp theo" + +#: src/apprt/gtk/ui/1.2/surface.blp:6 +msgid "Oh, no." +msgstr "Ôi hỏng rồi." + +#: src/apprt/gtk/ui/1.2/surface.blp:7 +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:97 +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 "" +"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:107 +msgid "Read-only" +msgstr "Chỉ đọc" + +#: src/apprt/gtk/ui/1.2/surface.blp:260 src/apprt/gtk/ui/1.5/window.blp:200 +msgid "Copy" +msgstr "Sao chép" + +#: src/apprt/gtk/ui/1.2/surface.blp:265 src/apprt/gtk/ui/1.5/window.blp:205 +msgid "Paste" +msgstr "Dán" + +#: src/apprt/gtk/ui/1.2/surface.blp:270 +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:277 src/apprt/gtk/ui/1.5/window.blp:273 +msgid "Clear" +msgstr "Xóa" + +#: src/apprt/gtk/ui/1.2/surface.blp:282 src/apprt/gtk/ui/1.5/window.blp:278 +msgid "Reset" +msgstr "Đặt lại" + +#: src/apprt/gtk/ui/1.2/surface.blp:289 src/apprt/gtk/ui/1.5/window.blp:242 +msgid "Split" +msgstr "Chia màn hình" + +#: src/apprt/gtk/ui/1.2/surface.blp:292 src/apprt/gtk/ui/1.5/window.blp:245 +msgid "Change Title…" +msgstr "Đổi tiêu đề…" + +#: src/apprt/gtk/ui/1.2/surface.blp:297 src/apprt/gtk/ui/1.5/window.blp:177 +#: src/apprt/gtk/ui/1.5/window.blp:250 +msgid "Split Up" +msgstr "Chia lên trên" + +#: src/apprt/gtk/ui/1.2/surface.blp:303 src/apprt/gtk/ui/1.5/window.blp:182 +#: src/apprt/gtk/ui/1.5/window.blp:255 +msgid "Split Down" +msgstr "Chia xuống dưới" + +#: src/apprt/gtk/ui/1.2/surface.blp:309 src/apprt/gtk/ui/1.5/window.blp:187 +#: src/apprt/gtk/ui/1.5/window.blp:260 +msgid "Split Left" +msgstr "Chia sang trái" + +#: src/apprt/gtk/ui/1.2/surface.blp:315 src/apprt/gtk/ui/1.5/window.blp:192 +#: src/apprt/gtk/ui/1.5/window.blp:265 +msgid "Split Right" +msgstr "Chia sang phải" + +#: src/apprt/gtk/ui/1.2/surface.blp:321 +msgid "Close Split" +msgstr "" + +#: src/apprt/gtk/ui/1.2/surface.blp:327 +msgid "Tab" +msgstr "Tab" + +#: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:224 +#: src/apprt/gtk/ui/1.5/window.blp:320 +msgid "Change Tab Title…" +msgstr "Đổi tiêu đề Tab…" + +#: src/apprt/gtk/ui/1.2/surface.blp:335 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 +msgid "New Tab" +msgstr "Tab mới" + +#: src/apprt/gtk/ui/1.2/surface.blp:340 src/apprt/gtk/ui/1.5/window.blp:234 +msgid "Close Tab" +msgstr "Đóng Tab" + +#: src/apprt/gtk/ui/1.2/surface.blp:347 +msgid "Window" +msgstr "Cửa sổ" + +#: src/apprt/gtk/ui/1.2/surface.blp:350 src/apprt/gtk/ui/1.5/window.blp:212 +msgid "New Window" +msgstr "Cửa sổ mới" + +#: src/apprt/gtk/ui/1.2/surface.blp:355 src/apprt/gtk/ui/1.5/window.blp:217 +msgid "Close Window" +msgstr "Đóng cửa sổ" + +#: src/apprt/gtk/ui/1.2/surface.blp:363 +msgid "Config" +msgstr "Cấu hình" + +#: src/apprt/gtk/ui/1.2/surface.blp:366 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.5/title-dialog.blp:5 +msgid "Leave blank to restore the default title." +msgstr "Để trống để khôi phục tiêu đề mặc định." + +#: src/apprt/gtk/ui/1.5/title-dialog.blp:9 +msgid "OK" +msgstr "Đồng ý" + +#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +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 +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 +msgid "Main Menu" +msgstr "Trình đơn chính" + +#: src/apprt/gtk/ui/1.5/window.blp:285 +msgid "Command Palette" +msgstr "Bảng lệnh" + +#: src/apprt/gtk/ui/1.5/window.blp:290 +msgid "Terminal Inspector" +msgstr "Bộ kiểm tra Terminal" + +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 +msgid "About Ghostty" +msgstr "Về Ghostty" + +#: src/apprt/gtk/ui/1.5/window.blp:312 +msgid "Quit" +msgstr "Thoát" + +#: src/apprt/gtk/ui/1.5/command-palette.blp:17 +msgid "Execute a command…" +msgstr "Chạy một lệnh…" + +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 +msgid "" +"An application is attempting to write to the clipboard. The current " +"clipboard contents are shown below." +msgstr "" +"Một ứng dụng đang cố gắng ghi vào bảng tạm. Nội dung hiện tại của bảng tạm " +"được hiển thị bên dưới." + +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:202 +msgid "" +"An application is attempting to read from the clipboard. The current " +"clipboard contents are shown below." +msgstr "" +"Một ứng dụng đang cố gắng đọc từ bảng tạm. Nội dung hiện tại của bảng tạm " +"được hiển thị bên dưới." + +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:205 +msgid "Warning: Potentially Unsafe Paste" +msgstr "Cảnh báo: Thao tác Dán có thể không an toàn" + +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:206 +msgid "" +"Pasting this text into the terminal may be dangerous as it looks like some " +"commands may be executed." +msgstr "" +"Dán văn bản này vào terminal có thể nguy hiểm vì có vẻ như một số lệnh sẽ bị " +"thực thi." + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:184 +msgid "Quit Ghostty?" +msgstr "Thoát Ghostty?" + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:185 +msgid "Close Tab?" +msgstr "Đóng Tab?" + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:186 +msgid "Close Window?" +msgstr "Đóng cửa sổ?" + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:187 +msgid "Close Split?" +msgstr "Đóng phần chia màn hình?" + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:193 +msgid "All terminal sessions will be terminated." +msgstr "Tất cả các phiên làm việc terminal sẽ bị chấm dứt." + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:194 +msgid "All terminal sessions in this tab will be terminated." +msgstr "Tất cả các phiên làm việc terminal trong tab này sẽ bị chấm dứt." + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:195 +msgid "All terminal sessions in this window will be terminated." +msgstr "Tất cả các phiên làm việc terminal trong cửa sổ này sẽ bị chấm dứt." + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:196 +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:1141 +msgid "Command Finished" +msgstr "Lệnh đã kết thúc" + +#: src/apprt/gtk/class/surface.zig:1142 +msgid "Command Succeeded" +msgstr "Lệnh thành công" + +#: src/apprt/gtk/class/surface.zig:1143 +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 +msgid "Change Terminal Title" +msgstr "Đổi tiêu đề Terminal" + +#: src/apprt/gtk/class/title_dialog.zig:226 +msgid "Change Tab Title" +msgstr "Đổi tiêu đề Tab" + +#: src/apprt/gtk/class/window.zig:1067 +msgid "Reloaded the configuration" +msgstr "Đã tải lại cấu hình" + +#: src/apprt/gtk/class/window.zig:1611 +msgid "Copied to clipboard" +msgstr "Đã sao chép vào bảng tạm" + +#: src/apprt/gtk/class/window.zig:1613 +msgid "Cleared clipboard" +msgstr "Đã xóa sạch bảng tạm" + +#: src/apprt/gtk/class/window.zig:1753 +msgid "Ghostty Developers" +msgstr "Các nhà phát triển Ghostty" diff --git a/po/zh_CN.po b/po/zh_CN.po index 07f0b44dc..3848fb17a 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-12 01:56+0800\n" "Last-Translator: Leah <hi@pluie.me>\n" "Language-Team: Chinese (simplified) <i18n-zh@googlegroups.com>\n" @@ -16,6 +16,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" @@ -238,7 +239,7 @@ msgstr "命令面板" msgid "Terminal Inspector" msgstr "终端调试器" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "关于 Ghostty" @@ -304,15 +305,15 @@ msgstr "窗口内所有运行中的进程将被终止。" msgid "The currently running process in this split will be terminated." msgstr "分屏内正在运行中的进程将被终止。" -#: src/apprt/gtk/class/surface.zig:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "命令已完成" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "命令执行成功" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "命令执行失败" @@ -336,14 +337,14 @@ msgstr "更改标签页标题" msgid "Reloaded the configuration" msgstr "已重新加载配置" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "已复制至剪贴板" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "已清空剪贴板" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Ghostty 开发团队" diff --git a/po/zh_TW.po b/po/zh_TW.po index 5bad77343..2cf5044d3 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-03-04 21:13-0800\n" +"POT-Creation-Date: 2026-03-25 16:24-0700\n" "PO-Revision-Date: 2026-02-18 13:58+0800\n" "Last-Translator: Yi-Jyun Pan <me@pan93.com>\n" "Language-Team: Chinese (traditional)\n" @@ -15,6 +15,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" @@ -236,7 +237,7 @@ msgstr "命令面板" msgid "Terminal Inspector" msgstr "終端機檢查工具" -#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1787 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1772 msgid "About Ghostty" msgstr "關於 Ghostty" @@ -302,15 +303,15 @@ msgstr "此視窗中的所有終端機工作階段都將被終止。" msgid "The currently running process in this split will be terminated." msgstr "此窗格中目前執行的處理程序將被終止。" -#: src/apprt/gtk/class/surface.zig:1131 +#: src/apprt/gtk/class/surface.zig:1141 msgid "Command Finished" msgstr "命令執行完成" -#: src/apprt/gtk/class/surface.zig:1132 +#: src/apprt/gtk/class/surface.zig:1142 msgid "Command Succeeded" msgstr "命令執行成功" -#: src/apprt/gtk/class/surface.zig:1133 +#: src/apprt/gtk/class/surface.zig:1143 msgid "Command Failed" msgstr "命令執行失敗" @@ -334,14 +335,14 @@ msgstr "變更分頁標題" msgid "Reloaded the configuration" msgstr "已重新載入設定" -#: src/apprt/gtk/class/window.zig:1626 +#: src/apprt/gtk/class/window.zig:1611 msgid "Copied to clipboard" msgstr "已複製到剪貼簿" -#: src/apprt/gtk/class/window.zig:1628 +#: src/apprt/gtk/class/window.zig:1613 msgid "Cleared clipboard" msgstr "已清除剪貼簿" -#: src/apprt/gtk/class/window.zig:1768 +#: src/apprt/gtk/class/window.zig:1753 msgid "Ghostty Developers" msgstr "Ghostty 開發者" diff --git a/snap/local/launcher b/snap/local/launcher index 71b92f5bb..6057881b3 100755 --- a/snap/local/launcher +++ b/snap/local/launcher @@ -41,7 +41,7 @@ fi export LD_LIBRARY_PATH=${SNAP}/usr/lib/${ARCH}:${SNAP}/usr/lib/${ARCH}/vdpau:${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:} export LIBGL_DRIVERS_PATH=${LIBGL_DRIVERS_PATH:+$LIBGL_DRIVERS_PATH:}${SNAP}/usr/lib/${ARCH}/dri/ export LIBVA_DRIVERS_PATH=${LIBVA_DRIVERS_PATH:+$LIBVA_DRIVERS_PATH:}${SNAP}/usr/lib/${ARCH}/dri/ -export __EGL_VENDOR_LIBRARY_DIRS=${__EGL_VENDOR_LIBRARY_DIRS:+$__EGL_VENDOR_LIBRARY_DIRS:}${SNAP}/usr/share/glvnd/egl_vendor.d +export __EGL_VENDOR_LIBRARY_DIRS=${__EGL_VENDOR_LIBRARY_DIRS:+$__EGL_VENDOR_LIBRARY_DIRS:}/etc/glvnd/egl_vendor.d:/usr/share/glvnd/egl_vendor.d:${SNAP}/usr/share/glvnd/egl_vendor.d export __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS=${__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:+$__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:}${SNAP}/usr/share/egl/egl_external_platform.d export DRIRC_CONFIGDIR=${SNAP}/usr/share/drirc.d export VK_LAYER_PATH=${VK_LAYER_PATH:+$VK_LAYER_PATH:}${SNAP}/usr/share/vulkan/implicit_layer.d/:${SNAP}/usr/share/vulkan/explicit_layer.d/ diff --git a/src/Command.zig b/src/Command.zig index 3a40143b9..2b381912b 100644 --- a/src/Command.zig +++ b/src/Command.zig @@ -725,6 +725,11 @@ test "Command: redirect stdout to file" { .path = "C:\\Windows\\System32\\whoami.exe", .args = &.{"C:\\Windows\\System32\\whoami.exe"}, .stdout = stdout, + .os_pre_exec = null, + .rt_pre_exec = null, + .rt_post_fork = null, + .rt_pre_exec_info = undefined, + .rt_post_fork_info = undefined, } else .{ .path = "/bin/sh", .args = &.{ "/bin/sh", "-c", "echo hello" }, diff --git a/src/Surface.zig b/src/Surface.zig index a3691b53e..c8055cfee 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -36,6 +36,7 @@ const App = @import("App.zig"); const internal_os = @import("os/main.zig"); const inspectorpkg = @import("inspector/main.zig"); const SurfaceMouse = @import("surface_mouse.zig"); +const ProcessInfo = @import("pty.zig").ProcessInfo; const log = std.log.scoped(.surface); @@ -324,7 +325,7 @@ const DerivedConfig = struct { window_padding_bottom: u32, window_padding_left: u32, window_padding_right: u32, - window_padding_balance: bool, + window_padding_balance: configpkg.Config.WindowPaddingBalance, window_height: u32, window_width: u32, title: ?[:0]const u8, @@ -536,8 +537,8 @@ pub fn init( x_dpi, y_dpi, ); - if (derived_config.window_padding_balance) { - size.balancePadding(explicit); + if (derived_config.window_padding_balance != .false) { + size.balancePadding(explicit, derived_config.window_padding_balance); } else { size.padding = explicit; } @@ -639,7 +640,7 @@ pub fn init( .shell_integration = config.@"shell-integration", .shell_integration_features = config.@"shell-integration-features", .cursor_blink = config.@"cursor-style-blink", - .working_directory = config.@"working-directory", + .working_directory = if (config.@"working-directory") |wd| wd.value() else null, .resources_dir = global_state.resources_dir.host(), .term = config.term, .rt_pre_exec_info = .init(config), @@ -2462,11 +2463,11 @@ fn resize(self: *Surface, size: rendererpkg.ScreenSize) !void { /// Recalculate the balanced padding if needed. fn balancePaddingIfNeeded(self: *Surface) void { - if (!self.config.window_padding_balance) return; + if (self.config.window_padding_balance == .false) return; const content_scale = try self.rt_surface.getContentScale(); const x_dpi = content_scale.x * font.face.default_dpi; const y_dpi = content_scale.y * font.face.default_dpi; - self.size.balancePadding(self.config.scaledPadding(x_dpi, y_dpi)); + self.size.balancePadding(self.config.scaledPadding(x_dpi, y_dpi), self.config.window_padding_balance); } /// Called to set the preedit state for character input. Preedit is used @@ -3518,7 +3519,7 @@ pub fn scrollCallback( if (self.isMouseReporting()) { for (0..@abs(y.delta)) |_| { const pos = try self.rt_surface.getCursorPos(); - try self.mouseReport(switch (y.direction()) { + self.mouseReport(switch (y.direction()) { .up_right => .four, .down_left => .five, }, .press, self.mouse.mods, pos); @@ -3526,7 +3527,7 @@ pub fn scrollCallback( for (0..@abs(x.delta)) |_| { const pos = try self.rt_surface.getCursorPos(); - try self.mouseReport(switch (x.direction()) { + self.mouseReport(switch (x.direction()) { .up_right => .six, .down_left => .seven, }, .press, self.mouse.mods, pos); @@ -3576,7 +3577,7 @@ pub fn contentScaleCallback(self: *Surface, content_scale: apprt.ContentScale) ! // Update our padding which is dependent on DPI. We only do this for // unbalanced padding since balanced padding is not dependent on DPI. - if (!self.config.window_padding_balance) { + if (self.config.window_padding_balance == .false) { self.size.padding = self.config.scaledPadding(x_dpi, y_dpi); } @@ -3585,9 +3586,6 @@ pub fn contentScaleCallback(self: *Surface, content_scale: apprt.ContentScale) ! try self.resize(self.size.screen); } -/// The type of action to report for a mouse event. -const MouseReportAction = enum { press, release, motion }; - /// Returns true if mouse reporting is enabled both in the config and /// the terminal state. fn isMouseReporting(self: *const Surface) bool { @@ -3598,228 +3596,65 @@ fn isMouseReporting(self: *const Surface) bool { fn mouseReport( self: *Surface, button: ?input.MouseButton, - action: MouseReportAction, + action: input.MouseAction, mods: input.Mods, pos: apprt.CursorPos, -) !void { +) void { // Mouse reporting must be enabled by both config and terminal state assert(self.config.mouse_reporting); assert(self.io.terminal.flags.mouse_event != .none); - // Depending on the event, we may do nothing at all. - switch (self.io.terminal.flags.mouse_event) { - .none => unreachable, // checked by assert above + // Build our encoding options. + const encoding_opts: input.mouse_encode.Options = opts: { + // Terminal and size state. + var opts: input.mouse_encode.Options = .fromTerminal( + &self.io.terminal, + self.size, + ); - // X10 only reports clicks with mouse button 1, 2, 3. We verify - // the button later. - .x10 => if (action != .press or - button == null or - !(button.? == .left or - button.? == .right or - button.? == .middle)) return, - - // Doesn't report motion - .normal => if (action == .motion) return, - - // Button must be pressed - .button => if (button == null) return, - - // Everything - .any => {}, - } - - // Handle scenarios where the mouse position is outside the viewport. - // We always report release events no matter where they happen. - if (action != .release) { - const pos_out_viewport = pos_out_viewport: { - const max_x: f32 = @floatFromInt(self.size.screen.width); - const max_y: f32 = @floatFromInt(self.size.screen.height); - break :pos_out_viewport pos.x < 0 or pos.y < 0 or - pos.x > max_x or pos.y > max_y; - }; - if (pos_out_viewport) outside_viewport: { - // If we don't have a motion-tracking event mode, do nothing. - if (!self.io.terminal.flags.mouse_event.motion()) return; - - // If any button is pressed, we still do the report. Otherwise, - // we do not do the report. + // Whether any button is pressed at all. + opts.any_button_pressed = pressed: { for (self.mouse.click_state) |state| { - if (state != .release) break :outside_viewport; + if (state != .release) break :pressed true; } - return; - } - } + break :pressed false; + }; - // This format reports X/Y - const viewport_point = self.posToViewport(pos.x, pos.y); + // Keep track of our last reported viewport cell for event + // deduplication. + opts.last_cell = &self.mouse.event_point; - // Record our new point. We only want to send a mouse event if the - // cell changed, unless we're tracking raw pixels. - if (action == .motion and self.io.terminal.flags.mouse_format != .sgr_pixels) { - if (self.mouse.event_point) |last_point| { - if (last_point.eql(viewport_point)) return; - } - } - self.mouse.event_point = viewport_point; - - // Get the code we'll actually write - const button_code: u8 = code: { - var acc: u8 = 0; - - // Determine our initial button value - if (button == null) { - // Null button means motion without a button pressed - acc = 3; - } else if (action == .release and - self.io.terminal.flags.mouse_format != .sgr and - self.io.terminal.flags.mouse_format != .sgr_pixels) - { - // Release is 3. It is NOT 3 in SGR mode because SGR can tell - // the application what button was released. - acc = 3; - } else { - acc = switch (button.?) { - .left => 0, - .middle => 1, - .right => 2, - .four => 64, - .five => 65, - .six => 66, - .seven => 67, - .eight => 128, - .nine => 129, - else => return, // unsupported - }; - } - - // X10 doesn't have modifiers - if (self.io.terminal.flags.mouse_event != .x10) { - if (mods.shift) acc += 4; - if (mods.alt) acc += 8; - if (mods.ctrl) acc += 16; - } - - // Motion adds another bit - if (action == .motion) acc += 32; - - break :code acc; + break :opts opts; }; - switch (self.io.terminal.flags.mouse_format) { - .x10 => { - if (viewport_point.x > 222 or viewport_point.y > 222) { - log.info("X10 mouse format can only encode X/Y up to 223", .{}); - return; - } - - // + 1 below is because our x/y is 0-indexed and the protocol wants 1 - var data: termio.Message.WriteReq.Small.Array = undefined; - assert(data.len >= 6); - data[0] = '\x1b'; - data[1] = '['; - data[2] = 'M'; - data[3] = 32 + button_code; - data[4] = 32 + @as(u8, @intCast(viewport_point.x)) + 1; - data[5] = 32 + @as(u8, @intCast(viewport_point.y)) + 1; - - // Ask our IO thread to write the data - self.queueIo(.{ .write_small = .{ - .data = data, - .len = 6, - } }, .locked); + var data: termio.Message.WriteReq.Small.Array = undefined; + var writer: std.Io.Writer = .fixed(&data); + input.mouse_encode.encode(&writer, .{ + .button = button, + .action = action, + .mods = mods, + .pos = .{ + .x = pos.x, + .y = pos.y, }, - - .utf8 => { - // Maximum of 12 because at most we have 2 fully UTF-8 encoded chars - var data: termio.Message.WriteReq.Small.Array = undefined; - assert(data.len >= 12); - data[0] = '\x1b'; - data[1] = '['; - data[2] = 'M'; - - // The button code will always fit in a single u8 - data[3] = 32 + button_code; - - // UTF-8 encode the x/y - var i: usize = 4; - i += try std.unicode.utf8Encode(@intCast(32 + viewport_point.x + 1), data[i..]); - i += try std.unicode.utf8Encode(@intCast(32 + viewport_point.y + 1), data[i..]); - - // Ask our IO thread to write the data - self.queueIo(.{ .write_small = .{ - .data = data, - .len = @intCast(i), - } }, .locked); + }, encoding_opts) catch |err| switch (err) { + error.WriteFailed => { + // This should never happen since mouse events should never + // be able to overflow the size of our small array. But if it + // does, let's log it and return. No need to crash upstreams. + // In the future we may want to fall back to allocation. + log.warn("failed to encode mouse event err={}", .{err}); + return; }, + }; + const written = writer.buffered(); + if (written.len == 0) return; - .sgr => { - // Final character to send in the CSI - const final: u8 = if (action == .release) 'm' else 'M'; - - // Response always is at least 4 chars, so this leaves the - // remainder for numbers which are very large... - var data: termio.Message.WriteReq.Small.Array = undefined; - const resp = try std.fmt.bufPrint(&data, "\x1B[<{d};{d};{d}{c}", .{ - button_code, - viewport_point.x + 1, - viewport_point.y + 1, - final, - }); - - // Ask our IO thread to write the data - self.queueIo(.{ .write_small = .{ - .data = data, - .len = @intCast(resp.len), - } }, .locked); - }, - - .urxvt => { - // Response always is at least 4 chars, so this leaves the - // remainder for numbers which are very large... - var data: termio.Message.WriteReq.Small.Array = undefined; - const resp = try std.fmt.bufPrint(&data, "\x1B[{d};{d};{d}M", .{ - 32 + button_code, - viewport_point.x + 1, - viewport_point.y + 1, - }); - - // Ask our IO thread to write the data - self.queueIo(.{ .write_small = .{ - .data = data, - .len = @intCast(resp.len), - } }, .locked); - }, - - .sgr_pixels => { - // Final character to send in the CSI - const final: u8 = if (action == .release) 'm' else 'M'; - - // The position has to be adjusted to the terminal space. - const coord: rendererpkg.Coordinate.Terminal = (rendererpkg.Coordinate{ - .surface = .{ - .x = pos.x, - .y = pos.y, - }, - }).convert(.terminal, self.size).terminal; - - // Response always is at least 4 chars, so this leaves the - // remainder for numbers which are very large... - var data: termio.Message.WriteReq.Small.Array = undefined; - const resp = try std.fmt.bufPrint(&data, "\x1B[<{d};{d};{d}{c}", .{ - button_code, - @as(i32, @intFromFloat(@round(coord.x))), - @as(i32, @intFromFloat(@round(coord.y))), - final, - }); - - // Ask our IO thread to write the data - self.queueIo(.{ .write_small = .{ - .data = data, - .len = @intCast(resp.len), - } }, .locked); - }, - } + self.queueIo(.{ .write_small = .{ + .data = data, + .len = @intCast(written.len), + } }, .locked); } /// Returns true if the shift modifier is allowed to be captured by modifier @@ -4003,12 +3838,12 @@ pub fn mouseButtonCallback( const pos = try self.rt_surface.getCursorPos(); - const report_action: MouseReportAction = switch (action) { + const report_action: input.MouseAction = switch (action) { .press => .press, .release => .release, }; - try self.mouseReport( + self.mouseReport( button, report_action, self.mouse.mods, @@ -4740,7 +4575,7 @@ pub fn cursorPosCallback( break :button @enumFromInt(i); } else null; - try self.mouseReport(button, .motion, self.mouse.mods, pos); + self.mouseReport(button, .motion, self.mouse.mods, pos); // If we're doing mouse motion tracking, we do not support text // selection. @@ -4968,14 +4803,14 @@ fn mouseSelection( break :ebs drag_pin.before(click_pin); }; - // Whether or not the the click pin cell + // Whether or not the click pin cell // should be included in the selection. const include_click_cell = if (end_before_start) click_x_frac >= threshold_point else click_x_frac < threshold_point; - // Whether or not the the drag pin cell + // Whether or not the drag pin cell // should be included in the selection. const include_drag_cell = if (end_before_start) drag_x_frac < threshold_point @@ -5482,6 +5317,26 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .tab, ), + .set_surface_title => |v| { + const title = try self.alloc.dupeZ(u8, v); + defer self.alloc.free(title); + return try self.rt_app.performAction( + .{ .surface = self }, + .set_title, + .{ .title = title }, + ); + }, + + .set_tab_title => |v| { + const title = try self.alloc.dupeZ(u8, v); + defer self.alloc.free(title); + return try self.rt_app.performAction( + .{ .surface = self }, + .set_tab_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 @@ -6488,6 +6343,13 @@ fn testMouseSelectionIsNull( ); } +/// Get information about the process(es) running within the surface. Returns +/// `null` if there was an error getting the information or the information is +/// not available on a particular platform. +pub fn getProcessInfo(self: *Surface, comptime info: ProcessInfo) ?ProcessInfo.Type(info) { + return self.io.getProcessInfo(info); +} + test "Surface: selection logic" { // We disable format to make these easier to // read by pairing sets of coordinates per line. diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 55e80a700..f6865af83 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -201,6 +201,9 @@ pub const Action = union(Key) { /// Set the title of the target to the requested value. set_title: SetTitle, + /// Set the tab title override for the target's tab. + set_tab_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. @@ -375,6 +378,7 @@ pub const Action = union(Key) { render_inspector, desktop_notification, set_title, + set_tab_title, prompt_title, pwd, mouse_shape, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 54d5472c6..0d5a4f8da 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -50,10 +50,11 @@ pub const App = struct { /// Callback called to handle an action. action: *const fn (*App, apprt.Target.C, apprt.Action.C) callconv(.c) bool, - /// Read the clipboard value. The return value must be preserved - /// by the host until the next call. If there is no valid clipboard - /// value then this should return null. - read_clipboard: *const fn (SurfaceUD, c_int, *apprt.ClipboardRequest) callconv(.c) void, + /// Read the clipboard value. Returns true if the clipboard request + /// was started and complete_clipboard_request may be called with the + /// given state pointer. Returns false if the clipboard request couldn't + /// be started (such as when no text is available for a paste request). + read_clipboard: *const fn (SurfaceUD, c_int, *apprt.ClipboardRequest) callconv(.c) bool, /// This may be called after a read clipboard call to request /// confirmation that the clipboard value is safe to read. The embedder @@ -512,7 +513,15 @@ pub const Surface = struct { break :wd; } - config.@"working-directory" = wd; + var wd_val: configpkg.WorkingDirectory = .{ .path = wd }; + if (wd_val.finalize(config.arenaAlloc())) |_| { + config.@"working-directory" = wd_val; + } else |err| { + log.warn( + "error finalizing working directory config dir={s} err={}", + .{ wd_val.path, err }, + ); + } } } @@ -672,14 +681,16 @@ pub const Surface = struct { errdefer alloc.destroy(state_ptr); state_ptr.* = state; - self.app.opts.read_clipboard( + const started = self.app.opts.read_clipboard( self.userdata, @intCast(@intFromEnum(clipboard_type)), state_ptr, ); + if (!started) { + alloc.destroy(state_ptr); + return false; + } - // Embedded apprt can't synchronously check clipboard content types, - // so we always return true to indicate the request was started. return true; } diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index 36a9290fb..eb33c4e4d 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -13,4 +13,5 @@ test { @import("std").testing.refAllDecls(@This()); _ = @import("gtk/ext.zig"); _ = @import("gtk/key.zig"); + _ = @import("gtk/portal.zig"); } diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 6c7310339..39c13c19d 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -22,16 +22,10 @@ const log = std.log.scoped(.gtk); pub const must_draw_from_app_thread = true; /// GTK application ID -pub const application_id = switch (builtin.mode) { - .Debug, .ReleaseSafe => "com.mitchellh.ghostty-debug", - .ReleaseFast, .ReleaseSmall => "com.mitchellh.ghostty", -}; +pub const application_id = @import("build/info.zig").application_id; /// GTK object path -pub const object_path = switch (builtin.mode) { - .Debug, .ReleaseSafe => "/com/mitchellh/ghostty_debug", - .ReleaseFast, .ReleaseSmall => "/com/mitchellh/ghostty", -}; +pub const object_path = @import("build/info.zig").object_path; /// The GObject Application instance app: *Application, diff --git a/src/apprt/gtk/build/gresource.zig b/src/apprt/gtk/build/gresource.zig index bcece4caa..c50ea8cd5 100644 --- a/src/apprt/gtk/build/gresource.zig +++ b/src/apprt/gtk/build/gresource.zig @@ -7,9 +7,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -/// Prefix/appid for the gresource file. -pub const prefix = "/com/mitchellh/ghostty"; -pub const app_id = "com.mitchellh.ghostty"; +const build_info = @import("info.zig"); /// The path to the Blueprint files. The folder structure is expected to be /// `{version}/{name}.blp` where `version` is the major and minor @@ -112,7 +110,7 @@ pub fn blueprint(comptime bp: Blueprint) [:0]const u8 { std.mem.eql(u8, candidate.name, bp.name)) { return std.fmt.comptimePrint("{s}/ui/{d}.{d}/{s}.ui", .{ - prefix, + build_info.resource_path, candidate.major, candidate.minor, candidate.name, @@ -173,7 +171,7 @@ fn genIcons(writer: *std.Io.Writer) !void { try writer.print( \\ <gresource prefix="{s}/icons"> \\ - , .{prefix}); + , .{build_info.resource_path}); const cwd = std.fs.cwd(); inline for (icon_sizes) |size| { @@ -186,7 +184,7 @@ fn genIcons(writer: *std.Io.Writer) !void { \\ <file alias="{s}/apps/{s}.png">{s}</file> \\ , - .{ alias, app_id, source }, + .{ alias, build_info.base_application_id, source }, ); } @@ -199,7 +197,7 @@ fn genIcons(writer: *std.Io.Writer) !void { \\ <file alias="{s}/apps/{s}.png">{s}</file> \\ , - .{ alias, app_id, source }, + .{ alias, build_info.base_application_id, source }, ); } } @@ -215,7 +213,7 @@ fn genRoot(writer: *std.Io.Writer) !void { try writer.print( \\ <gresource prefix="{s}"> \\ - , .{prefix}); + , .{build_info.resource_path}); const cwd = std.fs.cwd(); inline for (css) |name| { @@ -249,7 +247,7 @@ fn genUi( try writer.print( \\ <gresource prefix="{s}/ui"> \\ - , .{prefix}); + , .{build_info.resource_path}); for (files.items) |ui_file| { for (blueprints) |bp| { diff --git a/src/apprt/gtk/build/info.zig b/src/apprt/gtk/build/info.zig new file mode 100644 index 000000000..fc6478d81 --- /dev/null +++ b/src/apprt/gtk/build/info.zig @@ -0,0 +1,18 @@ +const builtin = @import("builtin"); + +/// Base application ID +pub const base_application_id = "com.mitchellh.ghostty"; + +/// GTK application ID +pub const application_id = switch (builtin.mode) { + .Debug, .ReleaseSafe => base_application_id ++ "-debug", + .ReleaseFast, .ReleaseSmall => base_application_id, +}; + +pub const resource_path = "/com/mitchellh/ghostty"; + +/// GTK object path +pub const object_path = switch (builtin.mode) { + .Debug, .ReleaseSafe => resource_path ++ "_debug", + .ReleaseFast, .ReleaseSmall => resource_path, +}; diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index c3ff51e0f..fa9b71eb7 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -9,6 +9,7 @@ const gobject = @import("gobject"); const gtk = @import("gtk"); const build_config = @import("../../../build_config.zig"); +const build_info = @import("../build/info.zig"); const state = &@import("../../../global.zig").state; const i18n = @import("../../../os/main.zig").i18n; const apprt = @import("../../../apprt.zig"); @@ -40,6 +41,7 @@ const Tab = @import("tab.zig").Tab; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; const ConfigErrorsDialog = @import("config_errors_dialog.zig").ConfigErrorsDialog; const GlobalShortcuts = @import("global_shortcuts.zig").GlobalShortcuts; +const OpenURI = @import("../portal.zig").OpenURI; const log = std.log.scoped(.gtk_ghostty_application); @@ -214,6 +216,8 @@ pub const Application = extern struct { /// not exist in Ghostty's environment variable. saved_language: ?[:0]const u8 = null, + open_uri: OpenURI = undefined, + pub var offset: c_int = 0; }; @@ -327,7 +331,7 @@ pub const Application = extern struct { } } - break :app_id ApprtApp.application_id; + break :app_id build_info.application_id; }; const display: *gdk.Display = gdk.Display.getDefault() orelse { @@ -350,7 +354,7 @@ pub const Application = extern struct { log.warn("error initializing windowing protocol err={}", .{err}); break :wp .{ .none = .{} }; }; - errdefer wp.deinit(alloc); + errdefer wp.deinit(); log.debug("windowing protocol={s}", .{@tagName(wp)}); // Create our GTK Application which encapsulates our process. @@ -381,7 +385,7 @@ pub const Application = extern struct { // Force the resource path to a known value so it doesn't depend // on the app id (which changes between debug/release and can be // user-configured) and force it to load in compiled resources. - .resource_base_path = "/com/mitchellh/ghostty", + .resource_base_path = build_info.resource_path, }); // Setup our private state. More setup is done in the init @@ -397,6 +401,7 @@ pub const Application = extern struct { .custom_css_providers = .empty, .global_shortcuts = gobject.ext.newInstance(GlobalShortcuts, .{}), .saved_language = saved_language, + .open_uri = .init(rt_app), }; // Signals @@ -431,7 +436,8 @@ pub const Application = extern struct { const alloc = self.allocator(); const priv: *Private = self.private(); priv.config.unref(); - priv.winproto.deinit(alloc); + priv.winproto.deinit(); + priv.open_uri.deinit(); priv.global_shortcuts.unref(); if (priv.saved_language) |language| alloc.free(language); if (gdk.Display.getDefault()) |display| { @@ -740,6 +746,7 @@ pub const Application = extern struct { .scrollbar => Action.scrollbar(target, value), .set_title => Action.setTitle(target, value), + .set_tab_title => return Action.setTabTitle(target, value), .show_child_exited => return Action.showChildExited(target, value), @@ -804,6 +811,11 @@ pub const Application = extern struct { return &self.private().winproto; } + /// Returns the open URI portal implementation. + pub fn openUri(self: *Self) *OpenURI { + return &self.private().open_uri; + } + /// This will get called when there are no more open surfaces. fn startQuitTimer(self: *Self) void { const priv = self.private(); @@ -1287,6 +1299,11 @@ pub const Application = extern struct { // Set ourselves as the default application. gio.Application.setDefault(self.as(gio.Application)); + // The D-Bus connection is only valid after GApplication startup. + self.openUri().setDbusConnection( + self.as(gio.Application).getDbusConnection(), + ); + // Setup our event loop self.startupXev(); @@ -1871,6 +1888,17 @@ pub const Application = extern struct { gobject.Object.virtual_methods.finalize.implement(class, &finalize); } }; + + pub fn openUrlFallback(self: *Application, kind: apprt.action.OpenUrl.Kind, url: []const u8) void { + // Fallback to the minimal cross-platform way of opening a URL. + // This is always a safe fallback and enables for example Windows + // to open URLs (GTK on Windows via WSL is a thing). + internal_os.open( + self.allocator(), + kind, + url, + ) catch |err| log.warn("unable to open url: {}", .{err}); + } }; /// All apprt action handlers @@ -2315,16 +2343,20 @@ const Action = struct { self: *Application, value: apprt.action.OpenUrl, ) void { - // TODO: use https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.OpenURI.html + if (std.mem.startsWith(u8, value.url, "/")) { + self.openUrlFallback(value.kind, value.url); + return; + } + if (std.mem.startsWith(u8, value.url, "file://")) { + self.openUrlFallback(value.kind, value.url); + return; + } - // Fallback to the minimal cross-platform way of opening a URL. - // This is always a safe fallback and enables for example Windows - // to open URLs (GTK on Windows via WSL is a thing). - internal_os.open( - self.allocator(), - value.kind, - value.url, - ) catch |err| log.warn("unable to open url: {}", .{err}); + self.openUri().start(value) catch |err| { + log.err("unable to open uri err={}", .{err}); + self.openUrlFallback(value.kind, value.url); + return; + }; } pub fn pwd( @@ -2545,6 +2577,30 @@ const Action = struct { } } + pub fn setTabTitle( + 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 tab = ext.getAncestor( + Tab, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a tab, ignoring set_tab_title", .{}); + return false; + }; + tab.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 9c79f2712..6101e82e8 100644 --- a/src/apprt/gtk/class/command_palette.zig +++ b/src/apprt/gtk/class/command_palette.zig @@ -353,7 +353,7 @@ pub const CommandPalette = extern struct { // Regular command - emit trigger signal const action = cmd.getAction() orelse return; - // Signal that an an action has been selected. Signals are synchronous + // Signal that an action has been selected. Signals are synchronous // so we shouldn't need to worry about cloning the action. signals.trigger.impl.emit( self, diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 8d9e1bcf0..8ff8e55e1 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -35,6 +35,7 @@ const TitleDialog = @import("title_dialog.zig").TitleDialog; const Window = @import("window.zig").Window; const InspectorWindow = @import("inspector_window.zig").InspectorWindow; const i18n = @import("../../../os/i18n.zig"); +const media = @import("../media.zig"); const log = std.log.scoped(.gtk_ghostty_surface); @@ -823,10 +824,11 @@ pub const Surface = extern struct { /// should be applied to the surface fn closureShouldUnfocusedSplitBeShown( _: *Self, + search_active: c_int, focused: c_int, is_split: c_int, ) callconv(.c) c_int { - return @intFromBool(focused == 0 and is_split != 0); + return @intFromBool(search_active == 0 and focused == 0 and is_split != 0); } pub fn toggleFullscreen(self: *Self) void { @@ -1012,6 +1014,14 @@ pub const Surface = extern struct { priv.progress_bar_timer = null; } + if (priv.config) |config| { + if (!config.get().@"progress-style") { + log.debug("progress_report action blocked by config", .{}); + priv.progress_bar_overlay.as(gtk.Widget).setVisible(@intFromBool(false)); + return; + } + } + const progress_bar = priv.progress_bar_overlay; switch (value.state) { // Remove the progress bar @@ -2448,34 +2458,8 @@ pub const Surface = extern struct { 1.0, ); - assert(std.fs.path.isAbsolute(path)); - const media_file = gtk.MediaFile.newForFilename(path); - - // If the audio file is marked as required, we'll emit an error if - // there was a problem playing it. Otherwise there will be silence. - if (required) { - _ = gobject.Object.signals.notify.connect( - media_file, - ?*anyopaque, - mediaFileError, - null, - .{ .detail = "error" }, - ); - } - - // Watch for the "ended" signal so that we can clean up after - // ourselves. - _ = gobject.Object.signals.notify.connect( - media_file, - ?*anyopaque, - mediaFileEnded, - null, - .{ .detail = "ended" }, - ); - - const media_stream = media_file.as(gtk.MediaStream); - media_stream.setVolume(volume); - media_stream.play(); + const media_file = media.fromFilename(path) orelse break :audio; + media.playMediaFile(media_file, volume, required); } } @@ -3380,12 +3364,20 @@ pub const Surface = extern struct { config.command = try c.clone(config._arena.?.allocator()); } if (priv.overrides.working_directory) |wd| { - config.@"working-directory" = try config._arena.?.allocator().dupeZ(u8, wd); + const config_alloc = config.arenaAlloc(); + var wd_val: configpkg.WorkingDirectory = .{ .path = try config_alloc.dupe(u8, wd) }; + try wd_val.finalize(config_alloc); + config.@"working-directory" = wd_val; } // Properties that can impact surface init if (priv.font_size_request) |size| config.@"font-size" = size.points; - if (priv.pwd) |pwd| config.@"working-directory" = pwd; + if (priv.pwd) |pwd| { + const config_alloc = config.arenaAlloc(); + var wd_val: configpkg.WorkingDirectory = .{ .path = try config_alloc.dupe(u8, pwd) }; + try wd_val.finalize(config_alloc); + config.@"working-directory" = wd_val; + } // Initialize the surface surface.init( @@ -3464,35 +3456,6 @@ pub const Surface = extern struct { right.setVisible(0); } - fn mediaFileError( - media_file: *gtk.MediaFile, - _: *gobject.ParamSpec, - _: ?*anyopaque, - ) callconv(.c) void { - const path = path: { - const file = media_file.getFile() orelse break :path null; - break :path file.getPath(); - }; - defer if (path) |p| glib.free(p); - - const media_stream = media_file.as(gtk.MediaStream); - const err = media_stream.getError() orelse return; - log.warn("error playing bell from {s}: {s} {d} {s}", .{ - path orelse "<<unknown>>", - glib.quarkToString(err.f_domain), - err.f_code, - err.f_message orelse "", - }); - } - - fn mediaFileEnded( - media_file: *gtk.MediaFile, - _: *gobject.ParamSpec, - _: ?*anyopaque, - ) callconv(.c) void { - media_file.unref(); - } - fn titleDialogSet( _: *TitleDialog, title_ptr: [*:0]const u8, diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index c01cad618..bf2a2fe7c 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -1071,21 +1071,6 @@ pub const Window = extern struct { self.syncAppearance(); } - fn propGdkSurfaceHeight( - _: *gdk.Surface, - _: *gobject.ParamSpec, - self: *Self, - ) callconv(.c) void { - // X11 needs to fix blurring on resize, but winproto implementations - // could do anything. - self.private().winproto.resizeEvent() catch |err| { - log.warn( - "winproto resize event failed error={}", - .{err}, - ); - }; - } - fn propIsActive( _: *gtk.Window, _: *gobject.ParamSpec, @@ -1111,7 +1096,7 @@ pub const Window = extern struct { }; } - fn propGdkSurfaceWidth( + fn propGdkSurfaceDims( _: *gdk.Surface, _: *gobject.ParamSpec, self: *Self, @@ -1250,7 +1235,7 @@ pub const Window = extern struct { fn finalize(self: *Self) callconv(.c) void { const priv = self.private(); priv.tab_bindings.unref(); - priv.winproto.deinit(Application.default().allocator()); + priv.winproto.deinit(); gobject.Object.virtual_methods.finalize.call( Class.parent, @@ -1282,14 +1267,14 @@ pub const Window = extern struct { _ = gobject.Object.signals.notify.connect( gdk_surface, *Self, - propGdkSurfaceWidth, + propGdkSurfaceDims, self, .{ .detail = "width" }, ); _ = gobject.Object.signals.notify.connect( gdk_surface, *Self, - propGdkSurfaceHeight, + propGdkSurfaceDims, self, .{ .detail = "height" }, ); diff --git a/src/apprt/gtk/media.zig b/src/apprt/gtk/media.zig new file mode 100644 index 000000000..1015c933f --- /dev/null +++ b/src/apprt/gtk/media.zig @@ -0,0 +1,102 @@ +const std = @import("std"); +const assert = @import("../../quirks.zig").inlineAssert; + +const log = std.log.scoped(.gtk_media); + +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +pub fn fromFilename(path: [:0]const u8) ?*gtk.MediaFile { + assert(std.fs.path.isAbsolute(path)); + std.fs.accessAbsolute(path, .{ .mode = .read_only }) catch |err| { + log.warn("unable to access {s}: {t}", .{ path, err }); + return null; + }; + return gtk.MediaFile.newForFilename(path); +} + +pub fn fromResource(path: [:0]const u8) ?*gtk.MediaFile { + assert(std.fs.path.isAbsolute(path)); + var gerr: ?*glib.Error = null; + + const found = gio.resourcesGetInfo(path, .{}, null, null, &gerr); + if (gerr) |err| { + defer err.free(); + log.warn( + "failed to find resource {s}: {s} {d} {s}", + .{ + path, + glib.quarkToString(err.f_domain), + err.f_code, + err.f_message orelse "(no message)", + }, + ); + return null; + } + + if (found == 0) { + log.warn("failed to find resource {s}", .{path}); + return null; + } + + return gtk.MediaFile.newForResource(path); +} + +pub fn playMediaFile(media_file: *gtk.MediaFile, volume: f64, required: bool) void { + // If the audio file is marked as required, we'll emit an error if + // there was a problem playing it. Otherwise there will be silence. + if (required) { + _ = gobject.Object.signals.notify.connect( + media_file, + ?*anyopaque, + mediaFileError, + null, + .{ .detail = "error" }, + ); + } + + // Watch for the "ended" signal so that we can clean up after + // ourselves. + _ = gobject.Object.signals.notify.connect( + media_file, + ?*anyopaque, + mediaFileEnded, + null, + .{ .detail = "ended" }, + ); + + const media_stream = media_file.as(gtk.MediaStream); + media_stream.setVolume(volume); + media_stream.play(); +} + +fn mediaFileError( + media_file: *gtk.MediaFile, + _: *gobject.ParamSpec, + _: ?*anyopaque, +) callconv(.c) void { + const path = path: { + const file = media_file.getFile() orelse break :path null; + break :path file.getPath(); + }; + defer if (path) |p| glib.free(p); + + const media_stream = media_file.as(gtk.MediaStream); + const err = media_stream.getError() orelse return; + log.warn("error playing sound from {s}: {s} {d} {s}", .{ + path orelse "<<unknown>>", + glib.quarkToString(err.f_domain), + err.f_code, + err.f_message orelse "", + }); +} + +fn mediaFileEnded( + media_file: *gtk.MediaFile, + _: *gobject.ParamSpec, + _: ?*anyopaque, +) callconv(.c) void { + media_file.unref(); +} diff --git a/src/apprt/gtk/portal.zig b/src/apprt/gtk/portal.zig new file mode 100644 index 000000000..3e51042d6 --- /dev/null +++ b/src/apprt/gtk/portal.zig @@ -0,0 +1,105 @@ +const std = @import("std"); + +const gio = @import("gio"); + +const Allocator = std.mem.Allocator; + +pub const OpenURI = @import("portal/OpenURI.zig"); +pub const token_hex_len = @sizeOf(usize) * 2; +pub const TokenBuffer = [token_hex_len + 1]u8; +const token_format = std.fmt.comptimePrint("{{x:0>{}}}", .{token_hex_len}); + +/// Generate a token suitable for use in requests to the XDG Desktop Portal +pub fn generateToken() usize { + return std.crypto.random.int(usize); +} + +/// Format a request token consistently for use in portal object paths and payloads. +pub fn formatToken(buf: *TokenBuffer, token: usize) [:0]const u8 { + return std.fmt.bufPrintZ(buf, token_format, .{token}) catch unreachable; +} + +/// Get the XDG portal request path for the current Ghostty instance. +/// +/// See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html +/// for the protocol of the Request interface. +pub fn getRequestPath(alloc: Allocator, dbus: *gio.DBusConnection, token: usize) (Allocator.Error || error{NoDBusUniqueName})![:0]const u8 { + // Get the unique name from D-Bus and strip the leading `:` + const unique_name = std.mem.span( + dbus.getUniqueName() orelse { + return error.NoDBusUniqueName; + }, + )[1..]; + + return buildRequestPath(alloc, unique_name, token); +} + +/// Build the XDG portal request path for given unique name and token. +fn buildRequestPath(alloc: Allocator, unique_name: []const u8, token: usize) Allocator.Error![:0]const u8 { + var token_buf: TokenBuffer = undefined; + const token_string = formatToken(&token_buf, token); + + const object_path = try std.mem.joinZ( + alloc, + "/", + &.{ + "/org/freedesktop/portal/desktop/request", + unique_name, + token_string, + }, + ); + + // Sanitize the unique name by replacing every `.` with `_`. In effect, this + // will turn a unique name like `1.192` into `1_192`. + // This sounds arbitrary, but it's part of the Request protocol. + _ = std.mem.replaceScalar(u8, object_path, '.', '_'); + + return object_path; +} + +/// Try and parse the token out of a request path. +pub fn parseRequestPathToken(request_path: []const u8) ?usize { + const index = std.mem.lastIndexOfScalar(u8, request_path, '/') orelse return null; + const token = request_path[index + 1 ..]; + return std.fmt.parseUnsigned(usize, token, 16) catch return null; +} + +test "formatToken pads to fixed width" { + const testing = std.testing; + + var token_buf: TokenBuffer = undefined; + const token = formatToken(&token_buf, 0x42); + + try testing.expectEqual(@as(usize, token_hex_len), token.len); + try testing.expectEqualStrings("0000000000000042", token); +} + +test "buildRequestPath" { + const testing = std.testing; + + const path = try buildRequestPath(testing.allocator, "1.42", 0x75af01a79c6fea34); + try testing.expectEqualStrings( + "/org/freedesktop/portal/desktop/request/1_42/75af01a79c6fea34", + path, + ); + testing.allocator.free(path); +} + +test "buildRequestPath pads token" { + const testing = std.testing; + const path = try buildRequestPath(testing.allocator, "1.42", 0x42); + + try testing.expectEqualStrings( + "/org/freedesktop/portal/desktop/request/1_42/0000000000000042", + path, + ); + testing.allocator.free(path); +} + +test "parseRequestPathToken" { + const testing = std.testing; + + try testing.expectEqual(0x75af01a79c6fea34, parseRequestPathToken("/org/freedesktop/portal/desktop/request/1_42/75af01a79c6fea34").?); + try testing.expectEqual(null, parseRequestPathToken("/org/freedesktop/portal/desktop/request/1_42/75af01a79c6fGa34")); + try testing.expectEqual(null, parseRequestPathToken("75af01a79c6fea34")); +} diff --git a/src/apprt/gtk/portal/OpenURI.zig b/src/apprt/gtk/portal/OpenURI.zig new file mode 100644 index 000000000..97aa013e5 --- /dev/null +++ b/src/apprt/gtk/portal/OpenURI.zig @@ -0,0 +1,565 @@ +//! Use DBus to call the XDG Desktop Portal to open an URI. +//! See: https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.OpenURI.html#org-freedesktop-portal-openuri-openuri +const OpenURI = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; + +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); + +const App = @import("../App.zig"); +const portal = @import("../portal.zig"); +const apprt = @import("../../../apprt.zig"); + +const log = std.log.scoped(.openuri); + +/// The GTK app that we "belong" to. +app: *App, + +/// Connection to the D-Bus session bus that we'll use for all of our messaging. +dbus: ?*gio.DBusConnection = null, + +/// Mutex to protect modification of the entries map or the cleanup timer. +mutex: std.Thread.Mutex = .{}, + +/// Map to store data about any in-flight calls to the portal. +entries: std.AutoArrayHashMapUnmanaged(usize, *Entry) = .empty, + +/// Used to manage a timer to clean up any orphan entries in the map. +cleanup_timer: ?c_uint = null, + +/// Set to false during shutdown so callbacks stop touching internal state. +alive: bool = true, + +const RequestData = struct { + open_uri: *OpenURI, + token: usize, + kind: apprt.action.OpenUrl.Kind, + uri: [:0]const u8, + request_path: [:0]const u8, + + pub fn init( + alloc: Allocator, + open_uri: *OpenURI, + token: usize, + kind: apprt.action.OpenUrl.Kind, + uri: []const u8, + request_path: []const u8, + ) Allocator.Error!*RequestData { + const uri_copy = try alloc.dupeZ(u8, uri); + errdefer alloc.free(uri_copy); + + const request_path_copy = try alloc.dupeZ(u8, request_path); + errdefer alloc.free(request_path_copy); + + const data = try alloc.create(RequestData); + errdefer alloc.destroy(data); + + data.* = .{ + .open_uri = open_uri, + .token = token, + .kind = kind, + .uri = uri_copy, + .request_path = request_path_copy, + }; + + return data; + } + + pub fn deinit(self: *const RequestData, alloc: Allocator) void { + alloc.free(self.uri); + alloc.free(self.request_path); + } +}; + +/// Data about any in-flight calls to the portal. +pub const Entry = struct { + /// When the request started. + start: std.time.Instant, + /// A token used by the portal to identify requests and responses. The + /// actual format of the token does not really matter as long as it can be + /// used as part of a D-Bus object path. `usize` was chosen since it's easy + /// to hash and to generate random tokens. + token: usize, + /// The "kind" of URI. Unused here, but we may need to pass it on to the + /// fallback URL opener if the D-Bus method fails. + kind: apprt.action.OpenUrl.Kind, + /// A copy of the URI that we are opening. We need our own copy since the + /// method calls are asynchronous and the original may have been freed by + /// the time we need it. + uri: [:0]const u8, + /// Used to manage a subscription to a D-Bus signal, which is how the XDG + /// Portal reports results of the method call. + subscription: ?c_uint = null, + + pub fn deinit(self: *const Entry, alloc: Allocator) void { + alloc.free(self.uri); + } +}; + +pub const Errors = error{ + /// Could not get a D-Bus connection + DBusConnectionRequired, + /// The D-Bus connection did not have a unique name. This _should_ be + /// impossible, but is handled for safety's sake. + NoDBusUniqueName, + /// The system was unable to give us the time. + TimerUnavailable, +}; + +pub fn init(app: *App) OpenURI { + return .{ + .app = app, + }; +} + +pub fn setDbusConnection(self: *OpenURI, dbus: ?*gio.DBusConnection) void { + self.dbus = dbus; +} + +pub fn deinit(self: *OpenURI) void { + const alloc = self.app.app.allocator(); + + self.mutex.lock(); + defer self.mutex.unlock(); + + if (!self.alive) return; + self.alive = false; + + self.stopCleanupTimer(); + + for (self.entries.entries.items(.value)) |entry| { + self.unsubscribeFromResponse(entry); + destroyEntry(alloc, entry); + } + + self.entries.deinit(alloc); + self.entries = .empty; + self.dbus = null; +} + +/// Send the D-Bus method call to the XDG Desktop portal. The result of the +/// method call will be reported asynchronously. +pub fn start(self: *OpenURI, value: apprt.action.OpenUrl) (Allocator.Error || Errors)!void { + const alloc = self.app.app.allocator(); + const dbus = self.dbus orelse return error.DBusConnectionRequired; + + const token = portal.generateToken(); + const request_path = try portal.getRequestPath(alloc, dbus, token); + defer alloc.free(request_path); + + const request = try RequestData.init(alloc, self, token, value.kind, value.url, request_path); + errdefer { + request.deinit(alloc); + alloc.destroy(request); + } + + self.mutex.lock(); + defer self.mutex.unlock(); + + // Create an entry that is used to track the results of the D-Bus method + // call. + const entry = entry: { + const entry = try alloc.create(Entry); + errdefer alloc.destroy(entry); + entry.* = .{ + .start = std.time.Instant.now() catch return error.TimerUnavailable, + .token = token, + .kind = value.kind, + .uri = try alloc.dupeZ(u8, value.url), + }; + errdefer entry.deinit(alloc); + try self.entries.putNoClobber(alloc, token, entry); + break :entry entry; + }; + + errdefer { + _ = self.entries.swapRemove(token); + destroyEntry(alloc, entry); + } + + self.startCleanupTimer(); + self.subscribeToResponse(entry, dbus, request_path.ptr); + self.sendRequest(entry, dbus, request); +} + +/// Subscribe to the D-Bus signal that will contain the results of our method +/// call to the portal. This must be called with the mutex locked. +fn subscribeToResponse( + self: *OpenURI, + entry: *Entry, + dbus: *gio.DBusConnection, + request_path: [*:0]const u8, +) void { + assert(!self.mutex.tryLock()); + + if (entry.subscription != null) return; + + entry.subscription = dbus.signalSubscribe( + null, + "org.freedesktop.portal.Request", + "Response", + request_path, + null, + .{}, + responseReceived, + self, + null, + ); +} + +/// Unsubscribe to the D-Bus signal that contains the result of the method call. +/// This will prevent a response from being processed multiple times. This must +/// be called when the mutex is locked. +fn unsubscribeFromResponse(self: *OpenURI, entry: *Entry) void { + assert(!self.mutex.tryLock()); + + // Unsubscribe from the response signal + if (entry.subscription) |subscription| { + const dbus = self.dbus orelse { + entry.subscription = null; + log.warn("unable to unsubscribe open uri response without dbus connection", .{}); + return; + }; + dbus.signalUnsubscribe(subscription); + entry.subscription = null; + } +} + +fn destroyEntry(alloc: Allocator, entry: *Entry) void { + entry.deinit(alloc); + alloc.destroy(entry); +} + +fn failRequest(self: *OpenURI, token: usize) ?*Entry { + self.mutex.lock(); + defer self.mutex.unlock(); + + if (!self.alive) return null; + + const entry = (self.entries.fetchSwapRemove(token) orelse return null).value; + self.unsubscribeFromResponse(entry); + return entry; +} + +fn failRequestAndFallback(self: *OpenURI, request: *const RequestData) void { + const alloc = self.app.app.allocator(); + const entry = self.failRequest(request.token) orelse return; + defer destroyEntry(alloc, entry); + + self.app.app.openUrlFallback(request.kind, request.uri); +} + +/// Send the D-Bus method call to the portal. The mutex must be locked when this +/// is called. +fn sendRequest( + self: *OpenURI, + entry: *Entry, + dbus: *gio.DBusConnection, + request: *RequestData, +) void { + assert(!self.mutex.tryLock()); + + const payload = payload: { + const builder_type = glib.VariantType.new("(ssa{sv})"); + defer builder_type.free(); + + // Initialize our builder to build up our parameters + var builder: glib.VariantBuilder = undefined; + builder.init(builder_type); + + // parent window - empty string means we have no window + builder.add("s", ""); + + // URI to open + builder.add("s", entry.uri.ptr); + + // Options + { + const options = glib.VariantType.new("a{sv}"); + defer options.free(); + + builder.open(options); + defer builder.close(); + + { + const option = glib.VariantType.new("{sv}"); + defer option.free(); + + builder.open(option); + defer builder.close(); + + builder.add("s", "handle_token"); + + var token_buf: portal.TokenBuffer = undefined; + const token = portal.formatToken(&token_buf, entry.token); + + const handle_token = glib.Variant.newString(token.ptr); + builder.add("v", handle_token); + } + { + const option = glib.VariantType.new("{sv}"); + defer option.free(); + + builder.open(option); + defer builder.close(); + + builder.add("s", "writable"); + + const writable = glib.Variant.newBoolean(@intFromBool(false)); + builder.add("v", writable); + } + { + const option = glib.VariantType.new("{sv}"); + defer option.free(); + + builder.open(option); + defer builder.close(); + + builder.add("s", "ask"); + + const ask = glib.Variant.newBoolean(@intFromBool(false)); + builder.add("v", ask); + } + } + + break :payload builder.end(); + }; + + // We're expecting an object path back from the method call. + const reply_type = glib.VariantType.new("(o)"); + defer reply_type.free(); + + dbus.call( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.OpenURI", + "OpenURI", + payload, + reply_type, + .{}, + -1, + null, + requestCallback, + request, + ); +} + +/// Process the result of the original method call. Receiving this result does +/// not indicate that the that the method call succeeded but it may contain an +/// error message that is useful to log for debugging purposes. +fn requestCallback( + source: ?*gobject.Object, + result: *gio.AsyncResult, + ud: ?*anyopaque, +) callconv(.c) void { + const request: *RequestData = @ptrCast(@alignCast(ud orelse return)); + const self = request.open_uri; + const alloc = self.app.app.allocator(); + defer { + request.deinit(alloc); + alloc.destroy(request); + } + + const dbus = gobject.ext.cast(gio.DBusConnection, source orelse { + log.err("Open URI request finished without a D-Bus source object", .{}); + self.failRequestAndFallback(request); + return; + }) orelse { + log.err("Open URI request finished with an unexpected source object", .{}); + self.failRequestAndFallback(request); + return; + }; + + var err_: ?*glib.Error = null; + defer if (err_) |err| err.free(); + + const reply_ = dbus.callFinish(result, &err_); + + if (err_) |err| { + log.err("Open URI request failed={s} ({})", .{ + err.f_message orelse "(unknown)", + err.f_code, + }); + self.failRequestAndFallback(request); + return; + } + + const reply = reply_ orelse { + log.err("D-Bus method call returned a null value!", .{}); + self.failRequestAndFallback(request); + return; + }; + defer reply.unref(); + + const reply_type = glib.VariantType.new("(o)"); + defer reply_type.free(); + + if (reply.isOfType(reply_type) == 0) { + log.warn("Reply from D-Bus method call does not contain an object path!", .{}); + self.failRequestAndFallback(request); + return; + } + + var object_path: [*:0]const u8 = undefined; + reply.get("(&o)", &object_path); + + const token = portal.parseRequestPathToken(std.mem.span(object_path)) orelse { + log.warn("Unable to parse token from the object path {s}", .{object_path}); + self.failRequestAndFallback(request); + return; + }; + + if (token != request.token) { + log.warn("Open URI request returned mismatched token expected={x} actual={x}", .{ + request.token, + token, + }); + self.failRequestAndFallback(request); + return; + } + + self.mutex.lock(); + defer self.mutex.unlock(); + + if (!self.alive) return; + + const entry = self.entries.get(token) orelse return; + if (std.mem.eql(u8, request.request_path, std.mem.span(object_path))) return; + + log.debug("updating open uri request path old={s} new={s}", .{ + request.request_path, + object_path, + }); + self.unsubscribeFromResponse(entry); + self.subscribeToResponse(entry, dbus, object_path); +} + +/// Handle the response signal from the portal. This should contain the actual +/// results of the method call (success or failure). +fn responseReceived( + _: *gio.DBusConnection, + _: ?[*:0]const u8, + object_path: [*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + params: *glib.Variant, + ud: ?*anyopaque, +) callconv(.c) void { + const self: *OpenURI = @ptrCast(@alignCast(ud orelse { + log.err("OpenURI response received with null userdata", .{}); + return; + })); + + const alloc = self.app.app.allocator(); + + const token = portal.parseRequestPathToken(std.mem.span(object_path)) orelse { + log.warn("invalid object path: {s}", .{std.mem.span(object_path)}); + return; + }; + + self.mutex.lock(); + defer self.mutex.unlock(); + + if (!self.alive) return; + + const entry = (self.entries.fetchSwapRemove(token) orelse { + log.warn("no entry for token {x}", .{token}); + return; + }).value; + + defer destroyEntry(alloc, entry); + + self.unsubscribeFromResponse(entry); + + var response: u32 = 0; + var results: ?*glib.Variant = null; + defer if (results) |variant| variant.unref(); + params.get("(u@a{sv})", &response, &results); + + switch (response) { + 0 => { + log.debug("open uri successful", .{}); + }, + 1 => { + log.debug("open uri request was cancelled by the user", .{}); + }, + 2 => { + log.warn("open uri request ended unexpectedly", .{}); + self.app.app.openUrlFallback(entry.kind, entry.uri); + }, + else => { + log.err("unrecognized response code={}", .{response}); + self.app.app.openUrlFallback(entry.kind, entry.uri); + }, + } +} + +/// Wait this number of seconds and then clean up any orphaned entries. +const cleanup_timeout = 30; + +/// If there is an active cleanup timer, cancel it. This must be called with the +/// mutex locked +fn stopCleanupTimer(self: *OpenURI) void { + assert(!self.mutex.tryLock()); + + if (self.cleanup_timer) |timer| { + if (glib.Source.remove(timer) == 0) { + log.warn("unable to remove cleanup timer source={d}", .{timer}); + } + self.cleanup_timer = null; + } +} + +/// Start a timer to clean up any entries that have not received a timely +/// response. If there is already a timer it will be stopped and replaced with a +/// new one. This must be called with the mutex locked. +fn startCleanupTimer(self: *OpenURI) void { + assert(!self.mutex.tryLock()); + + self.stopCleanupTimer(); + self.cleanup_timer = glib.timeoutAddSeconds(cleanup_timeout + 1, cleanup, self); +} + +/// The cleanup timer is used to free up any entries that may have failed +/// to get a response in a timely manner. +fn cleanup(ud: ?*anyopaque) callconv(.c) c_int { + const self: *OpenURI = @ptrCast(@alignCast(ud orelse { + log.warn("cleanup called with null userdata", .{}); + return @intFromBool(glib.SOURCE_REMOVE); + })); + + const alloc = self.app.app.allocator(); + + self.mutex.lock(); + defer self.mutex.unlock(); + + self.cleanup_timer = null; + if (!self.alive) return @intFromBool(glib.SOURCE_REMOVE); + + const now = std.time.Instant.now() catch { + // `now()` should never fail, but if it does, don't crash, just return. + // This might cause a small memory leak in rare circumstances but it + // should get cleaned up the next time a URL is clicked. + return @intFromBool(glib.SOURCE_REMOVE); + }; + + loop: while (true) { + for (self.entries.entries.items(.value)) |entry| { + if (now.since(entry.start) > cleanup_timeout * std.time.ns_per_s) { + log.warn("open uri request timed out token={x}", .{entry.token}); + self.unsubscribeFromResponse(entry); + _ = self.entries.swapRemove(entry.token); + self.app.app.openUrlFallback(entry.kind, entry.uri); + destroyEntry(alloc, entry); + continue :loop; + } + } + break :loop; + } + + return @intFromBool(glib.SOURCE_REMOVE); +} diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index d36e0b708..037b54be6 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -203,7 +203,7 @@ 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(template.focused, template.is-split) as <bool>; + reveal-child: bind $should_unfocused_split_be_shown(search_overlay.active, template.focused, template.is-split) as <bool>; 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,7 +221,7 @@ Overlay terminal_page { DropTarget drop_target { drop => $drop(); - actions: copy; + actions: copy | move; } } diff --git a/src/apprt/gtk/winproto.zig b/src/apprt/gtk/winproto.zig index 3c1da2b21..d409d6c26 100644 --- a/src/apprt/gtk/winproto.zig +++ b/src/apprt/gtk/winproto.zig @@ -46,9 +46,9 @@ pub const App = union(Protocol) { return .{ .none = .{} }; } - pub fn deinit(self: *App, alloc: Allocator) void { + pub fn deinit(self: *App) void { switch (self.*) { - inline else => |*v| v.deinit(alloc), + inline else => |*v| v.deinit(), } } @@ -117,9 +117,9 @@ pub const Window = union(Protocol) { }; } - pub fn deinit(self: *Window, alloc: Allocator) void { + pub fn deinit(self: *Window) void { switch (self.*) { - inline else => |*v| v.deinit(alloc), + inline else => |*v| v.deinit(), } } diff --git a/src/apprt/gtk/winproto/BlurRegion.zig b/src/apprt/gtk/winproto/BlurRegion.zig new file mode 100644 index 000000000..f3041dae0 --- /dev/null +++ b/src/apprt/gtk/winproto/BlurRegion.zig @@ -0,0 +1,187 @@ +const BlurRegion = @This(); +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const gobject = @import("gobject"); +const gdk = @import("gdk"); +const gtk = @import("gtk"); + +const Window = @import("../winproto.zig").Window; +const ApprtWindow = @import("../class/window.zig").Window; + +slices: std.ArrayList(Slice), + +/// A rectangular slice of the blur region. +// Marked `extern` since we want to be able to use this in X11 directly. +pub const Slice = extern struct { + x: Pos, + y: Pos, + width: Pos, + height: Pos, +}; + +// X11 compatibility. Ideally this should just be an `i32` like Wayland, +// but XLib sucks +const Pos = c_long; + +pub const empty: BlurRegion = .{ + .slices = .empty, +}; + +pub fn deinit(self: *BlurRegion, alloc: Allocator) void { + self.slices.deinit(alloc); + self.slices = .empty; +} + +// Calculate the blur regions for a window. +// +// Since we have rounded corners by default, we need to carve out the +// pixels on each corner to avoid the "korners bug". +// (cf. https://github.com/cutefishos/fishui/blob/41d4ba194063a3c7fff4675619b57e6ac0504f06/src/platforms/linux/blurhelper/windowblur.cpp#L134) +pub fn calcForWindow( + alloc: Allocator, + window: *ApprtWindow, + csd: bool, + to_device_coordinates: bool, +) Allocator.Error!BlurRegion { + const native = window.as(gtk.Native); + const surface = native.getSurface() orelse return .empty; + + var slices: std.ArrayList(Slice) = .empty; + errdefer slices.deinit(alloc); + + // Calculate the primary blur region + // (the one that covers most of the screen). + // It's easier to do this inside a vector since we have to scale + // everything by the scale factor anyways. + + // NOTE(pluiedev): CSDs are a f--king mistake. + // Please, GNOME, stop this nonsense of making a window ~30% bigger + // internally than how they really are just for your shadows and + // rounded corners and all that fluff. Please. I beg of you. + const x: Pos, const y: Pos = off: { + var x: f64 = 0; + var y: f64 = 0; + native.getSurfaceTransform(&x, &y); + // Slightly inset the corners if we're using CSDs + if (csd) { + x += 1; + y += 1; + } + break :off .{ @intFromFloat(x), @intFromFloat(y) }; + }; + + var width = @as(Pos, surface.getWidth()); + var height = @as(Pos, surface.getHeight()); + + // Trim off the offsets. Be careful not to get negative. + width -= x * 2; + height -= y * 2; + if (width <= 0 or height <= 0) return .empty; + + // Empirically determined. + const are_corners_rounded = rounded: { + // This cast should always succeed as all of our windows + // should be toplevel. If this fails, something very strange + // is going on. + const toplevel = gobject.ext.cast( + gdk.Toplevel, + surface, + ) orelse break :rounded false; + + const state = toplevel.getState(); + if (state.fullscreen or state.maximized or state.tiled) + break :rounded false; + + break :rounded csd; + }; + + const new_slices = try approxRoundedRect( + alloc, + x, + y, + width, + height, + // See https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/css-variables.html#window-radius + if (are_corners_rounded) 15 else 0, + ); + + if (to_device_coordinates) { + // Transform surface coordinates to device coordinates. + const sf = surface.getScaleFactor(); + for (new_slices.items) |*s| { + s.x *= sf; + s.y *= sf; + s.width *= sf; + s.height *= sf; + } + } + + return .{ .slices = new_slices }; +} + +/// Whether two sets of blur regions are equal. +pub fn eql(self: BlurRegion, other: BlurRegion) bool { + if (self.slices.items.len != other.slices.items.len) return false; + for (self.slices.items, other.slices.items) |this, that| { + if (!std.meta.eql(this, that)) return false; + } + return true; +} + +/// Approximate a rounded rectangle with many smaller rectangles. +fn approxRoundedRect( + alloc: Allocator, + x: Pos, + y: Pos, + width: Pos, + height: Pos, + radius: Pos, +) Allocator.Error!std.ArrayList(Slice) { + const r_f: f32 = @floatFromInt(radius); + + var slices: std.ArrayList(Slice) = .empty; + errdefer slices.deinit(alloc); + + // Add the central rectangle + try slices.append(alloc, .{ + .x = x, + .y = y + radius, + .width = width, + .height = height - 2 * radius, + }); + + // Add the corner rows. This is honestly quite cursed. + var row: Pos = 0; + while (row < radius) : (row += 1) { + // y distance from this row to the center corner circle + const dy = @as(f32, @floatFromInt(radius - row)) - 0.5; + + // x distance - as given by the definition of a circle + const dx = @sqrt(r_f * r_f - dy * dy); + + // How much each row should be offset, rounded to an integer + const row_x: Pos = @intFromFloat(r_f - @round(dx + 0.5)); + + // Remove the offset from both ends + const row_w = width - 2 * row_x; + + // Top slice + try slices.append(alloc, .{ + .x = x + row_x, + .y = y + row, + .width = row_w, + .height = 1, + }); + + // Bottom slice + try slices.append(alloc, .{ + .x = x + row_x, + .y = y + height - 1 - row, + .width = row_w, + .height = 1, + }); + } + + return slices; +} diff --git a/src/apprt/gtk/winproto/noop.zig b/src/apprt/gtk/winproto/noop.zig index ed69736f8..950ee0f37 100644 --- a/src/apprt/gtk/winproto/noop.zig +++ b/src/apprt/gtk/winproto/noop.zig @@ -19,9 +19,8 @@ pub const App = struct { return null; } - pub fn deinit(self: *App, alloc: Allocator) void { + pub fn deinit(self: *App) void { _ = self; - _ = alloc; } pub fn eventMods( @@ -47,9 +46,8 @@ pub const Window = struct { return .{}; } - pub fn deinit(self: Window, alloc: Allocator) void { + pub fn deinit(self: *Window) void { _ = self; - _ = alloc; } pub fn updateConfigEvent( diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index ec02fbee5..12c7fb8a2 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -7,49 +7,26 @@ const gdk_wayland = @import("gdk_wayland"); const gobject = @import("gobject"); const gtk = @import("gtk"); const layer_shell = @import("gtk4-layer-shell"); + const wayland = @import("wayland"); - -const Config = @import("../../../config.zig").Config; -const input = @import("../../../input.zig"); -const ApprtWindow = @import("../class/window.zig").Window; - const wl = wayland.client.wl; +const ext = wayland.client.ext; +const kde = wayland.client.kde; const org = wayland.client.org; const xdg = wayland.client.xdg; +const Config = @import("../../../config.zig").Config; +const Globals = @import("wayland/Globals.zig"); +const input = @import("../../../input.zig"); +const ApprtWindow = @import("../class/window.zig").Window; +const BlurRegion = @import("BlurRegion.zig"); + const log = std.log.scoped(.winproto_wayland); /// Wayland state that contains application-wide Wayland objects (e.g. wl_display). pub const App = struct { display: *wl.Display, - context: *Context, - - const Context = struct { - kde_blur_manager: ?*org.KdeKwinBlurManager = null, - - // FIXME: replace with `zxdg_decoration_v1` once GTK merges - // https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398 - kde_decoration_manager: ?*org.KdeKwinServerDecorationManager = null, - - kde_slide_manager: ?*org.KdeKwinSlideManager = null, - - default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null, - - xdg_activation: ?*xdg.ActivationV1 = null, - - /// Whether the xdg_wm_dialog_v1 protocol is present. - /// - /// If it is present, gtk4-layer-shell < 1.0.4 may crash when the user - /// creates a quick terminal, and we need to ensure this fails - /// gracefully if this situation occurs. - /// - /// FIXME: This is a temporary workaround - we should remove this when - /// all of our supported distros drop support for affected old - /// gtk4-layer-shell versions. - /// - /// See https://github.com/wmww/gtk4-layer-shell/issues/50 - xdg_wm_dialog_present: bool = false, - }; + globals: *Globals, pub fn init( alloc: Allocator, @@ -69,34 +46,17 @@ pub const App = struct { gdk_wayland_display.getWlDisplay() orelse return error.NoWaylandDisplay, )); - // Create our context for our callbacks so we have a stable pointer. - // Note: at the time of writing this comment, we don't really need - // a stable pointer, but it's too scary that we'd need one in the future - // and not have it and corrupt memory or something so let's just do it. - const context = try alloc.create(Context); - errdefer alloc.destroy(context); - context.* = .{}; - - // Get our display registry so we can get all the available interfaces - // and bind to what we need. - const registry = try display.getRegistry(); - registry.setListener(*Context, registryListener, context); - if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; - - // Do another round-trip to get the default decoration mode - if (context.kde_decoration_manager) |deco_manager| { - deco_manager.setListener(*Context, decoManagerListener, context); - if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; - } + const globals: *Globals = try .init(alloc, display); + errdefer globals.deinit(); return .{ .display = display, - .context = context, + .globals = globals, }; } - pub fn deinit(self: *App, alloc: Allocator) void { - alloc.destroy(self.context); + pub fn deinit(self: *App) void { + self.globals.deinit(); } pub fn eventMods( @@ -108,118 +68,23 @@ pub const App = struct { } pub fn supportsQuickTerminal(self: App) bool { + _ = self; if (!layer_shell.isSupported()) { log.warn("your compositor does not support the wlr-layer-shell protocol; disabling quick terminal", .{}); return false; } - if (self.context.xdg_wm_dialog_present and - layer_shell.getLibraryVersion().order(.{ - .major = 1, - .minor = 0, - .patch = 4, - }) == .lt) - { - log.warn("the version of gtk4-layer-shell installed on your system is too old (must be 1.0.4 or newer); disabling quick terminal", .{}); - return false; - } - return true; } - pub fn initQuickTerminal(_: *App, apprt_window: *ApprtWindow) !void { + pub fn initQuickTerminal(self: *App, apprt_window: *ApprtWindow) !void { const window = apprt_window.as(gtk.Window); layer_shell.initForWindow(window); - } - fn getInterfaceType(comptime field: std.builtin.Type.StructField) ?type { - // Globals should be optional pointers - const T = switch (@typeInfo(field.type)) { - .optional => |o| switch (@typeInfo(o.child)) { - .pointer => |v| v.child, - else => return null, - }, - else => return null, - }; - - // Only process Wayland interfaces - if (!@hasDecl(T, "interface")) return null; - return T; - } - - fn registryListener( - registry: *wl.Registry, - event: wl.Registry.Event, - context: *Context, - ) void { - const ctx_fields = @typeInfo(Context).@"struct".fields; - - switch (event) { - .global => |v| { - log.debug("found global {s}", .{v.interface}); - - // We don't actually do anything with this other than checking - // for its existence, so we process this separately. - if (std.mem.orderZ( - u8, - v.interface, - "xdg_wm_dialog_v1", - ) == .eq) { - context.xdg_wm_dialog_present = true; - return; - } - - inline for (ctx_fields) |field| { - const T = getInterfaceType(field) orelse continue; - - if (std.mem.orderZ( - u8, - v.interface, - T.interface.name, - ) == .eq) { - log.debug("matched {}", .{T}); - - @field(context, field.name) = registry.bind( - v.name, - T, - T.generated_version, - ) catch |err| { - log.warn( - "error binding interface {s} error={}", - .{ v.interface, err }, - ); - return; - }; - } - } - }, - - // This should be a rare occurrence, but in case a global - // is suddenly no longer available, we destroy and unset it - // as the protocol mandates. - .global_remove => |v| remove: { - inline for (ctx_fields) |field| { - if (getInterfaceType(field) == null) continue; - const global = @field(context, field.name) orelse break :remove; - if (global.getId() == v.name) { - global.destroy(); - @field(context, field.name) = null; - } - } - }, - } - } - - fn decoManagerListener( - _: *org.KdeKwinServerDecorationManager, - event: org.KdeKwinServerDecorationManager.Event, - context: *Context, - ) void { - switch (event) { - .default_mode => |mode| { - context.default_deco_mode = @enumFromInt(mode.mode); - }, - } + // Set target monitor based on config (null lets compositor decide) + const monitor = resolveQuickTerminalMonitor(self.globals, apprt_window); + defer if (monitor) |v| v.unref(); + layer_shell.setMonitor(window, monitor); } }; @@ -231,10 +96,10 @@ pub const Window = struct { surface: *wl.Surface, /// The context from the app where we can load our Wayland interfaces. - app_context: *App.Context, + globals: *Globals, - /// A token that, when present, indicates that the window is blurred. - blur_token: ?*org.KdeKwinBlur = null, + /// Object that controls background effects like background blur. + bg_effect: ?*ext.BackgroundEffectSurfaceV1 = null, /// Object that controls the decoration mode (client/server/auto) /// of the window. @@ -248,6 +113,8 @@ pub const Window = struct { /// requesting attention from the user. activation_token: ?*xdg.ActivationTokenV1 = null, + blur_region: BlurRegion = .empty, + pub fn init( alloc: Allocator, app: *App, @@ -272,7 +139,7 @@ pub const Window = struct { // Get our decoration object so we can control the // CSD vs SSD status of this surface. const deco: ?*org.KdeKwinServerDecoration = deco: { - const mgr = app.context.kde_decoration_manager orelse + const mgr = app.globals.get(.kde_decoration_manager) orelse break :deco null; const deco: *org.KdeKwinServerDecoration = mgr.create( @@ -285,6 +152,20 @@ pub const Window = struct { break :deco deco; }; + const bg_effect: ?*ext.BackgroundEffectSurfaceV1 = bg: { + const mgr = app.globals.get(.ext_background_effect) orelse + break :bg null; + + const bg_effect: *ext.BackgroundEffectSurfaceV1 = mgr.getBackgroundEffect( + wl_surface, + ) catch |err| { + log.warn("could not create background effect object={}", .{err}); + break :bg null; + }; + + break :bg bg_effect; + }; + if (apprt_window.isQuickTerminal()) { _ = gdk.Surface.signals.enter_monitor.connect( gdk_surface, @@ -298,26 +179,31 @@ pub const Window = struct { return .{ .apprt_window = apprt_window, .surface = wl_surface, - .app_context = app.context, + .globals = app.globals, .decoration = deco, + .bg_effect = bg_effect, }; } - pub fn deinit(self: Window, alloc: Allocator) void { - _ = alloc; - if (self.blur_token) |blur| blur.release(); + pub fn deinit(self: *Window) void { + self.blur_region.deinit(self.globals.alloc); + if (self.bg_effect) |bg| bg.destroy(); if (self.decoration) |deco| deco.release(); if (self.slide) |slide| slide.release(); } - pub fn resizeEvent(_: *Window) !void {} + pub fn resizeEvent(self: *Window) !void { + self.syncBlur() catch |err| { + log.err("failed to sync blur={}", .{err}); + }; + } pub fn syncAppearance(self: *Window) !void { self.syncBlur() catch |err| { log.err("failed to sync blur={}", .{err}); }; self.syncDecoration() catch |err| { - log.err("failed to sync blur={}", .{err}); + log.err("failed to sync decoration={}", .{err}); }; if (self.apprt_window.isQuickTerminal()) { @@ -333,7 +219,7 @@ pub const Window = struct { // If we support SSDs, then we should *not* enable CSDs if we prefer SSDs. // However, if we do not support SSDs (e.g. GNOME) then we should enable // CSDs even if the user prefers SSDs. - .Server => if (self.app_context.kde_decoration_manager) |_| false else true, + .Server => if (self.globals.get(.kde_decoration_manager)) |_| false else true, .None => false, else => unreachable, }; @@ -345,7 +231,7 @@ pub const Window = struct { } pub fn setUrgent(self: *Window, urgent: bool) !void { - const activation = self.app_context.xdg_activation orelse return; + const activation = self.globals.get(.xdg_activation) orelse return; // If there already is a token, destroy and unset it if (self.activation_token) |token| token.destroy(); @@ -361,28 +247,47 @@ pub const Window = struct { /// Update the blur state of the window. fn syncBlur(self: *Window) !void { - const manager = self.app_context.kde_blur_manager orelse return; + const compositor = self.globals.get(.compositor) orelse return; + const bg = self.bg_effect orelse return; + if (!self.globals.state.bg_effect_capabilities.blur) return; + const config = if (self.apprt_window.getConfig()) |v| v.get() else return; const blur = config.@"background-blur"; - if (self.blur_token) |tok| { - // Only release token when transitioning from blurred -> not blurred - if (!blur.enabled()) { - manager.unset(self.surface); - tok.release(); - self.blur_token = null; - } - } else { - // Only acquire token when transitioning from not blurred -> blurred - if (blur.enabled()) { - const tok = try manager.create(self.surface); - tok.commit(); - self.blur_token = tok; - } + if (!blur.enabled()) { + self.blur_region.deinit(self.globals.alloc); + bg.setBlurRegion(null); + return; } + + var region: BlurRegion = try .calcForWindow( + self.globals.alloc, + self.apprt_window, + self.clientSideDecorationEnabled(), + false, + ); + errdefer region.deinit(self.globals.alloc); + + if (region.eql(self.blur_region)) { + // Region didn't change. Don't do anything. + region.deinit(self.globals.alloc); + return; + } + + const wl_region = try compositor.createRegion(); + errdefer if (wl_region) |r| r.destroy(); + for (region.slices.items) |s| wl_region.add( + @intCast(s.x), + @intCast(s.y), + @intCast(s.width), + @intCast(s.height), + ); + + bg.setBlurRegion(wl_region); + self.blur_region = region; } fn syncDecoration(self: *Window) !void { @@ -395,7 +300,7 @@ pub const Window = struct { fn getDecorationMode(self: Window) org.KdeKwinServerDecorationManager.Mode { return switch (self.apprt_window.getWindowDecoration()) { - .auto => self.app_context.default_deco_mode orelse .Client, + .auto => self.globals.state.default_deco_mode orelse .Client, .client => .Client, .server => .Server, .none => .None, @@ -417,6 +322,12 @@ pub const Window = struct { }); layer_shell.setNamespace(window, config.@"gtk-quick-terminal-namespace"); + // Re-resolve the target monitor on every sync so that config reloads + // and primary-output changes take effect without recreating the window. + const target_monitor = resolveQuickTerminalMonitor(self.globals, self.apprt_window); + defer if (target_monitor) |v| v.unref(); + layer_shell.setMonitor(window, target_monitor); + layer_shell.setKeyboardMode( window, switch (config.@"quick-terminal-keyboard-interactivity") { @@ -457,7 +368,7 @@ pub const Window = struct { if (self.slide) |slide| slide.release(); self.slide = if (anchored_edge) |anchored| slide: { - const mgr = self.app_context.kde_slide_manager orelse break :slide null; + const mgr = self.globals.get(.kde_slide_manager) orelse break :slide null; const slide = mgr.create(self.surface) catch |err| { log.warn("could not create slide object={}", .{err}); @@ -486,8 +397,17 @@ pub const Window = struct { const window = apprt_window.as(gtk.Window); const config = if (apprt_window.getConfig()) |v| v.get() else return; + const resolved_monitor = resolveQuickTerminalMonitor( + apprt_window.winproto().wayland.globals, + apprt_window, + ); + defer if (resolved_monitor) |v| v.unref(); + + // Use the configured monitor for sizing if not in mouse mode. + const size_monitor = resolved_monitor orelse monitor; + var monitor_size: gdk.Rectangle = undefined; - monitor.getGeometry(&monitor_size); + size_monitor.getGeometry(&monitor_size); const dims = config.@"quick-terminal-size".calculate( config.@"quick-terminal-position", @@ -505,7 +425,7 @@ pub const Window = struct { event: xdg.ActivationTokenV1.Event, self: *Window, ) void { - const activation = self.app_context.xdg_activation orelse return; + const activation = self.globals.get(.xdg_activation) orelse return; const current_token = self.activation_token orelse return; if (token.getId() != current_token.getId()) { @@ -522,3 +442,45 @@ pub const Window = struct { } } }; + +/// Resolve the quick-terminal-screen config to a specific monitor. +/// Returns null to let the compositor decide (used for .mouse mode). +/// Caller owns the returned ref and must unref it. +fn resolveQuickTerminalMonitor( + globals: *Globals, + apprt_window: *ApprtWindow, +) ?*gdk.Monitor { + const config = if (apprt_window.getConfig()) |v| v.get() else return null; + + switch (config.@"quick-terminal-screen") { + .mouse => return null, + .main, .@"macos-menu-bar" => {}, + } + + const display = apprt_window.as(gtk.Widget).getDisplay(); + const monitors = display.getMonitors(); + + // Try to find the monitor matching the primary output name. + if (globals.state.primary_output_name) |stored_name| { + var i: u32 = 0; + while (monitors.getObject(i)) |item| : (i += 1) { + const monitor = gobject.ext.cast(gdk.Monitor, item) orelse { + item.unref(); + continue; + }; + if (monitor.getConnector()) |connector_z| { + if (std.mem.orderZ(u8, connector_z, stored_name) == .eq) { + return monitor; + } + } + monitor.unref(); + } + } + + // Fall back to the first monitor in the list. + const first = monitors.getObject(0) orelse return null; + return gobject.ext.cast(gdk.Monitor, first) orelse { + first.unref(); + return null; + }; +} diff --git a/src/apprt/gtk/winproto/wayland/Globals.zig b/src/apprt/gtk/winproto/wayland/Globals.zig new file mode 100644 index 000000000..83052cbeb --- /dev/null +++ b/src/apprt/gtk/winproto/wayland/Globals.zig @@ -0,0 +1,252 @@ +const Globals = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const wayland = @import("wayland"); +const wl = wayland.client.wl; +const ext = wayland.client.ext; +const kde = wayland.client.kde; +const org = wayland.client.org; +const xdg = wayland.client.xdg; + +const log = std.log.scoped(.winproto_wayland_globals); + +alloc: Allocator, +state: State, +map: std.EnumMap(Tag, Binding), + +/// Used in the initial roundtrip to determine whether more +/// roundtrips are required to fetch the initial state. +needs_roundtrip: bool = false, + +const Binding = struct { + // All globals can be casted into a wl.Proxy object. + proxy: *wl.Proxy, + name: u32, +}; + +pub const Tag = enum { + compositor, + ext_background_effect, + kde_decoration_manager, + kde_slide_manager, + kde_output_order, + xdg_activation, + + fn Type(comptime self: Tag) type { + return switch (self) { + .compositor => wl.Compositor, + .ext_background_effect => ext.BackgroundEffectManagerV1, + .kde_decoration_manager => org.KdeKwinServerDecorationManager, + .kde_slide_manager => org.KdeKwinSlideManager, + .kde_output_order => kde.OutputOrderV1, + .xdg_activation => xdg.ActivationV1, + }; + } +}; + +pub const State = struct { + /// Connector name of the primary output (e.g., "DP-1") as reported + /// by kde_output_order_v1. The first output in each priority list + /// is the primary. + primary_output_name: ?[:0]const u8 = null, + + /// Tracks the output order event cycle. Set to true after a `done` + /// event so the next `output` event is captured as the new primary. + /// Initialized to true so the first event after binding is captured. + output_order_done: bool = true, + + default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null, + + bg_effect_capabilities: ext.BackgroundEffectManagerV1.Capability = .{}, + + /// Reset cached state derived from kde_output_order_v1. + fn resetOutputOrder(self: *State, alloc: Allocator) void { + if (self.primary_output_name) |name| alloc.free(name); + self.primary_output_name = null; + self.output_order_done = true; + } +}; + +pub fn init(alloc: Allocator, display: *wl.Display) !*Globals { + // We need to allocate here since the listener + // expects a stable memory address. + const self = try alloc.create(Globals); + self.* = .{ + .alloc = alloc, + .state = .{}, + .map = .{}, + }; + + const registry = try display.getRegistry(); + registry.setListener(*Globals, registryListener, self); + if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; + + // Do another roundtrip to process events emitted by globals we bound + // during registry discovery (e.g. default decoration mode, output + // order). Listeners are installed at bind time in registryListener. + if (self.needs_roundtrip) { + if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; + } + return self; +} + +pub fn deinit(self: *Globals) void { + if (self.state.primary_output_name) |name| self.alloc.free(name); + self.alloc.destroy(self); +} + +pub fn get(self: *const Globals, comptime tag: Tag) ?*tag.Type() { + const binding = self.map.get(tag) orelse return null; + return @ptrCast(binding.proxy); +} + +fn onGlobalAttached(self: *Globals, comptime tag: Tag) void { + // Install listeners immediately at bind time. This + // keeps listener setup and object lifetime in one + // place and also supports globals that appear later. + switch (tag) { + .ext_background_effect => { + const v = self.get(tag) orelse return; + v.setListener(*Globals, bgEffectListener, self); + self.needs_roundtrip = true; + }, + .kde_decoration_manager => { + const v = self.get(tag) orelse return; + v.setListener(*Globals, decoManagerListener, self); + self.needs_roundtrip = true; + }, + .kde_output_order => { + const v = self.get(tag) orelse return; + v.setListener(*Globals, outputOrderListener, self); + self.needs_roundtrip = true; + }, + else => {}, + } +} + +fn onGlobalRemoved(self: *Globals, tag: Tag) void { + switch (tag) { + .kde_output_order => self.state.resetOutputOrder(self.alloc), + else => {}, + } +} + +fn registryListener( + registry: *wl.Registry, + event: wl.Registry.Event, + self: *Globals, +) void { + switch (event) { + .global => |v| { + log.debug("found global {s}", .{v.interface}); + inline for (comptime std.meta.tags(Tag)) |tag| { + const T = tag.Type(); + if (std.mem.orderZ(u8, v.interface, T.interface.name) == .eq) { + log.debug("matched {}", .{T}); + + const new_proxy = registry.bind( + v.name, + T, + T.generated_version, + ) catch |err| { + log.warn( + "error binding interface {s} error={}", + .{ v.interface, err }, + ); + return; + }; + + // If this global was already bound, + // then we also need to destroy the old binding. + if (self.map.get(tag)) |old| { + self.onGlobalRemoved(tag); + old.proxy.destroy(); + } + + self.map.put(tag, .{ + .proxy = @ptrCast(new_proxy), + .name = v.name, + }); + self.onGlobalAttached(tag); + } + } + }, + + // This should be a rare occurrence, but in case a global + // is suddenly no longer available, we destroy and unset it + // as the protocol mandates. + .global_remove => |v| { + var it = self.map.iterator(); + while (it.next()) |kv| { + if (kv.value.name != v.name) continue; + self.onGlobalRemoved(kv.key); + kv.value.proxy.destroy(); + self.map.remove(kv.key); + } + }, + } +} + +fn bgEffectListener( + _: *ext.BackgroundEffectManagerV1, + event: ext.BackgroundEffectManagerV1.Event, + self: *Globals, +) void { + switch (event) { + .capabilities => |cap| { + self.state.bg_effect_capabilities = cap.flags; + }, + } +} + +fn decoManagerListener( + _: *org.KdeKwinServerDecorationManager, + event: org.KdeKwinServerDecorationManager.Event, + self: *Globals, +) void { + switch (event) { + .default_mode => |mode| { + self.state.default_deco_mode = @enumFromInt(mode.mode); + }, + } +} + +fn outputOrderListener( + _: *kde.OutputOrderV1, + event: kde.OutputOrderV1.Event, + self: *Globals, +) void { + switch (event) { + .output => |v| { + // Only the first output event after a `done` is the new primary. + if (!self.state.output_order_done) return; + self.state.output_order_done = false; + + const name = std.mem.sliceTo(v.output_name, 0); + if (self.state.primary_output_name) |old| self.alloc.free(old); + + if (name.len == 0) { + self.state.primary_output_name = null; + log.warn("ignoring empty primary output name from kde_output_order_v1", .{}); + } else { + self.state.primary_output_name = self.alloc.dupeZ(u8, name) catch |err| { + self.state.primary_output_name = null; + log.warn("failed to allocate primary output name: {}", .{err}); + return; + }; + log.debug("primary output: {s}", .{name}); + } + }, + .done => { + if (self.state.output_order_done) { + // No output arrived since the previous done. Treat this as + // an empty update and drop any stale cached primary. + self.state.resetOutputOrder(self.alloc); + return; + } + self.state.output_order_done = true; + }, + } +} diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index 1e73c6139..8109959da 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -19,6 +19,7 @@ pub const c = @cImport({ const input = @import("../../../input.zig"); const Config = @import("../../../config.zig").Config; const ApprtWindow = @import("../class/window.zig").Window; +const BlurRegion = @import("BlurRegion.zig"); const log = std.log.scoped(.gtk_x11); @@ -48,7 +49,7 @@ pub const App = struct { else "ghostty"; - // Set the X11 window class property (WM_CLASS) if are are on an X11 + // Set the X11 window class property (WM_CLASS) if we are on an X11 // display. // // Note that we also set the program name here using g_set_prgname. @@ -106,9 +107,8 @@ pub const App = struct { }; } - pub fn deinit(self: *App, alloc: Allocator) void { + pub fn deinit(self: *App) void { _ = self; - _ = alloc; } /// Checks for an immediate pending XKB state update event, and returns the @@ -170,13 +170,13 @@ pub const Window = struct { app: *App, apprt_window: *ApprtWindow, x11_surface: *gdk_x11.X11Surface, + alloc: Allocator, - blur_region: Region = .{}, + blur_region: BlurRegion = .empty, // Cache last applied values to avoid redundant X11 property updates. // Redundant property updates seem to cause some visual glitches // with some window managers: https://github.com/ghostty-org/ghostty/pull/8075 - last_applied_blur_region: ?Region = null, last_applied_decoration_hints: ?MotifWMHints = null, pub fn init( @@ -184,8 +184,6 @@ pub const Window = struct { app: *App, apprt_window: *ApprtWindow, ) !Window { - _ = alloc; - const surface = apprt_window.as(gtk.Native).getSurface() orelse return error.NotX11Surface; @@ -196,49 +194,31 @@ pub const Window = struct { return .{ .app = app, + .alloc = alloc, .apprt_window = apprt_window, .x11_surface = x11_surface, }; } - pub fn deinit(self: Window, alloc: Allocator) void { - _ = self; - _ = alloc; + pub fn deinit(self: *Window) void { + self.blur_region.deinit(self.alloc); } pub fn resizeEvent(self: *Window) !void { // The blur region must update with window resizes - try self.syncBlur(); + self.syncBlur() catch |err| { + log.err("failed to sync blur={}", .{err}); + }; } pub fn syncAppearance(self: *Window) !void { // The user could have toggled between CSDs and SSDs, // therefore we need to recalculate the blur region offset. - self.blur_region = blur: { - // NOTE(pluiedev): CSDs are a f--king mistake. - // Please, GNOME, stop this nonsense of making a window ~30% bigger - // internally than how they really are just for your shadows and - // rounded corners and all that fluff. Please. I beg of you. - var x: f64 = 0; - var y: f64 = 0; - - self.apprt_window.as(gtk.Native).getSurfaceTransform(&x, &y); - - // Transform surface coordinates to device coordinates. - const scale: f64 = @floatFromInt(self.apprt_window.as(gtk.Widget).getScaleFactor()); - x *= scale; - y *= scale; - - break :blur .{ - .x = @intFromFloat(x), - .y = @intFromFloat(y), - }; - }; self.syncBlur() catch |err| { - log.err("failed to synchronize blur={}", .{err}); + log.err("failed to sync blur={}", .{err}); }; self.syncDecorations() catch |err| { - log.err("failed to synchronize decorations={}", .{err}); + log.err("failed to sync decorations={}", .{err}); }; } @@ -250,53 +230,49 @@ pub const Window = struct { } fn syncBlur(self: *Window) !void { - // FIXME: This doesn't currently factor in rounded corners on Adwaita, - // which means that the blur region will grow slightly outside of the - // window borders. Unfortunately, actually calculating the rounded - // region can be quite complex without having access to existing APIs - // (cf. https://github.com/cutefishos/fishui/blob/41d4ba194063a3c7fff4675619b57e6ac0504f06/src/platforms/linux/blurhelper/windowblur.cpp#L134) - // and I think it's not really noticeable enough to justify the effort. - // (Wayland also has this visual artifact anyway...) - - const gtk_widget = self.apprt_window.as(gtk.Widget); const config = if (self.apprt_window.getConfig()) |v| v.get() else return; // When blur is disabled, remove the property if it was previously set const blur = config.@"background-blur"; - if (!blur.enabled()) { - if (self.last_applied_blur_region != null) { - try self.deleteProperty(self.app.atoms.kde_blur); - self.last_applied_blur_region = null; - } + var region: BlurRegion = if (blur.enabled()) + try .calcForWindow( + self.alloc, + self.apprt_window, + self.clientSideDecorationEnabled(), + true, + ) + else + .empty; + errdefer region.deinit(self.alloc); + + // Only update X11 properties when the blur region actually changes + if (region.eql(self.blur_region)) { + region.deinit(self.alloc); return; } - // Transform surface coordinates to device coordinates. - const scale = gtk_widget.getScaleFactor(); - self.blur_region.width = gtk_widget.getWidth() * scale; - self.blur_region.height = gtk_widget.getHeight() * scale; + if (region.slices.items.len > 0) { + log.debug("set blur={}, window xid={}, region={}", .{ + blur, + self.x11_surface.getXid(), + region, + }); - // Only update X11 properties when the blur region actually changes - if (self.last_applied_blur_region) |last| { - if (std.meta.eql(self.blur_region, last)) return; + try self.changeProperty( + BlurRegion.Slice, + self.app.atoms.kde_blur, + c.XA_CARDINAL, + ._32, + .{ .mode = .replace }, + region.slices.items, + ); + } else { + try self.deleteProperty(self.app.atoms.kde_blur); } - log.debug("set blur={}, window xid={}, region={}", .{ - blur, - self.x11_surface.getXid(), - self.blur_region, - }); - - try self.changeProperty( - Region, - self.app.atoms.kde_blur, - c.XA_CARDINAL, - ._32, - .{ .mode = .replace }, - &self.blur_region, - ); - self.last_applied_blur_region = self.blur_region; + self.blur_region.deinit(self.alloc); + self.blur_region = region; } fn syncDecorations(self: *Window) !void { @@ -336,7 +312,7 @@ pub const Window = struct { self.app.atoms.motif_wm_hints, ._32, .{ .mode = .replace }, - &hints, + &.{hints}, ); self.last_applied_decoration_hints = hints; } @@ -411,9 +387,11 @@ pub const Window = struct { options: struct { mode: PropertyChangeMode, }, - value: *T, + values: []const T, ) X11Error!void { - const data: format.bufferType() = @ptrCast(value); + const data: format.bufferType() = @ptrCast(@constCast(values)); + // The number of "words" that each element `T` occupies. + const words_per_elem = @divExact(@sizeOf(T), @sizeOf(format.elemType())); const status = c.XChangeProperty( @ptrCast(@alignCast(self.app.display)), @@ -423,7 +401,7 @@ pub const Window = struct { @intFromEnum(format), @intFromEnum(options.mode), data, - @divExact(@sizeOf(T), @sizeOf(format.elemType())), + @intCast(words_per_elem * values.len), ); // For some godforsaken reason Xlib alternates between @@ -499,13 +477,6 @@ const PropertyFormat = enum(c_int) { } }; -const Region = extern struct { - x: c_long = 0, - y: c_long = 0, - width: c_long = 0, - height: c_long = 0, -}; - // See Xm/MwmUtil.h, packaged with the Motif Window Manager const MotifWMHints = extern struct { flags: packed struct(c_ulong) { diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 5c25281c8..3cb0016fa 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -188,7 +188,7 @@ pub fn newConfig( if (prev) |p| { if (shouldInheritWorkingDirectory(context, config)) { if (try p.pwd(alloc)) |pwd| { - copy.@"working-directory" = pwd; + copy.@"working-directory" = .{ .path = pwd }; } } } diff --git a/src/benchmark/CodepointWidth.zig b/src/benchmark/CodepointWidth.zig index effabb036..30d3f91e7 100644 --- a/src/benchmark/CodepointWidth.zig +++ b/src/benchmark/CodepointWidth.zig @@ -6,6 +6,7 @@ const CodepointWidth = @This(); const std = @import("std"); +const builtin = @import("builtin"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; const Benchmark = @import("Benchmark.zig"); @@ -104,6 +105,11 @@ fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { extern "c" fn wcwidth(c: u32) c_int; fn stepWcwidth(ptr: *anyopaque) Benchmark.Error!void { + if (comptime builtin.os.tag == .windows) { + log.warn("wcwidth is not available on Windows", .{}); + return; + } + const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; diff --git a/src/benchmark/ScreenClone.zig b/src/benchmark/ScreenClone.zig index 380379bc3..108eaa0c6 100644 --- a/src/benchmark/ScreenClone.zig +++ b/src/benchmark/ScreenClone.zig @@ -94,9 +94,9 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { // Force a style on every single row, which var s = self.terminal.vtStream(); defer s.deinit(); - s.nextSlice("\x1b[48;2;20;40;60m") catch unreachable; - for (0..self.terminal.rows - 1) |_| s.nextSlice("hello\r\n") catch unreachable; - s.nextSlice("hello") catch unreachable; + s.nextSlice("\x1b[48;2;20;40;60m"); + for (0..self.terminal.rows - 1) |_| s.nextSlice("hello\r\n"); + s.nextSlice("hello"); // Setup our terminal state const data_f: std.fs.File = (options.dataFile( @@ -120,10 +120,7 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { return error.BenchmarkFailed; }; if (n == 0) break; // EOF reached - stream.nextSlice(buf[0..n]) catch |err| { - log.warn("error processing data file chunk err={}", .{err}); - return error.BenchmarkFailed; - }; + stream.nextSlice(buf[0..n]); } } diff --git a/src/benchmark/TerminalStream.zig b/src/benchmark/TerminalStream.zig index 7cf28217f..1cac656e2 100644 --- a/src/benchmark/TerminalStream.zig +++ b/src/benchmark/TerminalStream.zig @@ -125,10 +125,7 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { return error.BenchmarkFailed; }; if (n == 0) break; // EOF reached - self.stream.nextSlice(buf[0..n]) catch |err| { - log.warn("error processing data file chunk err={}", .{err}); - return error.BenchmarkFailed; - }; + self.stream.nextSlice(buf[0..n]); } } @@ -142,9 +139,11 @@ const Handler = struct { self: *Handler, comptime action: Stream.Action.Tag, value: Stream.Action.Value(action), - ) !void { + ) void { switch (action) { - .print => try self.t.print(value.cp), + .print => self.t.print(value.cp) catch |err| { + log.warn("error processing benchmark print err={}", .{err}); + }, else => {}, } } diff --git a/src/build/Config.zig b/src/build/Config.zig index 3a8a4e0c7..eaaeaac90 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -51,6 +51,7 @@ emit_bench: bool = false, emit_docs: bool = false, emit_exe: bool = false, emit_helpgen: bool = false, +emit_lib_vt: bool = false, emit_macos_app: bool = false, emit_terminfo: bool = false, emit_termcap: bool = false, @@ -60,6 +61,10 @@ emit_xcframework: bool = false, emit_webdata: bool = false, emit_unicode_table_gen: bool = false, +/// True when Ghostty is being built as a dependency of another project +/// rather than as the root project. +is_dep: bool = false, + /// Environmental properties env: std.process.EnvMap, @@ -78,6 +83,19 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { result = genericMacOSTarget(b, result.query.cpu_arch); } + // On Windows, default to the MSVC ABI so that produced COFF + // objects (including compiler_rt) are compatible with the MSVC + // linker. Zig defaults to the GNU ABI which produces objects + // with invalid COMDAT sections that MSVC rejects (LNK1143). + // Only override when no explicit ABI was requested. + if (result.result.os.tag == .windows and + result.query.abi == null) + { + var query = result.query; + query.abi = .msvc; + result = b.resolveTargetQuery(query); + } + // 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) { @@ -87,6 +105,10 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { 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. @@ -109,6 +131,7 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { .optimize = optimize, .target = target, .wasm_target = wasm_target, + .is_dep = is_dep, .env = env, }; @@ -220,9 +243,7 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { const app_version = try std.SemanticVersion.parse(appVersion); // Is ghostty a dependency? If so, skip git detection. - // @src().file won't resolve from b.build_root unless ghostty - // is the project being built. - b.build_root.handle.access(@src().file, .{}) catch break :version .{ + if (is_dep) break :version .{ .major = app_version.major, .minor = app_version.minor, .patch = app_version.patch, @@ -314,11 +335,17 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { //--------------------------------------------------------------- // 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_exe = b.option( bool, "emit-exe", "Build and install main executables with 'build'", - ) orelse true; + ) orelse !config.emit_lib_vt; config.emit_test_exe = b.option( bool, @@ -352,7 +379,8 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { // If we are emitting any other artifacts then we default to false. if (config.emit_bench or config.emit_test_exe or - config.emit_helpgen) break :emit_docs false; + config.emit_helpgen or + config.emit_lib_vt) break :emit_docs false; // We always emit docs in system package mode. if (system_package) break :emit_docs true; @@ -401,7 +429,8 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { bool, "emit-xcframework", "Build and install the xcframework for the macOS library.", - ) orelse builtin.target.os.tag.isDarwin() and + ) orelse !config.emit_lib_vt and + builtin.target.os.tag.isDarwin() and target.result.os.tag == .macos and config.app_runtime == .none and (!config.emit_bench and @@ -412,7 +441,7 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { bool, "emit-macos-app", "Build and install the macOS app bundle.", - ) orelse config.emit_xcframework; + ) orelse !config.emit_lib_vt and config.emit_xcframework; //--------------------------------------------------------------- // System Packages diff --git a/src/build/GhosttyExe.zig b/src/build/GhosttyExe.zig index 3e63b6026..caa564bf0 100644 --- a/src/build/GhosttyExe.zig +++ b/src/build/GhosttyExe.zig @@ -29,8 +29,9 @@ pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty // Set PIE if requested if (cfg.pie) exe.pie = true; - // Add the shared dependencies - _ = try deps.add(exe); + // Add the shared dependencies. When building only lib-vt we skip + // heavy deps so cross-compilation doesn't pull in GTK, etc. + if (!cfg.emit_lib_vt) _ = try deps.add(exe); // Check for possible issues try checkNixShell(exe, cfg); diff --git a/src/build/GhosttyLib.zig b/src/build/GhosttyLib.zig index 2ac383544..9ec9147fa 100644 --- a/src/build/GhosttyLib.zig +++ b/src/build/GhosttyLib.zig @@ -39,6 +39,12 @@ pub fn initStatic( lib.bundle_compiler_rt = true; lib.bundle_ubsan_rt = true; + if (deps.config.target.result.os.tag == .windows) { + // Zig's ubsan emits /exclude-symbols linker directives that + // are incompatible with the MSVC linker (LNK4229). + lib.bundle_ubsan_rt = false; + } + // Add our dependencies. Get the list of all static deps so we can // build a combined archive if necessary. var lib_list = try deps.add(lib); diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index 6d44c62b6..408f1ebc8 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -12,11 +12,22 @@ step: *std.Build.Step, /// The artifact result artifact: *std.Build.Step.InstallArtifact, +/// The kind of library +kind: Kind, + /// The final library file output: std.Build.LazyPath, dsym: ?std.Build.LazyPath, pkg_config: ?std.Build.LazyPath, +/// The kind of library being built. This is similar to LinkMode but +/// also includes wasm which is an executable, not a library. +const Kind = enum { + wasm, + shared, + static, +}; + pub fn initWasm( b: *std.Build, zig: *const GhosttyZig, @@ -39,20 +50,40 @@ pub fn initWasm( return .{ .step = &exe.step, .artifact = b.addInstallArtifact(exe, .{}), + .kind = .wasm, .output = exe.getEmittedBin(), .dsym = null, .pkg_config = null, }; } +pub fn initStatic( + b: *std.Build, + zig: *const GhosttyZig, +) !GhosttyLibVt { + return initLib(b, zig, .static); +} + pub fn initShared( b: *std.Build, zig: *const GhosttyZig, ) !GhosttyLibVt { + return initLib(b, zig, .dynamic); +} + +fn initLib( + b: *std.Build, + zig: *const GhosttyZig, + linkage: std.builtin.LinkMode, +) !GhosttyLibVt { + const kind: Kind = switch (linkage) { + .static => .static, + .dynamic => .shared, + }; const target = zig.vt.resolved_target.?; const lib = b.addLibrary(.{ - .name = "ghostty-vt", - .linkage = .dynamic, + .name = if (kind == .static) "ghostty-vt-static" else "ghostty-vt", + .linkage = linkage, .root_module = zig.vt_c, .version = std.SemanticVersion{ .major = 0, .minor = 1, .patch = 0 }, }); @@ -62,6 +93,25 @@ pub fn initShared( .{ .include_extensions = &.{".h"} }, ); + if (kind == .static) { + // These must be bundled since we're compiling into a static lib. + // Otherwise, you get undefined symbol errors. This could cause + // problems if you're linking multiple static Zig libraries but + // we'll cross that bridge when we get to it. + lib.bundle_compiler_rt = true; + lib.bundle_ubsan_rt = true; + + // Enable PIC so the static library can be linked into PIE + // executables, which is the default on most Linux distributions. + lib.root_module.pic = true; + } + + if (target.result.os.tag == .windows) { + // Zig's ubsan emits /exclude-symbols linker directives that + // are incompatible with the MSVC linker (LNK4229). + lib.bundle_ubsan_rt = false; + } + if (lib.rootModuleTarget().abi.isAndroid()) { // Support 16kb page sizes, required for Android 15+. lib.link_z_max_page_size = 16384; // 16kb @@ -82,11 +132,10 @@ pub fn initShared( if (builtin.os.tag.isDarwin()) try @import("apple_sdk").addPaths(b, lib); } - // Get our debug symbols + // Get our debug symbols (only for shared libs; static libs aren't linked) const dsymutil: ?std.Build.LazyPath = dsymutil: { - if (!target.result.os.tag.isDarwin()) { - break :dsymutil null; - } + if (kind != .shared) break :dsymutil null; + if (!target.result.os.tag.isDarwin()) break :dsymutil null; const dsymutil = RunStep.create(b, "dsymutil"); dsymutil.addArgs(&.{"dsymutil"}); @@ -116,6 +165,7 @@ pub fn initShared( return .{ .step = &lib.step, .artifact = b.addInstallArtifact(lib, .{}), + .kind = kind, .output = lib.getEmittedBin(), .dsym = dsymutil, .pkg_config = pc, diff --git a/src/build/GhosttyXcodebuild.zig b/src/build/GhosttyXcodebuild.zig index 5ca4c5e9a..81af994ca 100644 --- a/src/build/GhosttyXcodebuild.zig +++ b/src/build/GhosttyXcodebuild.zig @@ -104,6 +104,8 @@ pub fn init( "test", "-scheme", "Ghostty", + "-skip-testing", + "GhosttyUITests", }); if (xc_arch) |arch| step.addArgs(&.{ "-arch", arch }); diff --git a/src/build/GhosttyZig.zig b/src/build/GhosttyZig.zig index e63120e74..aabc00d46 100644 --- a/src/build/GhosttyZig.zig +++ b/src/build/GhosttyZig.zig @@ -64,8 +64,12 @@ fn initVt( .optimize = cfg.optimize, // SIMD require libc/libcpp (both) but otherwise we don't care. + // On MSVC, we must not use linkLibCpp because Zig passes + // -nostdinc++ and adds its bundled libc++/libc++abi headers + // which conflict with MSVC's C++ runtime. The MSVC SDK dirs + // added via link_libc contain both C and C++ headers. .link_libc = if (cfg.simd) true else null, - .link_libcpp = if (cfg.simd) true else null, + .link_libcpp = if (cfg.simd and cfg.target.result.abi != .msvc) true else null, }); vt.addOptions("build_options", general_options); vt_options.add(b, vt); diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 9276c9914..e01adb1fa 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -121,7 +121,7 @@ pub fn add( // We don't support cross-compiling to Darwin but due to the way // lazy dependencies work with Zig, we call this function. So we just // bail. The build will fail but the build would've failed anyways. - // And this lets other non-platform-specific targets like `lib-vt` + // And this lets other non-platform-specific targets like `-Demit-lib-vt` // cross-compile properly. if (!builtin.target.os.tag.isDarwin() and self.config.target.result.os.tag.isDarwin()) @@ -135,6 +135,33 @@ pub fn add( // Every exe needs the terminal options self.config.terminalOptions().add(b, step.root_module); + // C imports needed to manage/create PTYs + switch (target.result.os.tag) { + .freebsd, + .linux, + .macos, + => { + const c = b.addTranslateC(.{ + .root_source_file = b.path("src/pty.c"), + .target = target, + .optimize = optimize, + }); + switch (target.result.os.tag) { + .macos => { + const libc = try std.zig.LibCInstallation.findNative(.{ + .allocator = b.allocator, + .target = &target.result, + .verbose = false, + }); + c.addSystemIncludePath(.{ .cwd_relative = libc.sys_include_dir.? }); + }, + else => {}, + } + step.root_module.addImport("pty-c", c.createModule()); + }, + else => {}, + } + // Freetype. We always include this even if our font backend doesn't // use it because Dear Imgui uses Freetype. _ = b.systemIntegrationOption("freetype", .{}); // Shows it in help @@ -372,8 +399,15 @@ pub fn add( step.addIncludePath(b.path("src/apprt/gtk")); } - // libcpp is required for various dependencies - step.linkLibCpp(); + // libcpp is required for various dependencies. On MSVC, we must + // not use linkLibCpp because Zig unconditionally passes -nostdinc++ + // and then adds its bundled libc++/libc++abi include paths, which + // conflict with MSVC's own C++ runtime headers. The MSVC SDK + // include directories (already added via linkLibC above) contain + // both C and C++ headers, so linkLibCpp is not needed. + if (step.rootModuleTarget().abi != .msvc) { + step.linkLibCpp(); + } // We always require the system SDK so that our system headers are available. // This makes things like `os/log.h` available for cross-compiling. @@ -626,9 +660,6 @@ fn addGtkNg( .wayland_protocols = wayland_protocols_dep.path(""), }); - scanner.addCustomProtocol( - plasma_wayland_protocols_dep.path("src/protocols/blur.xml"), - ); // FIXME: replace with `zxdg_decoration_v1` once GTK merges https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398 scanner.addCustomProtocol( plasma_wayland_protocols_dep.path("src/protocols/server-decoration.xml"), @@ -636,13 +667,18 @@ fn addGtkNg( scanner.addCustomProtocol( plasma_wayland_protocols_dep.path("src/protocols/slide.xml"), ); + scanner.addCustomProtocol( + plasma_wayland_protocols_dep.path("src/protocols/kde-output-order-v1.xml"), + ); scanner.addSystemProtocol("staging/xdg-activation/xdg-activation-v1.xml"); + scanner.addSystemProtocol("staging/ext-background-effect/ext-background-effect-v1.xml"); scanner.generate("wl_compositor", 1); - scanner.generate("org_kde_kwin_blur_manager", 1); scanner.generate("org_kde_kwin_server_decoration_manager", 1); scanner.generate("org_kde_kwin_slide_manager", 1); + scanner.generate("kde_output_order_v1", 1); scanner.generate("xdg_activation_v1", 1); + scanner.generate("ext_background_effect_manager_v1", 1); step.root_module.addImport("wayland", b.createModule(.{ .root_source_file = scanner.result, @@ -657,10 +693,10 @@ fn addGtkNg( .optimize = optimize, })) |gtk4_layer_shell| { const layer_shell_module = gtk4_layer_shell.module("gtk4-layer-shell"); - if (gobject_) |gobject| layer_shell_module.addImport( - "gtk", - gobject.module("gtk4"), - ); + if (gobject_) |gobject| { + layer_shell_module.addImport("gtk", gobject.module("gtk4")); + layer_shell_module.addImport("gdk", gobject.module("gdk4")); + } step.root_module.addImport( "gtk4-layer-shell", layer_shell_module, @@ -754,12 +790,35 @@ pub fn addSimd( const HWY_AVX3_DL: c_int = 1 << 7; const HWY_AVX3: c_int = 1 << 8; + var flags: std.ArrayListUnmanaged([]const u8) = .empty; + // Zig 0.13 bug: https://github.com/ziglang/zig/issues/20414 // To workaround this we just disable AVX512 support completely. // The performance difference between AVX2 and AVX512 is not // significant for our use case and AVX512 is very rare on consumer // hardware anyways. const HWY_DISABLED_TARGETS: c_int = HWY_AVX10_2 | HWY_AVX3_SPR | HWY_AVX3_ZEN4 | HWY_AVX3_DL | HWY_AVX3; + if (target.result.cpu.arch == .x86_64) try flags.append( + b.allocator, + b.fmt("-DHWY_DISABLED_TARGETS={}", .{HWY_DISABLED_TARGETS}), + ); + + // MSVC requires explicit std specification otherwise these + // are guarded, at least on Windows 2025. Doing it unconditionally + // doesn't cause any issues on other platforms and ensures we get + // C++17 support on MSVC. + try flags.append( + b.allocator, + "-std=c++17", + ); + + // Disable ubsan for MSVC to avoid undefined references to + // __ubsan_handle_* symbols that require a runtime we don't link + // and bundle. Hopefully we can fix this one day since ubsan is nice! + if (target.result.abi == .msvc) try flags.appendSlice(b.allocator, &.{ + "-fno-sanitize=undefined", + "-fno-sanitize-trap=undefined", + }); m.addCSourceFiles(.{ .files = &.{ @@ -768,9 +827,7 @@ pub fn addSimd( "src/simd/index_of.cpp", "src/simd/vt.cpp", }, - .flags = if (target.result.cpu.arch == .x86_64) &.{ - b.fmt("-DHWY_DISABLED_TARGETS={}", .{HWY_DISABLED_TARGETS}), - } else &.{}, + .flags = flags.items, }); } } diff --git a/src/build/framegen/main.c b/src/build/framegen/main.c index 647768006..2139b15dd 100644 --- a/src/build/framegen/main.c +++ b/src/build/framegen/main.c @@ -8,15 +8,16 @@ #define SEPARATOR '\x01' #define CHUNK_SIZE 16384 +#define MAX_FRAMES 1024 +#define PATH_SEP '/' -static int filter_frames(const struct dirent *entry) { - const char *name = entry->d_name; +static int is_frame_file(const char *name) { size_t len = strlen(name); return len > 4 && strcmp(name + len - 4, ".txt") == 0; } -static int compare_frames(const struct dirent **a, const struct dirent **b) { - return strcmp((*a)->d_name, (*b)->d_name); +static int compare_names(const void *a, const void *b) { + return strcmp(*(const char **)a, *(const char **)b); } static char *read_file(const char *path, size_t *out_size) { @@ -54,25 +55,47 @@ int main(int argc, char **argv) { const char *frames_dir = argv[1]; const char *output_file = argv[2]; - struct dirent **namelist; - int n = scandir(frames_dir, &namelist, filter_frames, compare_frames); - if (n < 0) { + // Use opendir/readdir instead of scandir for Windows compatibility + DIR *dir = opendir(frames_dir); + if (!dir) { fprintf(stderr, "Failed to scan directory %s: %s\n", frames_dir, strerror(errno)); return 1; } + char *names[MAX_FRAMES]; + int n = 0; + struct dirent *entry; + while ((entry = readdir(dir)) != NULL) { + if (!is_frame_file(entry->d_name)) continue; + if (n >= MAX_FRAMES) { + fprintf(stderr, "Too many frame files (max %d)\n", MAX_FRAMES); + closedir(dir); + return 1; + } + names[n] = strdup(entry->d_name); + if (!names[n]) { + fprintf(stderr, "Failed to allocate memory\n"); + closedir(dir); + return 1; + } + n++; + } + closedir(dir); + if (n == 0) { fprintf(stderr, "No frame files found in %s\n", frames_dir); return 1; } + qsort(names, n, sizeof(char *), compare_names); + size_t total_size = 0; char **frame_contents = calloc(n, sizeof(char*)); size_t *frame_sizes = calloc(n, sizeof(size_t)); for (int i = 0; i < n; i++) { char path[4096]; - snprintf(path, sizeof(path), "%s/%s", frames_dir, namelist[i]->d_name); + snprintf(path, sizeof(path), "%s%c%s", frames_dir, PATH_SEP, names[i]); frame_contents[i] = read_file(path, &frame_sizes[i]); if (!frame_contents[i]) { diff --git a/src/cli/CommaSplitter.zig b/src/cli/CommaSplitter.zig index 3168c1ffa..d5093992d 100644 --- a/src/cli/CommaSplitter.zig +++ b/src/cli/CommaSplitter.zig @@ -13,8 +13,18 @@ //! //! Quotes and escapes are not stripped or decoded, that must be handled as a //! separate step! +//! +//! On Windows, backslash is only treated as an escape character inside quoted +//! strings. Outside quotes, backslash is a literal character (path separator). const CommaSplitter = @This(); +const builtin = @import("builtin"); + +/// Whether backslash acts as an escape character outside quoted strings. +/// On Windows, backslash is the path separator so it is always literal +/// outside quotes. +const escape_outside_quotes = builtin.os.tag != .windows; + pub const Error = error{ UnclosedQuote, UnfinishedEscape, @@ -77,8 +87,11 @@ pub fn next(self: *CommaSplitter) Error!?[]const u8 { }, '\\' => { self.index += 1; - last = .normal; - continue :loop .escape; + if (comptime escape_outside_quotes) { + last = .normal; + continue :loop .escape; + } + continue :loop .normal; }, else => { self.index += 1; @@ -273,6 +286,7 @@ test "splitter 8" { } test "splitter 9" { + if (comptime !escape_outside_quotes) return error.SkipZigTest; const std = @import("std"); const testing = std.testing; @@ -281,6 +295,7 @@ test "splitter 9" { } test "splitter 10" { + if (comptime !escape_outside_quotes) return error.SkipZigTest; const std = @import("std"); const testing = std.testing; @@ -289,6 +304,7 @@ test "splitter 10" { } test "splitter 11" { + if (comptime !escape_outside_quotes) return error.SkipZigTest; const std = @import("std"); const testing = std.testing; @@ -297,6 +313,7 @@ test "splitter 11" { } test "splitter 12" { + if (comptime !escape_outside_quotes) return error.SkipZigTest; const std = @import("std"); const testing = std.testing; @@ -305,6 +322,7 @@ test "splitter 12" { } test "splitter 13" { + if (comptime !escape_outside_quotes) return error.SkipZigTest; const std = @import("std"); const testing = std.testing; @@ -313,6 +331,7 @@ test "splitter 13" { } test "splitter 14" { + if (comptime !escape_outside_quotes) return error.SkipZigTest; const std = @import("std"); const testing = std.testing; @@ -330,6 +349,7 @@ test "splitter 15" { } test "splitter 16" { + if (comptime !escape_outside_quotes) return error.SkipZigTest; const std = @import("std"); const testing = std.testing; @@ -338,6 +358,7 @@ test "splitter 16" { } test "splitter 17" { + if (comptime !escape_outside_quotes) return error.SkipZigTest; const std = @import("std"); const testing = std.testing; @@ -346,6 +367,7 @@ test "splitter 17" { } test "splitter 18" { + if (comptime !escape_outside_quotes) return error.SkipZigTest; const std = @import("std"); const testing = std.testing; @@ -415,6 +437,7 @@ test "splitter 24" { } test "splitter 25" { + if (comptime !escape_outside_quotes) return error.SkipZigTest; const std = @import("std"); const testing = std.testing; @@ -422,3 +445,39 @@ test "splitter 25" { try testing.expectEqualStrings("a", (try s.next()).?); try testing.expectError(error.IllegalEscape, s.next()); } + +// Windows-specific tests: backslash is literal outside quotes. + +test "splitter: windows paths" { + if (comptime escape_outside_quotes) return error.SkipZigTest; + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("light:C:\\Users\\foo\\theme,dark:C:\\Users\\bar\\theme"); + try testing.expectEqualStrings("light:C:\\Users\\foo\\theme", (try s.next()).?); + try testing.expectEqualStrings("dark:C:\\Users\\bar\\theme", (try s.next()).?); + try testing.expect(null == try s.next()); +} + +test "splitter: backslash literal outside quotes on windows" { + if (comptime escape_outside_quotes) return error.SkipZigTest; + const std = @import("std"); + const testing = std.testing; + + // Backslash followed by characters that would be escapes on Unix + // are treated as literal on Windows outside quotes. + var s: CommaSplitter = .init("\\n\\r\\t"); + try testing.expectEqualStrings("\\n\\r\\t", (try s.next()).?); + try testing.expect(null == try s.next()); +} + +test "splitter: backslash still escapes inside quotes on windows" { + if (comptime escape_outside_quotes) return error.SkipZigTest; + const std = @import("std"); + const testing = std.testing; + + // Inside quotes, backslash escapes work on all platforms. + var s: CommaSplitter = .init("\"hello\\nworld\""); + try testing.expectEqualStrings("\"hello\\nworld\"", (try s.next()).?); + try testing.expect(null == try s.next()); +} diff --git a/src/cli/explain_config.zig b/src/cli/explain_config.zig new file mode 100644 index 000000000..cf385ca32 --- /dev/null +++ b/src/cli/explain_config.zig @@ -0,0 +1,142 @@ +const std = @import("std"); +const args = @import("args.zig"); +const Allocator = std.mem.Allocator; +const Action = @import("ghostty.zig").Action; +const help_strings = @import("help_strings"); +const Config = @import("../config/Config.zig"); +const ConfigKey = @import("../config/key.zig").Key; +const KeybindAction = @import("../input/Binding.zig").Action; + +pub const Options = struct { + /// The config option to explain. For example: + /// + /// ghostty +explain-config --option=font-size + option: ?[]const u8 = null, + + /// The keybind action to explain. For example: + /// + /// ghostty +explain-config --keybind=copy_to_clipboard + keybind: ?[]const u8 = null, + + pub fn deinit(self: Options) void { + _ = self; + } + + /// Enables `-h` and `--help` to work. + pub fn help(self: Options) !void { + _ = self; + return Action.help_error; + } +}; + +/// The `explain-config` command prints the documentation for a single +/// Ghostty configuration option or keybind action. +/// +/// Examples: +/// +/// ghostty +explain-config font-size +/// ghostty +explain-config copy_to_clipboard +/// ghostty +explain-config --option=font-size +/// ghostty +explain-config --keybind=copy_to_clipboard +/// +/// Flags: +/// +/// * `--option`: The name of the configuration option to explain. +/// * `--keybind`: The name of the keybind action to explain. +pub fn run(alloc: Allocator) !u8 { + var option_name: ?[]const u8 = null; + var keybind_name: ?[]const u8 = null; + var positional: ?[]const u8 = null; + var iter = try args.argsIterator(alloc); + defer iter.deinit(); + + while (iter.next()) |arg| { + if (std.mem.startsWith(u8, arg, "--option=")) { + option_name = arg["--option=".len..]; + } else if (std.mem.startsWith(u8, arg, "--keybind=")) { + keybind_name = arg["--keybind=".len..]; + } else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { + return Action.help_error; + } else if (!std.mem.startsWith(u8, arg, "-")) { + positional = arg; + } + } + + // Resolve what to look up. Explicit flags go directly to their + // respective lookup. A bare positional argument tries config + // options first, then keybind actions as a fallback. + const name = keybind_name orelse option_name orelse positional orelse { + var stderr: std.fs.File = .stderr(); + var buffer: [4096]u8 = undefined; + var stderr_writer = stderr.writer(&buffer); + try stderr_writer.interface.writeAll("Usage: ghostty +explain-config <option>\n"); + try stderr_writer.interface.writeAll(" ghostty +explain-config --option=<option>\n"); + try stderr_writer.interface.writeAll(" ghostty +explain-config --keybind=<action>\n"); + try stderr_writer.end(); + return 1; + }; + + const text = if (keybind_name != null) + explainKeybind(name) + else if (option_name != null) + explainOption(name) + else + explainOption(name) orelse explainKeybind(name); + + var stdout: std.fs.File = .stdout(); + var buffer: [4096]u8 = undefined; + var stdout_writer = stdout.writer(&buffer); + const writer = &stdout_writer.interface; + + if (text) |t| { + try writer.writeAll(t); + try writer.writeAll("\n"); + } else { + try writer.writeAll("Unknown: '"); + try writer.writeAll(name); + try writer.writeAll("'.\n"); + try stdout_writer.end(); + return 1; + } + + try stdout_writer.end(); + return 0; +} + +fn explainOption(name: []const u8) ?[]const u8 { + const key = std.meta.stringToEnum(ConfigKey, name) orelse return null; + return switch (key) { + inline else => |tag| { + const field_name = @tagName(tag); + return if (@hasDecl(help_strings.Config, field_name)) + @field(help_strings.Config, field_name) + else + null; + }, + }; +} + +fn explainKeybind(name: []const u8) ?[]const u8 { + const tag = std.meta.stringToEnum(std.meta.Tag(KeybindAction), name) orelse return null; + return switch (tag) { + inline else => |t| { + const field_name = @tagName(t); + return if (@hasDecl(help_strings.KeybindAction, field_name)) + @field(help_strings.KeybindAction, field_name) + else + null; + }, + }; +} + +test "explain" { + // Config options + try std.testing.expect(explainOption("font-size") != null); + try std.testing.expect(explainOption("copy_to_clipboard") == null); + try std.testing.expect(explainOption("unknown-option") == null); + + // Keybind actions + try std.testing.expect(explainKeybind("copy_to_clipboard") != null); + try std.testing.expect(explainKeybind("font-size") == null); + try std.testing.expect(explainKeybind("unknown_keybind") == null); +} diff --git a/src/cli/ghostty.zig b/src/cli/ghostty.zig index f6ac7d93d..3acb90043 100644 --- a/src/cli/ghostty.zig +++ b/src/cli/ghostty.zig @@ -14,6 +14,7 @@ const list_actions = @import("list_actions.zig"); const ssh_cache = @import("ssh_cache.zig"); const edit_config = @import("edit_config.zig"); const show_config = @import("show_config.zig"); +const explain_config = @import("explain_config.zig"); const validate_config = @import("validate_config.zig"); const crash_report = @import("crash_report.zig"); const show_face = @import("show_face.zig"); @@ -54,6 +55,9 @@ pub const Action = enum { /// Dump the config to stdout @"show-config", + /// Explain a single config option + @"explain-config", + // Validate passed config file @"validate-config", @@ -142,6 +146,7 @@ pub const Action = enum { .@"ssh-cache" => try ssh_cache.run(alloc), .@"edit-config" => try edit_config.run(alloc), .@"show-config" => try show_config.run(alloc), + .@"explain-config" => try explain_config.run(alloc), .@"validate-config" => try validate_config.run(alloc), .@"crash-report" => try crash_report.run(alloc), .@"show-face" => try show_face.run(alloc), @@ -181,6 +186,7 @@ pub const Action = enum { .@"ssh-cache" => ssh_cache.Options, .@"edit-config" => edit_config.Options, .@"show-config" => show_config.Options, + .@"explain-config" => explain_config.Options, .@"validate-config" => validate_config.Options, .@"crash-report" => crash_report.Options, .@"show-face" => show_face.Options, diff --git a/src/cli/list_actions.zig b/src/cli/list_actions.zig index 682eed251..aaef1195e 100644 --- a/src/cli/list_actions.zig +++ b/src/cli/list_actions.zig @@ -46,6 +46,7 @@ pub fn run(alloc: Allocator) !u8 { opts.docs, std.heap.page_allocator, ); + try stdout_writer.interface.flush(); return 0; } diff --git a/src/cli/new_window.zig b/src/cli/new_window.zig index 12acafadf..a89c4ffab 100644 --- a/src/cli/new_window.zig +++ b/src/cli/new_window.zig @@ -198,7 +198,8 @@ fn runArgs( const cwd: std.fs.Dir = std.fs.cwd(); var buf: [std.fs.max_path_bytes]u8 = undefined; const wd = try cwd.realpath(".", &buf); - try opts._arguments.append(alloc, try std.fmt.allocPrintSentinel(alloc, "--working-directory={s}", .{wd}, 0)); + // 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); diff --git a/src/cli/ssh-cache/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig index 6214d0429..6fa74b43d 100644 --- a/src/cli/ssh-cache/DiskCache.zig +++ b/src/cli/ssh-cache/DiskCache.zig @@ -57,6 +57,16 @@ pub fn clear(self: DiskCache) !void { pub const AddResult = enum { added, updated }; +pub const AddError = std.fs.Dir.MakeError || + std.fs.Dir.StatFileError || + std.fs.File.OpenError || + std.fs.File.ChmodError || + std.io.Reader.LimitedAllocError || + FixupPermissionsError || + ReadEntriesError || + WriteCacheFileError || + Error; + /// Add or update a hostname entry in the cache. /// Returns AddResult.added for new entries or AddResult.updated for existing ones. /// The cache file is created if it doesn't exist with secure permissions (0600). @@ -64,7 +74,7 @@ pub fn add( self: DiskCache, alloc: Allocator, hostname: []const u8, -) !AddResult { +) AddError!AddResult { if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; // Create cache directory if needed @@ -128,13 +138,19 @@ pub fn add( return result; } +pub const RemoveError = std.fs.File.OpenError || + FixupPermissionsError || + ReadEntriesError || + WriteCacheFileError || + Error; + /// Remove a hostname entry from the cache. /// No error is returned if the hostname doesn't exist or the cache file is missing. pub fn remove( self: DiskCache, alloc: Allocator, hostname: []const u8, -) !void { +) RemoveError!void { if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; // Open our file @@ -168,13 +184,17 @@ pub fn remove( try self.writeCacheFile(entries, null); } +pub const ContainsError = std.fs.File.OpenError || + ReadEntriesError || + error{HostnameIsInvalid}; + /// Check if a hostname exists in the cache. /// Returns false if the cache file doesn't exist. pub fn contains( self: DiskCache, alloc: Allocator, hostname: []const u8, -) !bool { +) ContainsError!bool { if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; // Open our file @@ -194,7 +214,9 @@ pub fn contains( return entries.contains(hostname); } -fn fixupPermissions(file: std.fs.File) (std.fs.File.StatError || std.fs.File.ChmodError)!void { +pub const FixupPermissionsError = (std.fs.File.StatError || std.fs.File.ChmodError); + +fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void { // Windows does not support chmod if (comptime builtin.os.tag == .windows) return; @@ -206,11 +228,18 @@ fn fixupPermissions(file: std.fs.File) (std.fs.File.StatError || std.fs.File.Chm } } +pub const WriteCacheFileError = std.fs.Dir.OpenError || + std.fs.AtomicFile.InitError || + std.fs.AtomicFile.FlushError || + std.fs.AtomicFile.FinishError || + Entry.FormatError || + error{InvalidCachePath}; + fn writeCacheFile( self: DiskCache, entries: std.StringHashMap(Entry), expire_days: ?u32, -) !void { +) WriteCacheFileError!void { const cache_dir = std.fs.path.dirname(self.path) orelse return error.InvalidCachePath; const cache_basename = std.fs.path.basename(self.path); @@ -270,10 +299,12 @@ pub fn deinitEntries( entries.deinit(); } +pub const ReadEntriesError = std.mem.Allocator.Error || std.io.Reader.LimitedAllocError; + fn readEntries( alloc: Allocator, file: std.fs.File, -) !std.StringHashMap(Entry) { +) ReadEntriesError!std.StringHashMap(Entry) { var reader = file.reader(&.{}); const content = try reader.interface.allocRemaining( alloc, diff --git a/src/cli/ssh-cache/Entry.zig b/src/cli/ssh-cache/Entry.zig index f3403dbd4..b586161f2 100644 --- a/src/cli/ssh-cache/Entry.zig +++ b/src/cli/ssh-cache/Entry.zig @@ -33,7 +33,9 @@ pub fn parse(line: []const u8) ?Entry { }; } -pub fn format(self: Entry, writer: *std.Io.Writer) !void { +pub const FormatError = std.Io.Writer.Error; + +pub fn format(self: Entry, writer: *std.Io.Writer) FormatError!void { try writer.print( "{s}|{d}|{s}\n", .{ self.hostname, self.timestamp, self.terminfo_version }, diff --git a/src/config.zig b/src/config.zig index 0bf61a47f..289b6a811 100644 --- a/src/config.zig +++ b/src/config.zig @@ -2,6 +2,7 @@ const builtin = @import("builtin"); const file_load = @import("config/file_load.zig"); const formatter = @import("config/formatter.zig"); +const formatter_file = @import("config/formatter_file.zig"); pub const Config = @import("config/Config.zig"); pub const conditional = @import("config/conditional.zig"); pub const io = @import("config/io.zig"); @@ -10,7 +11,7 @@ pub const edit = @import("config/edit.zig"); pub const url = @import("config/url.zig"); pub const ConditionalState = conditional.State; -pub const FileFormatter = formatter.FileFormatter; +pub const FileFormatter = formatter_file.FileFormatter; pub const entryFormatter = formatter.entryFormatter; pub const formatEntry = formatter.formatEntry; pub const preferredDefaultFilePath = file_load.preferredDefaultFilePath; @@ -44,6 +45,7 @@ pub const WindowPaddingColor = Config.WindowPaddingColor; pub const BackgroundImagePosition = Config.BackgroundImagePosition; pub const BackgroundImageFit = Config.BackgroundImageFit; pub const LinkPreviews = Config.LinkPreviews; +pub const WorkingDirectory = Config.WorkingDirectory; // Alternate APIs pub const CApi = @import("config/CApi.zig"); diff --git a/src/config/Config.zig b/src/config/Config.zig index ca93c85d6..13f78eea6 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -39,6 +39,7 @@ pub const Path = @import("path.zig").Path; pub const RepeatablePath = @import("path.zig").RepeatablePath; const ClipboardCodepointMap = @import("ClipboardCodepointMap.zig"); const KeyRemapSet = @import("../input/key_mods.zig").RemapSet; +pub const WindowPaddingBalance = @import("../renderer/size.zig").PaddingBalance; const string = @import("string.zig"); // We do this instead of importing all of terminal/main.zig to @@ -48,6 +49,7 @@ const string = @import("string.zig"); const terminal = struct { const CursorStyle = @import("../terminal/cursor.zig").Style; const color = @import("../terminal/color.zig"); + const style = @import("../terminal/style.zig"); const x11_color = @import("../terminal/x11_color.zig"); }; @@ -749,7 +751,7 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// The null character (U+0000) is always treated as a boundary and does not /// need to be included in this configuration. /// -/// Default: ` \t'"│`|:;,()[]{}<>$` +/// Default: `` \t'"│`|:;,()[]{}<>$ `` /// /// To add or remove specific characters, you can set this to a custom value. /// For example, to treat semicolons as part of words: @@ -1398,8 +1400,6 @@ input: RepeatableReadableIO = .{}, /// * `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 /// a scrollbar. -/// -/// This only applies to macOS currently. GTK doesn't yet support scrollbars. scrollbar: Scrollbar = .system, /// Match a regular expression against the terminal text and associate clicking @@ -1526,13 +1526,14 @@ class: ?[:0]const u8 = null, /// `open`, then it defaults to `home`. On Linux with GTK, if Ghostty can detect /// it was launched from a desktop launcher, then it defaults to `home`. /// -/// The value of this must be an absolute value or one of the special values -/// below: +/// The value of this must be an absolute path, a path prefixed with `~/` +/// (the tilde will be expanded to the user's home directory), or +/// one of the special values below: /// /// * `home` - The home directory of the executing user. /// /// * `inherit` - The working directory of the launching process. -@"working-directory": ?[]const u8 = null, +@"working-directory": ?WorkingDirectory = null, /// Key bindings. The format is `trigger=action`. Duplicate triggers will /// overwrite previously set values. The list of actions is available in @@ -1964,7 +1965,16 @@ keybind: Keybinds = .{}, /// apply. The other padding is applied first and may affect how many grid cells /// actually exist, and this is applied last in order to balance the padding /// given a certain viewport size and grid cell size. -@"window-padding-balance": bool = false, +/// +/// Valid values are: +/// +/// * `false` - No balancing is applied. +/// * `true` - Balance the padding, but cap the top padding to avoid +/// excessive space above the first row. Any excess is shifted to the +/// bottom. +/// * `equal` - Balance the padding equally on all sides without any +/// top-padding cap. (Available since: 1.4.0) +@"window-padding-balance": WindowPaddingBalance = .false, /// The color of the padding area of the window. Valid values are: /// @@ -2679,7 +2689,13 @@ keybind: Keybinds = .{}, /// The default value is `main` because this is the recommended screen /// by the operating system. /// -/// Only implemented on macOS. +/// On macOS, `macos-menu-bar` uses the screen containing the menu bar. +/// On Linux/Wayland, `macos-menu-bar` is treated as equivalent to `main`. +/// +/// Note: On Linux, there is no universal concept of a "primary" monitor. +/// Ghostty uses the compositor-reported primary output when available and +/// falls back to the first monitor reported by GDK if no primary output can +/// be resolved. @"quick-terminal-screen": QuickTerminalScreen = .main, /// Duration (in seconds) of the quick terminal enter and exit animation. @@ -3047,7 +3063,7 @@ keybind: Keybinds = .{}, /// /// * `audio` /// -/// Play a custom sound. (GTK only) +/// Play a custom sound. (Available since 1.3.0 on macOS) /// /// * `attention` *(enabled by default)* /// @@ -3089,14 +3105,14 @@ keybind: Keybinds = .{}, /// directory if this is used as a CLI flag. The path may be prefixed with `~/` /// to reference the user's home directory. /// -/// Available since: 1.2.0 +/// Available since: 1.2.0 on GTK, 1.3.0 on macOS. @"bell-audio-path": ?Path = null, /// If `audio` is an enabled bell feature, this is the volume to play the audio /// file at (relative to the system volume). This is a floating point number /// ranging from 0.0 (silence) to 1.0 (as loud as possible). The default is 0.5. /// -/// Available since: 1.2.0 +/// Available since: 1.2.0 on GTK, 1.3.0 on macOS. @"bell-audio-volume": f64 = 0.5, /// Control the in-app notifications that Ghostty shows. @@ -3347,6 +3363,16 @@ keybind: Keybinds = .{}, /// you may want to disable it. @"macos-secure-input-indication": bool = true, +/// If true, Ghostty exposes and handles the built-in AppleScript dictionary +/// on macOS. +/// +/// If false, all AppleScript interactions are disabled. This includes +/// AppleScript commands and AppleScript object lookup for windows, tabs, +/// and terminals. +/// +/// The default is true. +@"macos-applescript": bool = true, + /// Customize the macOS app icon. /// /// This only affects the icon that appears in the dock, application @@ -3636,6 +3662,11 @@ else /// notifications using certain escape sequences such as OSC 9 or OSC 777. @"desktop-notifications": bool = true, +/// If `true` (default), applications running in the terminal can show +/// graphical progress bars using the ConEmu OSC 9;4 escape sequence. +/// If `false`, progress bar sequences are silently ignored. +@"progress-style": bool = true, + /// Modifies the color used for bold text in the terminal. /// /// This can be set to a specific color, using the same format as @@ -4003,10 +4034,28 @@ pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { const app_support_path = try file_load.preferredAppSupportPath(alloc); defer alloc.free(app_support_path); const app_support_loaded: bool = loaded: { - const legacy_app_support_action = self.loadOptionalFile(alloc, legacy_app_support_path); - const app_support_action = self.loadOptionalFile(alloc, app_support_path); + const legacy_app_support_action = self.loadOptionalFile( + alloc, + legacy_app_support_path, + ); + + // The app support path and legacy may be the same, since we + // use the `preferred` call above. If its the same, avoid + // a double-load. + const app_support_action: OptionalFileAction = if (!std.mem.eql( + u8, + legacy_app_support_path, + app_support_path, + )) self.loadOptionalFile( + alloc, + app_support_path, + ) else .not_found; + if (app_support_action != .not_found and legacy_app_support_action != .not_found) { - log.warn("both config files `{s}` and `{s}` exist.", .{ legacy_app_support_path, app_support_path }); + log.warn( + "both config files `{s}` and `{s}` exist.", + .{ legacy_app_support_path, app_support_path }, + ); log.warn("loading them both in that order", .{}); break :loaded true; } @@ -4491,23 +4540,18 @@ pub fn finalize(self: *Config) !void { } // The default for the working directory depends on the system. - const wd = self.@"working-directory" orelse if (probable_cli) - // From the CLI, we want to inherit where we were launched from. - "inherit" + var wd: WorkingDirectory = self.@"working-directory" orelse if (probable_cli) + .inherit else - // Otherwise we typically just want the home directory because - // our pwd is probably a runtime state dir or root or something - // (launchers and desktop environments typically do this). - "home"; + .home; // If we are missing either a command or home directory, we need // to look up defaults which is kind of expensive. We only do this // on desktop. - const wd_home = std.mem.eql(u8, "home", wd); if ((comptime !builtin.target.cpu.arch.isWasm()) and (comptime !builtin.is_test)) { - if (self.command == null or wd_home) command: { + if (self.command == null or wd == .home) command: { // First look up the command using the SHELL env var if needed. // We don't do this in flatpak because SHELL in Flatpak is always // set to /bin/sh. @@ -4529,7 +4573,7 @@ pub fn finalize(self: *Config) !void { self.command = .{ .shell = copy }; // If we don't need the working directory, then we can exit now. - if (!wd_home) break :command; + if (wd != .home) break :command; } else |_| {} } @@ -4540,10 +4584,12 @@ pub fn finalize(self: *Config) !void { self.command = .{ .shell = "cmd.exe" }; } - if (wd_home) { + if (wd == .home) { var buf: [std.fs.max_path_bytes]u8 = undefined; if (try internal_os.home(&buf)) |home| { - self.@"working-directory" = try alloc.dupe(u8, home); + wd = .{ .path = try alloc.dupe(u8, home) }; + } else { + wd = .inherit; } } }, @@ -4558,10 +4604,12 @@ pub fn finalize(self: *Config) !void { } } - if (wd_home) { + if (wd == .home) { if (pw.home) |home| { log.info("default working directory src=passwd value={s}", .{home}); - self.@"working-directory" = home; + wd = .{ .path = home }; + } else { + wd = .inherit; } } @@ -4572,6 +4620,8 @@ pub fn finalize(self: *Config) !void { } } } + try wd.finalize(alloc); + self.@"working-directory" = wd; // Apprt-specific defaults switch (build_config.app_runtime) { @@ -4590,10 +4640,6 @@ pub fn finalize(self: *Config) !void { }, } - // If we have the special value "inherit" then set it to null which - // does the same. In the future we should change to a tagged union. - if (std.mem.eql(u8, wd, "inherit")) self.@"working-directory" = null; - // Default our click interval if (self.@"click-repeat-interval" == 0 and (comptime !builtin.is_test)) @@ -5217,6 +5263,127 @@ pub const LinkPreviews = enum { osc8, }; +/// See working-directory +pub const WorkingDirectory = union(enum) { + const Self = @This(); + + /// Resolve to the current user's home directory during config finalize. + home, + + /// Inherit the working directory from the launching process. + inherit, + + /// Use an explicit working directory path. This may be not be + /// expanded until finalize is called. + path: []const u8, + + pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void { + var input = input_ orelse return error.ValueRequired; + input = std.mem.trim(u8, input, &std.ascii.whitespace); + if (input.len == 0) return error.ValueRequired; + + // Match path.zig behavior for quoted values. + if (input.len >= 2 and input[0] == '"' and input[input.len - 1] == '"') { + input = input[1 .. input.len - 1]; + } + + if (std.mem.eql(u8, input, "home")) { + self.* = .home; + return; + } + + if (std.mem.eql(u8, input, "inherit")) { + self.* = .inherit; + return; + } + + self.* = .{ .path = try alloc.dupe(u8, input) }; + } + + /// Expand tilde paths in .path values. + pub fn finalize(self: *Self, alloc: Allocator) Allocator.Error!void { + const path = switch (self.*) { + .path => |path| path, + else => return, + }; + + if (!std.mem.startsWith(u8, path, "~/")) return; + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const expanded = internal_os.expandHome(path, &buf) catch |err| { + log.warn( + "error expanding home directory for working-directory path={s}: {}", + .{ path, err }, + ); + return; + }; + + if (std.mem.eql(u8, expanded, path)) return; + self.* = .{ .path = try alloc.dupe(u8, expanded) }; + } + + pub fn value(self: Self) ?[]const u8 { + return switch (self) { + .path => |path| path, + .home, .inherit => null, + }; + } + + pub fn clone(self: Self, alloc: Allocator) Allocator.Error!Self { + return switch (self) { + .path => |path| .{ .path = try alloc.dupe(u8, path) }, + else => self, + }; + } + + pub fn formatEntry(self: Self, formatter: formatterpkg.EntryFormatter) !void { + switch (self) { + .home, .inherit => try formatter.formatEntry([]const u8, @tagName(self)), + .path => |path| try formatter.formatEntry([]const u8, path), + } + } + + test "WorkingDirectory parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var wd: Self = .inherit; + + try wd.parseCLI(alloc, "inherit"); + try testing.expectEqual(.inherit, wd); + + try wd.parseCLI(alloc, "home"); + try testing.expectEqual(.home, wd); + + try wd.parseCLI(alloc, "~/projects/ghostty"); + try testing.expectEqualStrings("~/projects/ghostty", wd.path); + + try wd.parseCLI(alloc, "\"/tmp path\""); + try testing.expectEqualStrings("/tmp path", wd.path); + } + + test "WorkingDirectory finalize" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + { + var wd: Self = .{ .path = "~/projects/ghostty" }; + try wd.finalize(alloc); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const expected = internal_os.expandHome( + "~/projects/ghostty", + &buf, + ) catch "~/projects/ghostty"; + try testing.expectEqualStrings(expected, wd.value().?); + } + } +}; + /// Color represents a color using RGB. /// /// This is a packed struct so that the C API to read color values just @@ -5426,6 +5593,14 @@ pub const BoldColor = union(enum) { color: Color, bright, + /// Convert to the terminal-native BoldColor type. + pub fn toTerminal(self: BoldColor) terminal.style.Style.BoldColor { + return switch (self) { + .color => |col| .{ .color = col.toTerminalRGB() }, + .bright => .bright, + }; + } + pub fn parseCLI(input_: ?[]const u8) !BoldColor { const input = input_ orelse return error.ValueRequired; if (std.mem.eql(u8, input, "bright")) return .bright; @@ -6304,10 +6479,11 @@ pub const Keybinds = struct { .{ .copy_to_clipboard = .mixed }, .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .unicode = 'v' }, .mods = mods }, - .{ .paste_from_clipboard = {} }, + .paste_from_clipboard, + .{ .performable = true }, ); } @@ -9634,9 +9810,16 @@ pub const Theme = struct { // we're parsing a light/dark mode theme pair. Note that "=" isn't // actually valid for setting a light/dark mode pair but I anticipate // it'll be a common typo. + // + // On Windows, a colon at index 1 is a drive letter (e.g. C:\...) + // and should not trigger light/dark pair parsing. + const has_colon = if (comptime builtin.os.tag == .windows) + if (std.mem.indexOf(u8, input, ":")) |idx| idx != 1 else false + else + std.mem.indexOf(u8, input, ":") != null; if (std.mem.indexOf(u8, input, ",") != null or std.mem.indexOf(u8, input, "=") != null or - std.mem.indexOf(u8, input, ":") != null) + has_colon) { self.* = try cli.args.parseAutoStruct( Theme, @@ -10280,6 +10463,26 @@ test "clone preserves conditional set" { try testing.expect(clone1._conditional_set.contains(.theme)); } +test "working-directory expands tilde" { + const testing = std.testing; + const alloc = testing.allocator; + + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--working-directory=~/projects/ghostty", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const expected = internal_os.expandHome( + "~/projects/ghostty", + &buf, + ) catch "~/projects/ghostty"; + try testing.expectEqualStrings(expected, cfg.@"working-directory".?.value().?); +} + test "changed" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/config/formatter.zig b/src/config/formatter.zig index dcf99167d..00d946f88 100644 --- a/src/config/formatter.zig +++ b/src/config/formatter.zig @@ -1,8 +1,7 @@ const formatter = @This(); + const std = @import("std"); const Allocator = std.mem.Allocator; -const help_strings = @import("help_strings"); -const Config = @import("Config.zig"); const Key = @import("key.zig").Key; /// Returns a single entry formatter for the given field name and writer. @@ -125,106 +124,6 @@ pub fn formatEntry( @compileError("missing case for type"); } -/// FileFormatter is a formatter implementation that outputs the -/// config in a file-like format. This uses more generous whitespace, -/// can include comments, etc. -pub const FileFormatter = struct { - alloc: Allocator, - config: *const Config, - - /// Include comments for documentation of each key - docs: bool = false, - - /// Only include changed values from the default. - changed: bool = false, - - /// Implements std.fmt so it can be used directly with std.fmt. - pub fn format( - self: FileFormatter, - writer: *std.Io.Writer, - ) std.Io.Writer.Error!void { - @setEvalBranchQuota(10_000); - - // If we're change-tracking then we need the default config to - // compare against. - var default: ?Config = if (self.changed) - Config.default(self.alloc) catch return error.WriteFailed - else - null; - defer if (default) |*v| v.deinit(); - - inline for (@typeInfo(Config).@"struct".fields) |field| { - if (field.name[0] == '_') continue; - - const value = @field(self.config, field.name); - const do_format = if (default) |d| format: { - const key = @field(Key, field.name); - break :format d.changed(self.config, key); - } else true; - - if (do_format) { - const do_docs = self.docs and @hasDecl(help_strings.Config, field.name); - if (do_docs) { - const help = @field(help_strings.Config, field.name); - var lines = std.mem.splitScalar(u8, help, '\n'); - while (lines.next()) |line| { - try writer.print("# {s}\n", .{line}); - } - } - - formatEntry( - field.type, - field.name, - value, - writer, - ) catch return error.WriteFailed; - - if (do_docs) try writer.print("\n", .{}); - } - } - } -}; - -test "format default config" { - const testing = std.testing; - const alloc = testing.allocator; - var cfg = try Config.default(alloc); - defer cfg.deinit(); - - var buf: std.Io.Writer.Allocating = .init(alloc); - defer buf.deinit(); - - // We just make sure this works without errors. We aren't asserting output. - const fmt: FileFormatter = .{ - .alloc = alloc, - .config = &cfg, - }; - try fmt.format(&buf.writer); - - //std.log.warn("{s}", .{buf.written()}); -} - -test "format default config changed" { - const testing = std.testing; - const alloc = testing.allocator; - var cfg = try Config.default(alloc); - defer cfg.deinit(); - cfg.@"font-size" = 42; - - var buf: std.Io.Writer.Allocating = .init(alloc); - defer buf.deinit(); - - // We just make sure this works without errors. We aren't asserting output. - const fmt: FileFormatter = .{ - .alloc = alloc, - .config = &cfg, - .changed = true, - }; - try fmt.format(&buf.writer); - - //std.log.warn("{s}", .{buf.written()}); -} - test "formatEntry bool" { const testing = std.testing; diff --git a/src/config/formatter_file.zig b/src/config/formatter_file.zig new file mode 100644 index 000000000..37beb7b54 --- /dev/null +++ b/src/config/formatter_file.zig @@ -0,0 +1,110 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Config = @import("Config.zig"); +const Key = @import("key.zig").Key; +const help_strings = @import("help_strings"); +const formatter = @import("formatter.zig"); + +// IMPORTANT: This is in a separate file from formatter.zig because it +// puts a build-time dependency on Config.zig which brings in too much +// into libghostty-vt tests which reference some formattable types. + +/// FileFormatter is a formatter implementation that outputs the +/// config in a file-like format. This uses more generous whitespace, +/// can include comments, etc. +pub const FileFormatter = struct { + alloc: Allocator, + config: *const Config, + + /// Include comments for documentation of each key + docs: bool = false, + + /// Only include changed values from the default. + changed: bool = false, + + /// Implements std.fmt so it can be used directly with std.fmt. + pub fn format( + self: FileFormatter, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + @setEvalBranchQuota(10_000); + + // If we're change-tracking then we need the default config to + // compare against. + var default: ?Config = if (self.changed) + Config.default(self.alloc) catch return error.WriteFailed + else + null; + defer if (default) |*v| v.deinit(); + + inline for (@typeInfo(Config).@"struct".fields) |field| { + if (field.name[0] == '_') continue; + + const value = @field(self.config, field.name); + const do_format = if (default) |d| format: { + const key = @field(Key, field.name); + break :format d.changed(self.config, key); + } else true; + + if (do_format) { + const do_docs = self.docs and @hasDecl(help_strings.Config, field.name); + if (do_docs) { + const help = @field(help_strings.Config, field.name); + var lines = std.mem.splitScalar(u8, help, '\n'); + while (lines.next()) |line| { + try writer.print("# {s}\n", .{line}); + } + } + + formatter.formatEntry( + field.type, + field.name, + value, + writer, + ) catch return error.WriteFailed; + + if (do_docs) try writer.print("\n", .{}); + } + } + } +}; + +test "format default config" { + const testing = std.testing; + const alloc = testing.allocator; + var cfg = try Config.default(alloc); + defer cfg.deinit(); + + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + + // We just make sure this works without errors. We aren't asserting output. + const fmt: FileFormatter = .{ + .alloc = alloc, + .config = &cfg, + }; + try fmt.format(&buf.writer); + + //std.log.warn("{s}", .{buf.written()}); +} + +test "format default config changed" { + const testing = std.testing; + const alloc = testing.allocator; + var cfg = try Config.default(alloc); + defer cfg.deinit(); + cfg.@"font-size" = 42; + + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + + // We just make sure this works without errors. We aren't asserting output. + const fmt: FileFormatter = .{ + .alloc = alloc, + .config = &cfg, + .changed = true, + }; + try fmt.format(&buf.writer); + + //std.log.warn("{s}", .{buf.written()}); +} diff --git a/src/config/url.zig b/src/config/url.zig index e7cf8603c..2621898e1 100644 --- a/src/config/url.zig +++ b/src/config/url.zig @@ -17,7 +17,7 @@ const oni = @import("oniguruma"); /// 2. Do not match regexes ending with ), except for ones which contain a ( /// without a subsequent ) /// -/// Rule 2 means that that we handle the following two cases: +/// Rule 2 means that we handle the following two cases: /// /// "https://en.wikipedia.org/wiki/Rust_(video_game)" (include parens) /// "(https://example.com)" (do not include the parens) diff --git a/src/extra/vim.zig b/src/extra/vim.zig index 062ccd2b6..dbd727133 100644 --- a/src/extra/vim.zig +++ b/src/extra/vim.zig @@ -31,7 +31,10 @@ pub const ftplugin = \\" Use syntax keywords for completion \\setlocal omnifunc=syntaxcomplete#Complete \\ - \\let b:undo_ftplugin = 'setl cms< isk< ofu<' + \\" Ask ghostty to explain config keywords + \\setlocal keywordprg=ghostty\ +explain-config + \\ + \\let b:undo_ftplugin = 'setl cms< isk< ofu< kp<' \\ \\if !exists('current_compiler') \\ compiler ghostty diff --git a/src/font/backend.zig b/src/font/backend.zig index 37b3189b6..d24edec79 100644 --- a/src/font/backend.zig +++ b/src/font/backend.zig @@ -40,6 +40,14 @@ pub const Backend = enum { }; } + if (target.os.tag == .windows) { + // Avoid fontconfig on Windows because its libxml2 dependency + // may not unpack due to symlinks. Use plain freetype for now + // which means no font discovery. Full solution would likely use + // DirectWrite which has its own discovery API. + return .freetype; + } + // macOS also supports "coretext_freetype" but there is no scenario // that is the default. It is only used by people who want to // self-compile Ghostty and prefer the freetype aesthetic. diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 827753254..528f72d52 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -679,13 +679,16 @@ pub const Face = struct { else => |f| { // Glyph formats are tags, so we can // output a semi-readable error here. + // Use @bitCast to u32 because MSVC translates C enums + // as signed int, while GCC/Clang uses unsigned int. + const tag: u32 = @bitCast(f); log.err( "Can't render glyph with unsupported glyph format \"{s}\"", .{[4]u8{ - @truncate(f >> 24), - @truncate(f >> 16), - @truncate(f >> 8), - @truncate(f >> 0), + @truncate(tag >> 24), + @truncate(tag >> 16), + @truncate(tag >> 8), + @truncate(tag >> 0), }}, ); return error.UnsupportedGlyphFormat; diff --git a/src/font/nerd_font_codegen.py b/src/font/nerd_font_codegen.py index 8ddc0c113..664555a39 100644 --- a/src/font/nerd_font_codegen.py +++ b/src/font/nerd_font_codegen.py @@ -460,10 +460,9 @@ def generate_zig_switch_arms( # scaled and aligned position would span the line height # plus overlap. Thus, we can use any other stretched # glyph with overlap as stand-in to get the vertical - # bounds, such as as 0xE0B0 (powerline left hard - # divider). We don't worry about the horizontal bounds, - # as they by design should not affect the group's - # bounding box. + # bounds, such as 0xE0B0 (powerline left hard divider). + # We don't worry about the horizontal bounds, as they by + # design should not affect the group's bounding box. if ( patch_set_name == "Progress Indicators" and cp_original == 0xEDFF diff --git a/src/font/shape.zig b/src/font/shape.zig index c96c8df7f..864c0f012 100644 --- a/src/font/shape.zig +++ b/src/font/shape.zig @@ -34,8 +34,8 @@ pub const Shaper = switch (options.backend) { .web_canvas => web_canvas.Shaper, }; -/// A cell is a single single within a terminal that should be rendered -/// for a shaping call. Note all terminal cells may be present; only +/// A cell is a single glyph within a terminal that should be rendered +/// for a shaping call. Not all terminal cells may be present; only /// cells that have a glyph that needs to be rendered. pub const Cell = struct { /// The X position of this shaper cell relative to the offset of the diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 5a8a6ccbf..ff7c6d9d3 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -850,7 +850,7 @@ test "run iterator" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("ABCD"); + s.nextSlice("ABCD"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -874,7 +874,7 @@ test "run iterator" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("ABCD EFG"); + s.nextSlice("ABCD EFG"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -897,7 +897,7 @@ test "run iterator" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("A😃D"); + s.nextSlice("A😃D"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -922,7 +922,7 @@ test "run iterator" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(bad); + s.nextSlice(bad); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -955,8 +955,8 @@ test "run iterator: empty cells with background set" { var s = t.vtStream(); defer s.deinit(); // Set red background - try s.nextSlice("\x1b[48;2;255;0;0m"); - try s.nextSlice("A"); + s.nextSlice("\x1b[48;2;255;0;0m"); + s.nextSlice("A"); // Get our first row { @@ -1014,7 +1014,7 @@ test "shape" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1053,7 +1053,7 @@ test "shape nerd fonts" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1086,7 +1086,7 @@ test "shape inconsolata ligs" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(">="); + s.nextSlice(">="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1115,7 +1115,7 @@ test "shape inconsolata ligs" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("==="); + s.nextSlice("==="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1152,7 +1152,7 @@ test "shape monaspace ligs" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("==="); + s.nextSlice("==="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1190,7 +1190,7 @@ test "shape left-replaced lig in last run" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("!=="); + s.nextSlice("!=="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1228,7 +1228,7 @@ test "shape left-replaced lig in early run" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("!==X"); + s.nextSlice("!==X"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1263,7 +1263,7 @@ test "shape U+3C9 with JB Mono" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("\u{03C9} foo"); + s.nextSlice("\u{03C9} foo"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1300,7 +1300,7 @@ test "shape emoji width" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("👍"); + s.nextSlice("👍"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1390,7 +1390,7 @@ test "shape variation selector VS15" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1429,7 +1429,7 @@ test "shape variation selector VS16" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1463,9 +1463,9 @@ test "shape with empty cells in between" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("A"); - try s.nextSlice("\x1b[5C"); // 5 spaces forward - try s.nextSlice("B"); + s.nextSlice("A"); + s.nextSlice("\x1b[5C"); // 5 spaces forward + s.nextSlice("B"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1510,7 +1510,7 @@ test "shape Combining characters" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1560,7 +1560,7 @@ test "shape Devanagari string" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("अपार्टमेंट"); + s.nextSlice("अपार्टमेंट"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1619,7 +1619,7 @@ test "shape Tai Tham vowels (position differs from advance)" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1680,7 +1680,7 @@ test "shape Tai Tham letters (position.y differs from advance)" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1740,7 +1740,7 @@ test "shape Javanese ligatures" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1803,7 +1803,7 @@ test "shape Chakma vowel sign with ligature (vowel sign renders first)" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1874,7 +1874,7 @@ test "shape Bengali ligatures with out of order vowels" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1929,7 +1929,7 @@ test "shape box glyphs" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1967,7 +1967,7 @@ test "shape selection boundary" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("a1b2c3d4e5"); + s.nextSlice("a1b2c3d4e5"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -2072,7 +2072,7 @@ test "shape cursor boundary" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("a1b2c3d4e5"); + s.nextSlice("a1b2c3d4e5"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -2209,7 +2209,7 @@ test "shape cursor boundary and colored emoji" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("👍🏼"); + s.nextSlice("👍🏼"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -2306,7 +2306,7 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(">="); + s.nextSlice(">="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -2332,9 +2332,9 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(">"); - try s.nextSlice("\x1b[1m"); // Bold - try s.nextSlice("="); + s.nextSlice(">"); + s.nextSlice("\x1b[1m"); // Bold + s.nextSlice("="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -2361,11 +2361,11 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); // RGB 1, 2, 3 - try s.nextSlice("\x1b[38;2;1;2;3m"); - try s.nextSlice(">"); + s.nextSlice("\x1b[38;2;1;2;3m"); + s.nextSlice(">"); // RGB 3, 2, 1 - try s.nextSlice("\x1b[38;2;3;2;1m"); - try s.nextSlice("="); + s.nextSlice("\x1b[38;2;3;2;1m"); + s.nextSlice("="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -2392,11 +2392,11 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); // RGB 1, 2, 3 bg - try s.nextSlice("\x1b[48;2;1;2;3m"); - try s.nextSlice(">"); + s.nextSlice("\x1b[48;2;1;2;3m"); + s.nextSlice(">"); // RGB 3, 2, 1 bg - try s.nextSlice("\x1b[48;2;3;2;1m"); - try s.nextSlice("="); + s.nextSlice("\x1b[48;2;3;2;1m"); + s.nextSlice("="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -2423,9 +2423,9 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); // RGB 1, 2, 3 bg - try s.nextSlice("\x1b[48;2;1;2;3m"); - try s.nextSlice(">"); - try s.nextSlice("="); + s.nextSlice("\x1b[48;2;1;2;3m"); + s.nextSlice(">"); + s.nextSlice("="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -2468,7 +2468,7 @@ test "shape high plane sprite font codepoint" { var s = t.vtStream(); defer s.deinit(); // U+1FB70: Vertical One Eighth Block-2 - try s.nextSlice("\u{1FB70}"); + s.nextSlice("\u{1FB70}"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index b1126dd4e..9c78d9de3 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -104,7 +104,7 @@ pub const Shaper = struct { } /// Returns an iterator that returns one text run at a time for the - /// given terminal row. Note that text runs are are only valid one at a time + /// given terminal row. Note that text runs are only valid one at a time /// for a Shaper struct since they share state. /// /// The selection must be a row-only selection (height = 1). See @@ -448,7 +448,7 @@ test "run iterator" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("ABCD"); + s.nextSlice("ABCD"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -472,7 +472,7 @@ test "run iterator" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("ABCD EFG"); + s.nextSlice("ABCD EFG"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -495,7 +495,7 @@ test "run iterator" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("A😃D"); + s.nextSlice("A😃D"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -533,7 +533,7 @@ test "run iterator: empty cells with background set" { var s = t.vtStream(); defer s.deinit(); // Set red background and write A - try s.nextSlice("\x1b[48;2;255;0;0mA"); + s.nextSlice("\x1b[48;2;255;0;0mA"); // Get our first row { @@ -592,7 +592,7 @@ test "shape" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -626,7 +626,7 @@ test "shape inconsolata ligs" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(">="); + s.nextSlice(">="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -655,7 +655,7 @@ test "shape inconsolata ligs" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("==="); + s.nextSlice("==="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -692,7 +692,7 @@ test "shape monaspace ligs" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("==="); + s.nextSlice("==="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -732,7 +732,7 @@ test "shape arabic forced LTR" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(@embedFile("testdata/arabic.txt")); + s.nextSlice(@embedFile("testdata/arabic.txt")); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -773,7 +773,7 @@ test "shape emoji width" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("👍"); + s.nextSlice("👍"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -870,7 +870,7 @@ test "shape variation selector VS15" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -911,7 +911,7 @@ test "shape variation selector VS16" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -950,9 +950,9 @@ test "shape with empty cells in between" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("A"); - try s.nextSlice("\x1b[5C"); - try s.nextSlice("B"); + s.nextSlice("A"); + s.nextSlice("\x1b[5C"); + s.nextSlice("B"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -997,7 +997,7 @@ test "shape Combining characters" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1048,7 +1048,7 @@ test "shape Devanagari string" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("अपार्टमेंट"); + s.nextSlice("अपार्टमेंट"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1078,67 +1078,70 @@ test "shape Devanagari string" { try testing.expect(try it.next(alloc) == null); } +// This test fails on Linux if you have the "Noto Sans Tai Tham" font installed +// locally. Disabling this test until it can be fixed. test "shape Tai Tham vowels (position differs from advance)" { - // Note that while this test was necessary for CoreText, the old logic was - // working for HarfBuzz. Still we keep it to ensure it has the correct - // behavior. - const testing = std.testing; - const alloc = testing.allocator; + return error.SkipZigTest; + // // Note that while this test was necessary for CoreText, the old logic was + // // working for HarfBuzz. Still we keep it to ensure it has the correct + // // behavior. + // const testing = std.testing; + // const alloc = testing.allocator; - // We need a font that supports Tai Tham for this to work, if we can't find - // Noto Sans Tai Tham, which is a system font on macOS, we just skip the - // test. - var testdata = testShaperWithDiscoveredFont( - alloc, - "Noto Sans Tai Tham", - ) catch return error.SkipZigTest; - defer testdata.deinit(); + // // We need a font that supports Tai Tham for this to work, if we can't find + // // Noto Sans Tai Tham, which is a system font on macOS, we just skip the + // // test. + // var testdata = testShaperWithDiscoveredFont( + // alloc, + // "Noto Sans Tai Tham", + // ) catch return error.SkipZigTest; + // defer testdata.deinit(); - var buf: [32]u8 = undefined; - var buf_idx: usize = 0; - buf_idx += try std.unicode.utf8Encode(0x1a2F, buf[buf_idx..]); // ᨯ - buf_idx += try std.unicode.utf8Encode(0x1a70, buf[buf_idx..]); // ᩰ + // var buf: [32]u8 = undefined; + // var buf_idx: usize = 0; + // buf_idx += try std.unicode.utf8Encode(0x1a2F, buf[buf_idx..]); // ᨯ + // buf_idx += try std.unicode.utf8Encode(0x1a70, buf[buf_idx..]); // ᩰ - // Make a screen with some data - var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); - defer t.deinit(alloc); + // // Make a screen with some data + // var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + // defer t.deinit(alloc); - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); + // // Enable grapheme clustering + // t.modes.set(.grapheme_cluster, true); - var s = t.vtStream(); - defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + // var s = t.vtStream(); + // defer s.deinit(); + // s.nextSlice(buf[0..buf_idx]); - var state: terminal.RenderState = .empty; - defer state.deinit(alloc); - try state.update(alloc, &t); + // var state: terminal.RenderState = .empty; + // defer state.deinit(alloc); + // try state.update(alloc, &t); - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(.{ - .grid = testdata.grid, - .cells = state.row_data.get(0).cells.slice(), - }); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; + // // Get our run iterator + // var shaper = &testdata.shaper; + // var it = shaper.runIterator(.{ + // .grid = testdata.grid, + // .cells = state.row_data.get(0).cells.slice(), + // }); + // var count: usize = 0; + // while (try it.next(alloc)) |run| { + // count += 1; - const cells = try shaper.shape(run); - try testing.expectEqual(@as(usize, 2), cells.len); - try testing.expectEqual(@as(u16, 0), cells[0].x); - try testing.expectEqual(@as(u16, 0), cells[1].x); + // const cells = try shaper.shape(run); + // try testing.expectEqual(@as(usize, 2), cells.len); + // try testing.expectEqual(@as(u16, 0), cells[0].x); + // try testing.expectEqual(@as(u16, 0), cells[1].x); - // The first glyph renders in the next cell. We expect the x_offset - // to equal the cell width. However, with FreeType the cell_width is - // computed from ASCII glyphs, and Noto Sans Tai Tham only has the - // space character in ASCII (with a 3px advance), so the cell_width - // metric doesn't match the actual Tai Tham glyph positioning. - const expected_x_offset: i16 = if (comptime font.options.backend.hasFreetype()) 7 else @intCast(run.grid.metrics.cell_width); - try testing.expectEqual(expected_x_offset, cells[0].x_offset); - try testing.expectEqual(@as(i16, 0), cells[1].x_offset); - } - try testing.expectEqual(@as(usize, 1), count); + // // The first glyph renders in the next cell. We expect the x_offset + // // to equal the cell width. However, with FreeType the cell_width is + // // computed from ASCII glyphs, and Noto Sans Tai Tham only has the + // // space character in ASCII (with a 3px advance), so the cell_width + // // metric doesn't match the actual Tai Tham glyph positioning. + // const expected_x_offset: i16 = if (comptime font.options.backend.hasFreetype()) 7 else @intCast(run.grid.metrics.cell_width); + // try testing.expectEqual(expected_x_offset, cells[0].x_offset); + // try testing.expectEqual(@as(i16, 0), cells[1].x_offset); + // } + // try testing.expectEqual(@as(usize, 1), count); } test "shape Tibetan characters" { @@ -1167,7 +1170,7 @@ test "shape Tibetan characters" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1194,125 +1197,131 @@ test "shape Tibetan characters" { try testing.expectEqual(@as(usize, 1), count); } +// This test fails on Linux if you have the "Noto Sans Tai Tham" font installed +// locally. Disabling this test until it can be fixed. test "shape Tai Tham letters (run_offset.y differs from zero)" { - const testing = std.testing; - const alloc = testing.allocator; + return error.SkipZigTest; + // const testing = std.testing; + // const alloc = testing.allocator; - // We need a font that supports Tai Tham for this to work, if we can't find - // Noto Sans Tai Tham, which is a system font on macOS, we just skip the - // test. - var testdata = testShaperWithDiscoveredFont( - alloc, - "Noto Sans Tai Tham", - ) catch return error.SkipZigTest; - defer testdata.deinit(); + // // We need a font that supports Tai Tham for this to work, if we can't find + // // Noto Sans Tai Tham, which is a system font on macOS, we just skip the + // // test. + // var testdata = testShaperWithDiscoveredFont( + // alloc, + // "Noto Sans Tai Tham", + // ) catch return error.SkipZigTest; + // defer testdata.deinit(); - var buf: [32]u8 = undefined; - var buf_idx: usize = 0; + // var buf: [32]u8 = undefined; + // var buf_idx: usize = 0; - // First grapheme cluster: - buf_idx += try std.unicode.utf8Encode(0x1a49, buf[buf_idx..]); // HA - buf_idx += try std.unicode.utf8Encode(0x1a60, buf[buf_idx..]); // SAKOT - // Second grapheme cluster, combining with the first in a ligature: - buf_idx += try std.unicode.utf8Encode(0x1a3f, buf[buf_idx..]); // YA - buf_idx += try std.unicode.utf8Encode(0x1a69, buf[buf_idx..]); // U + // // First grapheme cluster: + // buf_idx += try std.unicode.utf8Encode(0x1a49, buf[buf_idx..]); // HA + // buf_idx += try std.unicode.utf8Encode(0x1a60, buf[buf_idx..]); // SAKOT + // // Second grapheme cluster, combining with the first in a ligature: + // buf_idx += try std.unicode.utf8Encode(0x1a3f, buf[buf_idx..]); // YA + // buf_idx += try std.unicode.utf8Encode(0x1a69, buf[buf_idx..]); // U - // Make a screen with some data - var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); - defer t.deinit(alloc); + // // Make a screen with some data + // var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + // defer t.deinit(alloc); - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); + // // Enable grapheme clustering + // t.modes.set(.grapheme_cluster, true); - var s = t.vtStream(); - defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + // var s = t.vtStream(); + // defer s.deinit(); + // s.nextSlice(buf[0..buf_idx]); - var state: terminal.RenderState = .empty; - defer state.deinit(alloc); - try state.update(alloc, &t); + // var state: terminal.RenderState = .empty; + // defer state.deinit(alloc); + // try state.update(alloc, &t); - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(.{ - .grid = testdata.grid, - .cells = state.row_data.get(0).cells.slice(), - }); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; + // // Get our run iterator + // var shaper = &testdata.shaper; + // var it = shaper.runIterator(.{ + // .grid = testdata.grid, + // .cells = state.row_data.get(0).cells.slice(), + // }); + // var count: usize = 0; + // while (try it.next(alloc)) |run| { + // count += 1; - const cells = try shaper.shape(run); - try testing.expectEqual(@as(usize, 3), cells.len); - try testing.expectEqual(@as(u16, 0), cells[0].x); - try testing.expectEqual(@as(u16, 0), cells[1].x); - try testing.expectEqual(@as(u16, 0), cells[2].x); // U from second grapheme + // const cells = try shaper.shape(run); + // try testing.expectEqual(@as(usize, 3), cells.len); + // try testing.expectEqual(@as(u16, 0), cells[0].x); + // try testing.expectEqual(@as(u16, 0), cells[1].x); + // try testing.expectEqual(@as(u16, 0), cells[2].x); // U from second grapheme - // The U glyph renders at a y below zero - try testing.expectEqual(@as(i16, -3), cells[2].y_offset); - } - try testing.expectEqual(@as(usize, 1), count); + // // The U glyph renders at a y below zero + // try testing.expectEqual(@as(i16, -3), cells[2].y_offset); + // } + // try testing.expectEqual(@as(usize, 1), count); } +// This test fails on Linux if you have the "Noto Sans Javanese" font installed +// locally. Disabling this test until it can be fixed. test "shape Javanese ligatures" { - const testing = std.testing; - const alloc = testing.allocator; + return error.SkipZigTest; + // const testing = std.testing; + // const alloc = testing.allocator; - // We need a font that supports Javanese for this to work, if we can't find - // Noto Sans Javanese Regular, which is a system font on macOS, we just - // skip the test. - var testdata = testShaperWithDiscoveredFont( - alloc, - "Noto Sans Javanese", - ) catch return error.SkipZigTest; - defer testdata.deinit(); + // // We need a font that supports Javanese for this to work, if we can't find + // // Noto Sans Javanese Regular, which is a system font on macOS, we just + // // skip the test. + // var testdata = testShaperWithDiscoveredFont( + // alloc, + // "Noto Sans Javanese", + // ) catch return error.SkipZigTest; + // defer testdata.deinit(); - var buf: [32]u8 = undefined; - var buf_idx: usize = 0; + // var buf: [32]u8 = undefined; + // var buf_idx: usize = 0; - // First grapheme cluster: - buf_idx += try std.unicode.utf8Encode(0xa9a4, buf[buf_idx..]); // NA - buf_idx += try std.unicode.utf8Encode(0xa9c0, buf[buf_idx..]); // PANGKON - // Second grapheme cluster, combining with the first in a ligature: - buf_idx += try std.unicode.utf8Encode(0xa9b2, buf[buf_idx..]); // HA - buf_idx += try std.unicode.utf8Encode(0xa9b8, buf[buf_idx..]); // Vowel sign SUKU + // // First grapheme cluster: + // buf_idx += try std.unicode.utf8Encode(0xa9a4, buf[buf_idx..]); // NA + // buf_idx += try std.unicode.utf8Encode(0xa9c0, buf[buf_idx..]); // PANGKON + // // Second grapheme cluster, combining with the first in a ligature: + // buf_idx += try std.unicode.utf8Encode(0xa9b2, buf[buf_idx..]); // HA + // buf_idx += try std.unicode.utf8Encode(0xa9b8, buf[buf_idx..]); // Vowel sign SUKU - // Make a screen with some data - var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); - defer t.deinit(alloc); + // // Make a screen with some data + // var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + // defer t.deinit(alloc); - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); + // // Enable grapheme clustering + // t.modes.set(.grapheme_cluster, true); - var s = t.vtStream(); - defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + // var s = t.vtStream(); + // defer s.deinit(); + // s.nextSlice(buf[0..buf_idx]); - var state: terminal.RenderState = .empty; - defer state.deinit(alloc); - try state.update(alloc, &t); + // var state: terminal.RenderState = .empty; + // defer state.deinit(alloc); + // try state.update(alloc, &t); - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(.{ - .grid = testdata.grid, - .cells = state.row_data.get(0).cells.slice(), - }); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; + // // Get our run iterator + // var shaper = &testdata.shaper; + // var it = shaper.runIterator(.{ + // .grid = testdata.grid, + // .cells = state.row_data.get(0).cells.slice(), + // }); + // var count: usize = 0; + // while (try it.next(alloc)) |run| { + // count += 1; - const cells = try shaper.shape(run); - const cell_width = run.grid.metrics.cell_width; - try testing.expectEqual(@as(usize, 3), cells.len); - try testing.expectEqual(@as(u16, 0), cells[0].x); - try testing.expectEqual(@as(u16, 0), cells[1].x); - try testing.expectEqual(@as(u16, 0), cells[2].x); + // const cells = try shaper.shape(run); + // const cell_width = run.grid.metrics.cell_width; + // try testing.expectEqual(@as(usize, 3), cells.len); + // try testing.expectEqual(@as(u16, 0), cells[0].x); + // try testing.expectEqual(@as(u16, 0), cells[1].x); + // try testing.expectEqual(@as(u16, 0), cells[2].x); - // The vowel sign SUKU renders with correct x_offset - try testing.expect(cells[2].x_offset > 3 * cell_width); - } - try testing.expectEqual(@as(usize, 1), count); + // // The vowel sign SUKU renders with correct x_offset + // try testing.expect(cells[2].x_offset > 3 * cell_width); + // } + // try testing.expectEqual(@as(usize, 1), count); } test "shape Chakma vowel sign with ligature (vowel sign renders first)" { @@ -1349,7 +1358,7 @@ test "shape Chakma vowel sign with ligature (vowel sign renders first)" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1424,7 +1433,7 @@ test "shape Bengali ligatures with out of order vowels" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1478,7 +1487,7 @@ test "shape box glyphs" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1517,7 +1526,7 @@ test "shape selection boundary" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("a1b2c3d4e5"); + s.nextSlice("a1b2c3d4e5"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1622,7 +1631,7 @@ test "shape cursor boundary" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("a1b2c3d4e5"); + s.nextSlice("a1b2c3d4e5"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1762,7 +1771,7 @@ test "shape cursor boundary and colored emoji" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("👍🏼"); + s.nextSlice("👍🏼"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1859,7 +1868,7 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(">="); + s.nextSlice(">="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1885,9 +1894,9 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(">"); - try s.nextSlice("\x1b[1m"); - try s.nextSlice("="); + s.nextSlice(">"); + s.nextSlice("\x1b[1m"); + s.nextSlice("="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1914,11 +1923,11 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); // RGB 1, 2, 3 - try s.nextSlice("\x1b[38;2;1;2;3m"); - try s.nextSlice(">"); + s.nextSlice("\x1b[38;2;1;2;3m"); + s.nextSlice(">"); // RGB 3, 2, 1 - try s.nextSlice("\x1b[38;2;3;2;1m"); - try s.nextSlice("="); + s.nextSlice("\x1b[38;2;3;2;1m"); + s.nextSlice("="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1945,11 +1954,11 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); // RGB 1, 2, 3 bg - try s.nextSlice("\x1b[48;2;1;2;3m"); - try s.nextSlice(">"); + s.nextSlice("\x1b[48;2;1;2;3m"); + s.nextSlice(">"); // RGB 3, 2, 1 bg - try s.nextSlice("\x1b[48;2;3;2;1m"); - try s.nextSlice("="); + s.nextSlice("\x1b[48;2;3;2;1m"); + s.nextSlice("="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1976,9 +1985,9 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); // RGB 1, 2, 3 bg - try s.nextSlice("\x1b[48;2;1;2;3m"); - try s.nextSlice(">"); - try s.nextSlice("="); + s.nextSlice("\x1b[48;2;1;2;3m"); + s.nextSlice(">"); + s.nextSlice("="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); diff --git a/src/font/shaper/web_canvas.zig b/src/font/shaper/web_canvas.zig index 590c1d2a3..d16bd9681 100644 --- a/src/font/shaper/web_canvas.zig +++ b/src/font/shaper/web_canvas.zig @@ -58,7 +58,7 @@ pub const Shaper = struct { } /// Returns an iterator that returns one text run at a time for the - /// given terminal row. Note that text runs are are only valid one at a time + /// given terminal row. Note that text runs are only valid one at a time /// for a Shaper struct since they share state. pub fn runIterator( self: *Shaper, diff --git a/src/font/sprite/draw/box.zig b/src/font/sprite/draw/box.zig index cc6e694d4..da3edb51c 100644 --- a/src/font/sprite/draw/box.zig +++ b/src/font/sprite/draw/box.zig @@ -845,7 +845,7 @@ fn dashHorizontal( } hline(canvas, x, x1, y, thick_px); // Advance by the width of the dash we drew and the width - // of a gap to get the the start of the next dash. + // of a gap to get the start of the next dash. x = x1 + gap_width; } } @@ -923,7 +923,7 @@ fn dashVertical( } vline(canvas, y, y1, x, thick_px); // Advance by the height of the dash we drew and the height - // of a gap to get the the start of the next dash. + // of a gap to get the start of the next dash. y = y1 + gap_height; } } diff --git a/src/helpgen.zig b/src/helpgen.zig index fe30db10c..49b5f4439 100644 --- a/src/helpgen.zig +++ b/src/helpgen.zig @@ -12,7 +12,7 @@ pub fn main() !void { const alloc = gpa.allocator(); var buf: [4096]u8 = undefined; - var stdout = std.fs.File.stdout().writer(&buf); + var stdout = std.fs.File.stdout().writerStreaming(&buf); const writer = &stdout.interface; try writer.writeAll( \\// THIS FILE IS AUTO GENERATED diff --git a/src/input.zig b/src/input.zig index bad3ac1f3..833e05820 100644 --- a/src/input.zig +++ b/src/input.zig @@ -12,6 +12,7 @@ pub const function_keys = @import("input/function_keys.zig"); pub const keycodes = @import("input/keycodes.zig"); pub const key_encode = @import("input/key_encode.zig"); pub const kitty = @import("input/kitty.zig"); +pub const mouse_encode = @import("input/mouse_encode.zig"); pub const paste = @import("input/paste.zig"); pub const ctrlOrSuper = key.ctrlOrSuper; @@ -25,6 +26,7 @@ pub const KeyEvent = key.KeyEvent; pub const KeyRemapSet = key_mods.RemapSet; pub const InspectorMode = Binding.Action.InspectorMode; pub const Mods = key_mods.Mods; +pub const MouseAction = mouse.Action; pub const MouseButton = mouse.Button; pub const MouseButtonState = mouse.ButtonState; pub const MousePressureStage = mouse.PressureStage; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 286c8f2ed..62a4e39ac 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -577,6 +577,16 @@ pub const Action = union(enum) { /// and persists across focus changes within the tab. prompt_tab_title, + /// Set the title for the current focused surface. + /// + /// If the title is empty, the surface title is reset to an empty title. + set_surface_title: []const u8, + + /// Set the title for the current focused tab. + /// + /// If the title is empty, the tab title override is cleared. + set_tab_title: []const u8, + /// Create a new split in the specified direction. /// /// Valid arguments: @@ -1324,6 +1334,8 @@ pub const Action = union(enum) { .set_font_size, .prompt_surface_title, .prompt_tab_title, + .set_surface_title, + .set_tab_title, .clear_screen, .select_all, .scroll_to_top, @@ -3292,6 +3304,16 @@ test "parse: action with string" { try testing.expect(binding.action == .esc); try testing.expectEqualStrings("A", binding.action.esc); } + { + const binding = try parseSingle("a=set_surface_title:surface"); + try testing.expect(binding.action == .set_surface_title); + try testing.expectEqualStrings("surface", binding.action.set_surface_title); + } + { + const binding = try parseSingle("a=set_tab_title:tab"); + try testing.expect(binding.action == .set_tab_title); + try testing.expectEqualStrings("tab", binding.action.set_tab_title); + } } test "parse: action with enum" { @@ -4557,6 +4579,18 @@ test "action: format" { try testing.expectEqualStrings("text:\\xf0\\x9f\\x91\\xbb", buf.written()); } +test "action: format set title" { + const testing = std.testing; + const alloc = testing.allocator; + + const a: Action = .{ .set_tab_title = "foo bar" }; + + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try a.format(&buf.writer); + try testing.expectEqualStrings("set_tab_title:foo bar", buf.written()); +} + test "set: appendChain with no parent returns error" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/input/command.zig b/src/input/command.zig index f50e6840b..ac048eec0 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -689,6 +689,8 @@ fn actionCommands(action: Action.Key) []const Command { .esc, .cursor_key, .set_font_size, + .set_surface_title, + .set_tab_title, .search, .scroll_to_row, .scroll_page_fractional, diff --git a/src/input/key.zig b/src/input/key.zig index a929a0323..9c04f01e0 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -2,7 +2,6 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const cimgui = @import("dcimgui"); -const OptionAsAlt = @import("config.zig").OptionAsAlt; pub const Mods = @import("key_mods.zig").Mods; diff --git a/src/input/key_encode.zig b/src/input/key_encode.zig index 0373fb5f9..52bb556e3 100644 --- a/src/input/key_encode.zig +++ b/src/input/key_encode.zig @@ -159,7 +159,7 @@ fn kitty( switch (event.key) { else => {}, inline .enter, .backspace => |tag| { - // See legacy for why we handle this this way. + // See legacy for why we handle this way. if (isControlUtf8(event.utf8)) break :utf8; if (comptime tag == .backspace) return; return try writer.writeAll(event.utf8); diff --git a/src/input/keycodes.zig b/src/input/keycodes.zig index 2fa0665ea..9a3ebc923 100644 --- a/src/input/keycodes.zig +++ b/src/input/keycodes.zig @@ -40,7 +40,7 @@ pub const Entry = struct { }; /// A map from code to key. This isn't meant to be used at runtime -/// (though it could), so it isn't exported. It it used to build the +/// (though it could), so it isn't exported. It used to build the /// key value for Entry. const code_to_key = code_to_key: { @setEvalBranchQuota(5000); diff --git a/src/input/mouse.zig b/src/input/mouse.zig index bdf967ed2..fad3ed67a 100644 --- a/src/input/mouse.zig +++ b/src/input/mouse.zig @@ -1,5 +1,11 @@ const std = @import("std"); +/// The type of action associated with a mouse event. This is different +/// from ButtonState because button state is simply the current state +/// of a mouse button but an action is something that triggers via +/// an GUI event and supports more. +pub const Action = enum(c_int) { press, release, motion }; + /// The state of a mouse button. /// /// This is backed by a c_int so we can use this as-is for our embedding API. diff --git a/src/input/mouse_encode.zig b/src/input/mouse_encode.zig new file mode 100644 index 000000000..13a8bd462 --- /dev/null +++ b/src/input/mouse_encode.zig @@ -0,0 +1,781 @@ +const std = @import("std"); +const testing = std.testing; +const terminal = @import("../terminal/main.zig"); +const Terminal = terminal.Terminal; +const renderer_size = @import("../renderer/size.zig"); +const point = @import("../terminal/point.zig"); +const key = @import("key.zig"); +const mouse = @import("mouse.zig"); + +const log = std.log.scoped(.mouse_encode); + +/// Options that affect mouse encoding behavior and provide runtime context. +pub const Options = struct { + /// Terminal mouse reporting mode (X10, normal, button, any). + event: terminal.MouseEvent = .none, + + /// Terminal mouse reporting format. + format: terminal.MouseFormat = .x10, + + /// Full renderer size used to convert surface-space pixel positions + /// into grid cell coordinates (for most formats) and terminal-space + /// pixel coordinates (for SGR-Pixels), as well as to determine + /// whether a position falls outside the visible viewport. + size: renderer_size.Size, + + /// Whether any mouse button is currently pressed. When a motion + /// event occurs outside the viewport, it is only reported if a + /// button is held down and the event mode supports motion tracking. + /// Without this, out-of-viewport motions are silently dropped. + /// + /// This should reflect the state of the current event as well, so + /// if the encoded event is a button press, this should be true. + any_button_pressed: bool = false, + + /// Last reported viewport cell for motion deduplication. + /// If null, motion deduplication state is not tracked. + last_cell: ?*?point.Coordinate = null, + + /// Initialize from terminal and renderer state. The caller may still + /// set any_button_pressed and last_cell on the returned value. + pub fn fromTerminal( + t: *const Terminal, + size: renderer_size.Size, + ) Options { + return .{ + .event = t.flags.mouse_event, + .format = t.flags.mouse_format, + .size = size, + }; + } +}; + +/// A normalized mouse event for protocol encoding. +pub const Event = struct { + /// The action of this mouse event. + action: mouse.Action = .press, + + /// The button involved in this event. This can be null in the + /// case of a motion action with no pressed buttons. + button: ?mouse.Button = null, + + /// Keyboard modifiers held during this event. + mods: key.Mods = .{}, + + /// Mouse position in terminal-space pixels, with (0, 0) at the top-left + /// of the terminal. Negative values are allowed and indicate positions + /// above or to the left of the terminal. Values larger than the terminal + /// size are also allowed and indicate right or below the terminal. + pos: Pos = .{}, + + /// Mouse position in surface-space pixels. + pub const Pos = extern struct { + x: f32 = 0, + y: f32 = 0, + }; +}; + +/// Encode the mouse event to the writer according to the options. +/// +/// Not all events result in output. +pub fn encode( + writer: *std.Io.Writer, + event: Event, + opts: Options, +) std.Io.Writer.Error!void { + if (!shouldReport(event, opts)) return; + + // Handle scenarios where the mouse position is outside the viewport. + // We always report release events no matter where they happen. + if (event.action != .release and + posOutOfViewport(event.pos, opts.size)) + { + // If we don't have a motion-tracking event mode, do nothing, + // because events outside the viewport are never reported in + // such cases. + if (!terminal.mouse.eventSendsMotion(opts.event)) return; + + // For motion modes, we only report if a button is currently pressed. + // This lets a TUI detect a click over the surface + drag out + // of the surface. + if (!opts.any_button_pressed) return; + } + + const cell = posToCell(event.pos, opts.size); + + // We only send motion events when the cell changed unless + // we're tracking raw pixels. + if (event.action == .motion and opts.format != .sgr_pixels) { + if (opts.last_cell) |last| { + if (last.*) |last_cell| { + if (last_cell.eql(cell)) return; + } + } + } + + // Update the last reported cell if we are tracking it. + if (opts.last_cell) |last| last.* = cell; + + const button_code = buttonCode(event, opts) orelse return; + switch (opts.format) { + .x10 => { + if (cell.x > 222 or cell.y > 222) { + log.info("X10 mouse format can only encode X/Y up to 223", .{}); + return; + } + + // + 1 because our x/y are zero-indexed and the protocol uses 1-indexing. + try writer.writeAll("\x1B[M"); + try writer.writeByte(32 + button_code); + try writer.writeByte(32 + @as(u8, @intCast(cell.x)) + 1); + try writer.writeByte(32 + @as(u8, @intCast(cell.y)) + 1); + }, + + .utf8 => { + try writer.writeAll("\x1B[M"); + + // The button code always fits in a single byte. + try writer.writeByte(32 + button_code); + + var buf: [4]u8 = undefined; + const x_cp: u21 = @intCast(@as(u32, cell.x) + 33); + const y_cp: u21 = @intCast(cell.y + 33); + + const x_len = std.unicode.utf8Encode(x_cp, &buf) catch unreachable; + try writer.writeAll(buf[0..x_len]); + + const y_len = std.unicode.utf8Encode(y_cp, &buf) catch unreachable; + try writer.writeAll(buf[0..y_len]); + }, + + .sgr => try writer.print("\x1B[<{d};{d};{d}{c}", .{ + button_code, + cell.x + 1, + cell.y + 1, + @as(u8, if (event.action == .release) 'm' else 'M'), + }), + + .urxvt => try writer.print("\x1B[{d};{d};{d}M", .{ + 32 + button_code, + cell.x + 1, + cell.y + 1, + }), + + .sgr_pixels => { + const pixels = posToPixels(event.pos, opts.size); + try writer.print("\x1B[<{d};{d};{d}{c}", .{ + button_code, + pixels.x, + pixels.y, + @as(u8, if (event.action == .release) 'm' else 'M'), + }); + }, + } +} + +/// Returns true if this event should be reported for the given mouse +/// event mode. +fn shouldReport(event: Event, opts: Options) bool { + return switch (opts.event) { + .none => false, + + // X10 only reports button presses of left, middle, and right. + .x10 => event.action == .press and + event.button != null and + (event.button.? == .left or + event.button.? == .middle or + event.button.? == .right), + + // Normal mode does not report motion. + .normal => event.action != .motion, + + // Button mode requires an active button for motion events. + .button => event.button != null, + + // Any mode reports everything. + .any => true, + }; +} + +fn buttonCode(event: Event, opts: Options) ?u8 { + var acc: u8 = code: { + if (event.button == null) { + // Null button means motion with no pressed button. + break :code 3; + } + + if (event.action == .release and + opts.format != .sgr and + opts.format != .sgr_pixels) + { + // Legacy releases are always encoded as button 3. + break :code 3; + } + + break :code switch (event.button.?) { + .left => 0, + .middle => 1, + .right => 2, + .four => 64, + .five => 65, + .six => 66, + .seven => 67, + .eight => 128, + .nine => 129, + else => return null, + }; + }; + + // X10 does not include modifiers. + if (opts.event != .x10) { + if (event.mods.shift) acc += 4; + if (event.mods.alt) acc += 8; + if (event.mods.ctrl) acc += 16; + } + + // Motion adds another bit. + if (event.action == .motion) acc += 32; + + return acc; +} + +/// Terminal-space pixel position for SGR pixel reporting. +const PixelPoint = struct { + x: i32, + y: i32, +}; + +/// Returns true if the surface-space pixel position is outside the +/// visible viewport bounds (negative or beyond screen dimensions). +fn posOutOfViewport(pos: Event.Pos, size: renderer_size.Size) bool { + const max_x: f32 = @floatFromInt(size.screen.width); + const max_y: f32 = @floatFromInt(size.screen.height); + return pos.x < 0 or pos.y < 0 or pos.x > max_x or pos.y > max_y; +} + +/// Converts a surface-space pixel position to a zero-based grid cell +/// coordinate (column, row) within the terminal viewport. Out-of-bounds +/// values are clamped to the valid grid range (0 to columns/rows - 1). +fn posToCell(pos: Event.Pos, size: renderer_size.Size) point.Coordinate { + const coord: renderer_size.Coordinate = .{ .surface = .{ + .x = @as(f64, @floatCast(pos.x)), + .y = @as(f64, @floatCast(pos.y)), + } }; + const grid = coord.convert(.grid, size).grid; + return .{ .x = grid.x, .y = grid.y }; +} + +/// Converts a surface-space pixel position to terminal-space pixel +/// coordinates (accounting for padding/scaling) used by SGR-Pixels mode. +/// Unlike grid conversion, terminal-space coordinates are not clamped +/// and may be negative or exceed the terminal dimensions. +fn posToPixels(pos: Event.Pos, size: renderer_size.Size) PixelPoint { + const coord: renderer_size.Coordinate.Terminal = (renderer_size.Coordinate{ .surface = .{ + .x = @as(f64, @floatCast(pos.x)), + .y = @as(f64, @floatCast(pos.y)), + } }).convert(.terminal, size).terminal; + + return .{ + .x = @as(i32, @intFromFloat(@round(coord.x))), + .y = @as(i32, @intFromFloat(@round(coord.y))), + }; +} + +fn testSize() renderer_size.Size { + return .{ + .screen = .{ .width = 1_000, .height = 1_000 }, + .cell = .{ .width = 1, .height = 1 }, + .padding = .{}, + }; +} + +test "shouldReport: none mode never reports" { + const size = testSize(); + inline for ([_]mouse.Action{ .press, .release, .motion }) |action| { + try testing.expect(!shouldReport(.{ + .button = .left, + .action = action, + }, .{ .event = .none, .size = size })); + } +} + +test "shouldReport: x10 reports only left/middle/right press" { + const size = testSize(); + // Left, middle, right presses should report. + inline for ([_]mouse.Button{ .left, .middle, .right }) |btn| { + try testing.expect(shouldReport(.{ + .button = btn, + .action = .press, + }, .{ .event = .x10, .size = size })); + } + + // Release is not reported. + try testing.expect(!shouldReport(.{ + .button = .left, + .action = .release, + }, .{ .event = .x10, .size = size })); + + // Motion is not reported. + try testing.expect(!shouldReport(.{ + .button = .left, + .action = .motion, + }, .{ .event = .x10, .size = size })); + + // Other buttons are not reported. + try testing.expect(!shouldReport(.{ + .button = .four, + .action = .press, + }, .{ .event = .x10, .size = size })); + + // Null button is not reported. + try testing.expect(!shouldReport(.{ + .button = null, + .action = .press, + }, .{ .event = .x10, .size = size })); +} + +test "shouldReport: normal reports press and release but not motion" { + const size = testSize(); + try testing.expect(shouldReport(.{ + .button = .left, + .action = .press, + }, .{ .event = .normal, .size = size })); + + try testing.expect(shouldReport(.{ + .button = .left, + .action = .release, + }, .{ .event = .normal, .size = size })); + + try testing.expect(!shouldReport(.{ + .button = .left, + .action = .motion, + }, .{ .event = .normal, .size = size })); +} + +test "shouldReport: button mode requires a button" { + const size = testSize(); + // With a button, all actions report. + inline for ([_]mouse.Action{ .press, .release, .motion }) |action| { + try testing.expect(shouldReport(.{ + .button = .left, + .action = action, + }, .{ .event = .button, .size = size })); + } + + // Without a button (null), nothing reports. + inline for ([_]mouse.Action{ .press, .release, .motion }) |action| { + try testing.expect(!shouldReport(.{ + .button = null, + .action = action, + }, .{ .event = .button, .size = size })); + } +} + +test "shouldReport: any mode reports everything" { + const size = testSize(); + inline for ([_]mouse.Action{ .press, .release, .motion }) |action| { + try testing.expect(shouldReport(.{ + .button = .left, + .action = action, + }, .{ .event = .any, .size = size })); + } + + // Even null button + motion reports. + try testing.expect(shouldReport(.{ + .button = null, + .action = .motion, + }, .{ .event = .any, .size = size })); +} + +test "x10 press left" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .press, + .mods = .{ .shift = true, .alt = true, .ctrl = true }, + .pos = .{ .x = 0, .y = 0 }, + }, .{ + .event = .x10, + .format = .x10, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqualSlices(u8, &.{ + 0x1B, + '[', + 'M', + 32, + 33, + 33, + }, writer.buffered()); +} + +test "x10 ignores release" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .release, + }, .{ + .event = .x10, + .format = .x10, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqual(@as(usize, 0), writer.buffered().len); +} + +test "normal ignores motion" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .motion, + }, .{ + .event = .normal, + .format = .sgr, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqual(@as(usize, 0), writer.buffered().len); +} + +test "button mode requires button" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = null, + .action = .motion, + }, .{ + .event = .button, + .format = .sgr, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqual(@as(usize, 0), writer.buffered().len); +} + +test "sgr release keeps button identity" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .right, + .action = .release, + .pos = .{ .x = 4, .y = 5 }, + }, .{ + .event = .any, + .format = .sgr, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqualStrings("\x1B[<2;5;6m", writer.buffered()); +} + +test "sgr motion with no button" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = null, + .action = .motion, + .pos = .{ .x = 1, .y = 2 }, + }, .{ + .event = .any, + .format = .sgr, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqualStrings("\x1B[<35;2;3M", writer.buffered()); +} + +test "urxvt with modifiers" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .press, + .mods = .{ .shift = true, .alt = true, .ctrl = true }, + .pos = .{ .x = 2, .y = 3 }, + }, .{ + .event = .any, + .format = .urxvt, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqualStrings("\x1B[60;3;4M", writer.buffered()); +} + +test "utf8 encodes large coordinates" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .press, + .pos = .{ .x = 300, .y = 400 }, + }, .{ + .event = .any, + .format = .utf8, + .size = testSize(), + .last_cell = &last, + }); + + const out = writer.buffered(); + try testing.expectEqualSlices(u8, &.{ 0x1B, '[', 'M', 32 }, out[0..4]); + + const view = try std.unicode.Utf8View.init(out[4..]); + var it = view.iterator(); + try testing.expectEqual(@as(u21, 333), it.nextCodepoint().?); + try testing.expectEqual(@as(u21, 433), it.nextCodepoint().?); + try testing.expectEqual(@as(?u21, null), it.nextCodepoint()); +} + +test "x10 coordinate limit" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .press, + .pos = .{ .x = 223, .y = 0 }, + }, .{ + .event = .x10, + .format = .x10, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqual(@as(usize, 0), writer.buffered().len); +} + +test "sgr wheel button mappings" { + const Case = struct { + button: mouse.Button, + code: u8, + }; + + inline for ([_]Case{ + .{ .button = .four, .code = 64 }, + .{ .button = .five, .code = 65 }, + .{ .button = .six, .code = 66 }, + .{ .button = .seven, .code = 67 }, + }) |c| { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = c.button, + .action = .press, + .pos = .{ .x = 0, .y = 0 }, + }, .{ + .event = .any, + .format = .sgr, + .size = testSize(), + .last_cell = &last, + }); + + var expected: [32]u8 = undefined; + const want = try std.fmt.bufPrint(&expected, "\x1B[<{d};1;1M", .{c.code}); + try testing.expectEqualStrings(want, writer.buffered()); + } +} + +test "urxvt release uses legacy button 3 encoding" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .right, + .action = .release, + .pos = .{ .x = 2, .y = 3 }, + }, .{ + .event = .any, + .format = .urxvt, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqualStrings("\x1B[35;3;4M", writer.buffered()); +} + +test "unsupported button is ignored" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .ten, + .action = .press, + .pos = .{ .x = 1, .y = 1 }, + }, .{ + .event = .any, + .format = .sgr, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqual(@as(usize, 0), writer.buffered().len); +} + +test "sgr pixels uses terminal-space cursor coordinates" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .press, + .pos = .{ .x = 10, .y = 20 }, + }, .{ + .event = .any, + .format = .sgr_pixels, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqualStrings("\x1B[<0;10;20M", writer.buffered()); +} + +test "sgr pixels release keeps button identity" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .right, + .action = .release, + .pos = .{ .x = 10, .y = 20 }, + }, .{ + .event = .any, + .format = .sgr_pixels, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqualStrings("\x1B[<2;10;20m", writer.buffered()); +} + +test "position exactly at viewport boundary is encoded in final cell" { + const size: renderer_size.Size = .{ + .screen = .{ .width = 10, .height = 10 }, + .cell = .{ .width = 2, .height = 2 }, + .padding = .{}, + }; + + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .press, + .pos = .{ .x = 10, .y = 10 }, + }, .{ + .event = .any, + .format = .sgr, + .size = size, + .last_cell = &last, + }); + + try testing.expectEqualStrings("\x1B[<0;5;5M", writer.buffered()); +} + +test "outside viewport motion with no pressed button is ignored" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .motion, + .pos = .{ .x = -1, .y = -1 }, + }, .{ + .event = .any, + .format = .sgr, + .size = testSize(), + .any_button_pressed = false, + .last_cell = &last, + }); + + try testing.expectEqual(@as(usize, 0), writer.buffered().len); +} + +test "outside viewport motion with pressed button is reported" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .motion, + .pos = .{ .x = -1, .y = -1 }, + }, .{ + .event = .any, + .format = .sgr, + .size = testSize(), + .any_button_pressed = true, + .last_cell = &last, + }); + + try testing.expectEqualStrings("\x1B[<32;1;1M", writer.buffered()); +} + +test "motion is deduped by last cell except sgr pixels" { + var last: ?point.Coordinate = null; + + { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + try encode(&writer, .{ + .button = .left, + .action = .motion, + .pos = .{ .x = 5, .y = 6 }, + }, .{ + .event = .any, + .format = .sgr, + .size = testSize(), + .last_cell = &last, + }); + try testing.expect(writer.buffered().len > 0); + } + + { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + try encode(&writer, .{ + .button = .left, + .action = .motion, + .pos = .{ .x = 5, .y = 6 }, + }, .{ + .event = .any, + .format = .sgr, + .size = testSize(), + .last_cell = &last, + }); + try testing.expectEqual(@as(usize, 0), writer.buffered().len); + } + + { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + try encode(&writer, .{ + .button = .left, + .action = .motion, + .pos = .{ .x = 5, .y = 6 }, + }, .{ + .event = .any, + .format = .sgr_pixels, + .size = testSize(), + .last_cell = &last, + }); + try testing.expect(writer.buffered().len > 0); + } +} diff --git a/src/inspector/widgets/termio.zig b/src/inspector/widgets/termio.zig index a6c8f6081..b721d9422 100644 --- a/src/inspector/widgets/termio.zig +++ b/src/inspector/widgets/termio.zig @@ -54,7 +54,7 @@ pub const Stream = struct { .events = &self.events, }; defer self.parser_stream.handler.state = null; - try self.parser_stream.nextSlice(data); + self.parser_stream.nextSlice(data); } pub fn draw( @@ -736,7 +736,7 @@ const VTHandler = struct { self: *VTHandler, comptime action: VTHandler.Stream.Action.Tag, value: VTHandler.Stream.Action.Value(action), - ) !void { + ) void { _ = self; _ = value; } diff --git a/src/lib/enum.zig b/src/lib/enum.zig index bdec2ab88..76e05fa93 100644 --- a/src/lib/enum.zig +++ b/src/lib/enum.zig @@ -95,37 +95,37 @@ test "abi by removing a key" { /// Verify that for every key in enum T, there is a matching declaration in /// `ghostty.h` with the correct value. This should only ever be called inside a `test` /// because the `ghostty.h` module is only available then. -pub fn checkGhosttyHEnum(comptime T: type, comptime prefix: []const u8) !void { +pub fn checkGhosttyHEnum( + comptime T: type, + comptime prefix: []const u8, +) !void { const info = @typeInfo(T); try std.testing.expect(info == .@"enum"); try std.testing.expect(info.@"enum".tag_type == c_int); try std.testing.expect(info.@"enum".is_exhaustive == true); - @setEvalBranchQuota(1000000); + @setEvalBranchQuota(100_000); const c = @import("ghostty.h"); var set: std.EnumSet(T) = .initFull(); - const c_decls = @typeInfo(c).@"struct".decls; const enum_fields = info.@"enum".fields; inline for (enum_fields) |field| { - const upper_name = comptime u: { - var buf: [128]u8 = undefined; - break :u std.ascii.upperString(&buf, field.name); + const expected_name: *const [prefix.len + field.name.len]u8 = comptime e: { + var buf: [prefix.len + field.name.len]u8 = undefined; + @memcpy(buf[0..prefix.len], prefix); + for (buf[prefix.len..], field.name) |*d, s| { + d.* = std.ascii.toUpper(s); + } + break :e &buf; }; - inline for (c_decls) |decl| { - if (!comptime std.mem.startsWith(u8, decl.name, prefix)) continue; - - const suffix = decl.name[prefix.len..]; - - if (!comptime std.mem.eql(u8, suffix, upper_name)) continue; - - std.testing.expectEqual(field.value, @field(c, decl.name)) catch |e| { - std.log.err(@typeName(T) ++ " key " ++ field.name ++ " does not have the same backing int as " ++ decl.name, .{}); + if (@hasDecl(c, expected_name)) { + std.testing.expectEqual(field.value, @field(c, expected_name)) catch |e| { + std.log.err(@typeName(T) ++ " key " ++ field.name ++ " does not have the same backing int as " ++ expected_name, .{}); return e; }; diff --git a/src/lib/main.zig b/src/lib/main.zig index 89c6f6c47..05ebe9bd7 100644 --- a/src/lib/main.zig +++ b/src/lib/main.zig @@ -1,5 +1,6 @@ const std = @import("std"); const enumpkg = @import("enum.zig"); +const structpkg = @import("struct.zig"); const types = @import("types.zig"); const unionpkg = @import("union.zig"); @@ -7,7 +8,8 @@ pub const allocator = @import("allocator.zig"); pub const Enum = enumpkg.Enum; pub const checkGhosttyHEnum = enumpkg.checkGhosttyHEnum; pub const String = types.String; -pub const Struct = @import("struct.zig").Struct; +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; diff --git a/src/lib/struct.zig b/src/lib/struct.zig index d494da2e6..bbf07ece3 100644 --- a/src/lib/struct.zig +++ b/src/lib/struct.zig @@ -1,6 +1,17 @@ const std = @import("std"); +const testing = std.testing; const Target = @import("target.zig").Target; +/// Create a struct type that is C ABI compatible from a Zig struct type. +/// +/// When the target is `.zig`, the original struct type is returned as-is. +/// When the target is `.c`, the struct is recreated with an `extern` layout, +/// ensuring a stable, C-compatible memory layout. +/// +/// This handles packed structs by resolving zero alignments to the natural +/// alignment of each field's type, since extern structs require explicit +/// alignment. This means packed struct fields like `bool` will take up +/// their full size (1 byte) rather than being bit-packed. pub fn Struct( comptime target: Target, comptime Zig: type, @@ -16,7 +27,7 @@ pub fn Struct( .type = field.type, .default_value_ptr = field.default_value_ptr, .is_comptime = field.is_comptime, - .alignment = field.alignment, + .alignment = if (field.alignment > 0) field.alignment else @alignOf(field.type), }; } @@ -29,3 +40,66 @@ pub fn Struct( }, }; } + +/// Returns true if a struct of type `T` with size `size` can set +/// field `field` (if it fits within the size). This is used for ABI +/// compatibility for structs that have an explicit size field. +pub fn sizedFieldFits( + comptime T: type, + size: usize, + comptime field: []const u8, +) bool { + const offset = @offsetOf(T, field); + const field_size = @sizeOf(@FieldType(T, field)); + return size >= offset + field_size; +} + +test "sizedFieldFits boundary checks" { + const Sized = extern struct { + size: usize, + a: u8, + b: u32, + }; + + const size_required = @offsetOf(Sized, "size") + @sizeOf(@FieldType(Sized, "size")); + const a_required = @offsetOf(Sized, "a") + @sizeOf(@FieldType(Sized, "a")); + const b_required = @offsetOf(Sized, "b") + @sizeOf(@FieldType(Sized, "b")); + + try testing.expect(sizedFieldFits(Sized, size_required, "size")); + try testing.expect(!sizedFieldFits(Sized, size_required - 1, "size")); + + try testing.expect(sizedFieldFits(Sized, a_required, "a")); + try testing.expect(!sizedFieldFits(Sized, a_required - 1, "a")); + + try testing.expect(sizedFieldFits(Sized, b_required, "b")); + try testing.expect(!sizedFieldFits(Sized, b_required - 1, "b")); +} + +test "sizedFieldFits respects alignment padding" { + const Sized = extern struct { + size: usize, + a: u8, + b: u32, + }; + + const up_to_padding = @offsetOf(Sized, "b"); + try testing.expect(sizedFieldFits(Sized, up_to_padding, "a")); + try testing.expect(!sizedFieldFits(Sized, up_to_padding, "b")); +} + +test "packed struct converts to extern with full-size bools" { + const Packed = packed struct { + flag1: bool, + flag2: bool, + value: u8, + }; + + const C = Struct(.c, Packed); + const info = @typeInfo(C).@"struct"; + + try testing.expectEqual(.@"extern", info.layout); + try testing.expectEqual(@as(usize, 1), @sizeOf(@FieldType(C, "flag1"))); + try testing.expectEqual(@as(usize, 1), @sizeOf(@FieldType(C, "flag2"))); + try testing.expectEqual(@as(usize, 1), @sizeOf(@FieldType(C, "value"))); + try testing.expectEqual(@as(usize, 3), @sizeOf(C)); +} diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 426660621..0a749be87 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -53,14 +53,14 @@ pub const RenderState = terminal.RenderState; pub const Screen = terminal.Screen; pub const ScreenSet = terminal.ScreenSet; pub const Selection = terminal.Selection; +pub const size_report = terminal.size_report; pub const SizeReportStyle = terminal.SizeReportStyle; pub const StringMap = terminal.StringMap; pub const Style = terminal.Style; pub const Terminal = terminal.Terminal; +pub const TerminalStream = terminal.TerminalStream; pub const Stream = terminal.Stream; pub const StreamAction = terminal.StreamAction; -pub const ReadonlyStream = terminal.ReadonlyStream; -pub const ReadonlyHandler = terminal.ReadonlyHandler; pub const Cursor = Screen.Cursor; pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = terminal.CursorStyle; @@ -81,9 +81,16 @@ pub const input = struct { // We have to be careful to only import targeted files within // the input package because the full package brings in too many // other dependencies. + const focus = terminal.focus; const paste = @import("input/paste.zig"); const key = @import("input/key.zig"); const key_encode = @import("input/key_encode.zig"); + const mouse_encode = @import("input/mouse_encode.zig"); + + // Focus-related APIs + pub const max_focus_encode_size = focus.max_encode_size; + pub const FocusEvent = focus.Event; + pub const encodeFocus = focus.encode; // Paste-related APIs pub const PasteError = paste.Error; @@ -98,6 +105,13 @@ pub const input = struct { pub const KeyMods = key.Mods; pub const KeyEncodeOptions = key_encode.Options; pub const encodeKey = key_encode.encode; + + // Mouse encoding + pub const MouseAction = @import("input/mouse.zig").Action; + pub const MouseButton = @import("input/mouse.zig").Button; + pub const MouseEncodeOptions = mouse_encode.Options; + pub const MouseEncodeEvent = mouse_encode.Event; + pub const encodeMouse = mouse_encode.encode; }; comptime { @@ -124,7 +138,25 @@ comptime { @export(&c.key_encoder_new, .{ .name = "ghostty_key_encoder_new" }); @export(&c.key_encoder_free, .{ .name = "ghostty_key_encoder_free" }); @export(&c.key_encoder_setopt, .{ .name = "ghostty_key_encoder_setopt" }); + @export(&c.key_encoder_setopt_from_terminal, .{ .name = "ghostty_key_encoder_setopt_from_terminal" }); @export(&c.key_encoder_encode, .{ .name = "ghostty_key_encoder_encode" }); + @export(&c.mouse_event_new, .{ .name = "ghostty_mouse_event_new" }); + @export(&c.mouse_event_free, .{ .name = "ghostty_mouse_event_free" }); + @export(&c.mouse_event_set_action, .{ .name = "ghostty_mouse_event_set_action" }); + @export(&c.mouse_event_get_action, .{ .name = "ghostty_mouse_event_get_action" }); + @export(&c.mouse_event_set_button, .{ .name = "ghostty_mouse_event_set_button" }); + @export(&c.mouse_event_clear_button, .{ .name = "ghostty_mouse_event_clear_button" }); + @export(&c.mouse_event_get_button, .{ .name = "ghostty_mouse_event_get_button" }); + @export(&c.mouse_event_set_mods, .{ .name = "ghostty_mouse_event_set_mods" }); + @export(&c.mouse_event_get_mods, .{ .name = "ghostty_mouse_event_get_mods" }); + @export(&c.mouse_event_set_position, .{ .name = "ghostty_mouse_event_set_position" }); + @export(&c.mouse_event_get_position, .{ .name = "ghostty_mouse_event_get_position" }); + @export(&c.mouse_encoder_new, .{ .name = "ghostty_mouse_encoder_new" }); + @export(&c.mouse_encoder_free, .{ .name = "ghostty_mouse_encoder_free" }); + @export(&c.mouse_encoder_setopt, .{ .name = "ghostty_mouse_encoder_setopt" }); + @export(&c.mouse_encoder_setopt_from_terminal, .{ .name = "ghostty_mouse_encoder_setopt_from_terminal" }); + @export(&c.mouse_encoder_reset, .{ .name = "ghostty_mouse_encoder_reset" }); + @export(&c.mouse_encoder_encode, .{ .name = "ghostty_mouse_encoder_encode" }); @export(&c.osc_new, .{ .name = "ghostty_osc_new" }); @export(&c.osc_free, .{ .name = "ghostty_osc_free" }); @export(&c.osc_next, .{ .name = "ghostty_osc_next" }); @@ -132,7 +164,14 @@ comptime { @export(&c.osc_end, .{ .name = "ghostty_osc_end" }); @export(&c.osc_command_type, .{ .name = "ghostty_osc_command_type" }); @export(&c.osc_command_data, .{ .name = "ghostty_osc_command_data" }); + @export(&c.focus_encode, .{ .name = "ghostty_focus_encode" }); + @export(&c.mode_report_encode, .{ .name = "ghostty_mode_report_encode" }); @export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" }); + @export(&c.size_report_encode, .{ .name = "ghostty_size_report_encode" }); + @export(&c.style_default, .{ .name = "ghostty_style_default" }); + @export(&c.style_is_default, .{ .name = "ghostty_style_is_default" }); + @export(&c.cell_get, .{ .name = "ghostty_cell_get" }); + @export(&c.row_get, .{ .name = "ghostty_row_get" }); @export(&c.color_rgb_get, .{ .name = "ghostty_color_rgb_get" }); @export(&c.sgr_new, .{ .name = "ghostty_sgr_new" }); @export(&c.sgr_free, .{ .name = "ghostty_sgr_free" }); @@ -143,6 +182,44 @@ comptime { @export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" }); @export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" }); @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); + @export(&c.formatter_terminal_new, .{ .name = "ghostty_formatter_terminal_new" }); + @export(&c.formatter_format_buf, .{ .name = "ghostty_formatter_format_buf" }); + @export(&c.formatter_format_alloc, .{ .name = "ghostty_formatter_format_alloc" }); + @export(&c.formatter_free, .{ .name = "ghostty_formatter_free" }); + @export(&c.render_state_new, .{ .name = "ghostty_render_state_new" }); + @export(&c.render_state_update, .{ .name = "ghostty_render_state_update" }); + @export(&c.render_state_get, .{ .name = "ghostty_render_state_get" }); + @export(&c.render_state_set, .{ .name = "ghostty_render_state_set" }); + @export(&c.render_state_colors_get, .{ .name = "ghostty_render_state_colors_get" }); + @export(&c.render_state_row_iterator_new, .{ .name = "ghostty_render_state_row_iterator_new" }); + @export(&c.render_state_row_iterator_next, .{ .name = "ghostty_render_state_row_iterator_next" }); + @export(&c.render_state_row_get, .{ .name = "ghostty_render_state_row_get" }); + @export(&c.render_state_row_set, .{ .name = "ghostty_render_state_row_set" }); + @export(&c.render_state_row_iterator_free, .{ .name = "ghostty_render_state_row_iterator_free" }); + @export(&c.render_state_row_cells_new, .{ .name = "ghostty_render_state_row_cells_new" }); + @export(&c.render_state_row_cells_next, .{ .name = "ghostty_render_state_row_cells_next" }); + @export(&c.render_state_row_cells_select, .{ .name = "ghostty_render_state_row_cells_select" }); + @export(&c.render_state_row_cells_get, .{ .name = "ghostty_render_state_row_cells_get" }); + @export(&c.render_state_row_cells_free, .{ .name = "ghostty_render_state_row_cells_free" }); + @export(&c.render_state_free, .{ .name = "ghostty_render_state_free" }); + @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); + @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); + @export(&c.terminal_reset, .{ .name = "ghostty_terminal_reset" }); + @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_scroll_viewport, .{ .name = "ghostty_terminal_scroll_viewport" }); + @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_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); + @export(&c.grid_ref_cell, .{ .name = "ghostty_grid_ref_cell" }); + @export(&c.grid_ref_row, .{ .name = "ghostty_grid_ref_row" }); + @export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" }); + @export(&c.grid_ref_style, .{ .name = "ghostty_grid_ref_style" }); + @export(&c.build_info, .{ .name = "ghostty_build_info" }); + @export(&c.alloc_alloc, .{ .name = "ghostty_alloc" }); + @export(&c.alloc_free, .{ .name = "ghostty_free" }); // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { diff --git a/src/main_build_data.zig b/src/main_build_data.zig index 9dd1da395..4e55f449d 100644 --- a/src/main_build_data.zig +++ b/src/main_build_data.zig @@ -34,7 +34,7 @@ pub fn main() !void { // Our output always goes to stdout. var buffer: [1024]u8 = undefined; - var stdout_writer = std.fs.File.stdout().writer(&buffer); + var stdout_writer = std.fs.File.stdout().writerStreaming(&buffer); const writer = &stdout_writer.interface; switch (action) { .bash => try writer.writeAll(@import("extra/bash.zig").completions), diff --git a/src/os/homedir.zig b/src/os/homedir.zig index 0868a4fa5..9e0420f4a 100644 --- a/src/os/homedir.zig +++ b/src/os/homedir.zig @@ -13,7 +13,7 @@ const Error = error{ /// is generally an expensive process so the value should be cached. pub inline fn home(buf: []u8) !?[]const u8 { return switch (builtin.os.tag) { - inline .linux, .freebsd, .macos => try homeUnix(buf), + .linux, .freebsd, .macos => try homeUnix(buf), .windows => try homeWindows(buf), // iOS doesn't have a user-writable home directory @@ -122,7 +122,13 @@ pub const ExpandError = error{ pub fn expandHome(path: []const u8, buf: []u8) ExpandError![]const u8 { return switch (builtin.os.tag) { .linux, .freebsd, .macos => try expandHomeUnix(path, buf), + + // `~/` is not an idiom generally used on Windows + .windows => return path, + + // iOS doesn't have a user-writable home directory .ios => return path, + else => @compileError("unimplemented"), }; } @@ -143,6 +149,8 @@ fn expandHomeUnix(path: []const u8, buf: []u8) ExpandError![]const u8 { } test "expandHomeUnix" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const testing = std.testing; const allocator = testing.allocator; var buf: [std.fs.max_path_bytes]u8 = undefined; diff --git a/src/os/hostname.zig b/src/os/hostname.zig index f728a2455..af9148fbf 100644 --- a/src/os/hostname.zig +++ b/src/os/hostname.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const posix = std.posix; pub const LocalHostnameValidationError = error{ @@ -99,9 +100,21 @@ pub fn isLocal(hostname: []const u8) LocalHostnameValidationError!bool { if (std.mem.eql(u8, "localhost", hostname)) return true; // If hostname is not "localhost" it must match our hostname. - var buf: [posix.HOST_NAME_MAX]u8 = undefined; - const ourHostname = try posix.gethostname(&buf); - return std.mem.eql(u8, hostname, ourHostname); + switch (builtin.os.tag) { + .windows => { + const windows = @import("windows.zig"); + var buf: [256:0]u8 = undefined; + var nSize: windows.DWORD = buf.len; + if (windows.exp.kernel32.GetComputerNameA(&buf, &nSize) == 0) return false; + const ourHostname = buf[0..nSize]; + return std.mem.eql(u8, hostname, ourHostname); + }, + else => { + var buf: [posix.HOST_NAME_MAX]u8 = undefined; + const ourHostname = try posix.gethostname(&buf); + return std.mem.eql(u8, hostname, ourHostname); + }, + } } test "isLocal returns true when provided hostname is localhost" { @@ -109,9 +122,21 @@ test "isLocal returns true when provided hostname is localhost" { } test "isLocal returns true when hostname is local" { - var buf: [posix.HOST_NAME_MAX]u8 = undefined; - const localHostname = try posix.gethostname(&buf); - try std.testing.expect(try isLocal(localHostname)); + switch (builtin.os.tag) { + .windows => { + const windows = @import("windows.zig"); + var buf: [256:0]u8 = undefined; + var nSize: windows.DWORD = buf.len; + if (windows.exp.kernel32.GetComputerNameA(&buf, &nSize) == 0) return error.GetComputerNameFailed; + const localHostname = buf[0..nSize]; + try std.testing.expect(try isLocal(localHostname)); + }, + else => { + var buf: [posix.HOST_NAME_MAX]u8 = undefined; + const localHostname = try posix.gethostname(&buf); + try std.testing.expect(try isLocal(localHostname)); + }, + } } test "isLocal returns false when hostname is not local" { diff --git a/src/os/i18n_locales.zig b/src/os/i18n_locales.zig index 91290dab5..a044bfa48 100644 --- a/src/os/i18n_locales.zig +++ b/src/os/i18n_locales.zig @@ -43,6 +43,7 @@ pub const locales = [_][:0]const u8{ "id", "es_BO", "es_AR", + "es_ES", "pt_BR", "ca", "it", @@ -54,5 +55,6 @@ pub const locales = [_][:0]const u8{ "hr", "lt", "lv", + "vi", "kk", }; diff --git a/src/os/open.zig b/src/os/open.zig index 28d1c23ee..0cead5552 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -1,6 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; +const build_config = @import("../build_config.zig"); const apprt = @import("../apprt.zig"); const log = std.log.scoped(.@"os-open"); @@ -48,6 +49,17 @@ pub fn open( exe.stdout_behavior = .Pipe; exe.stderr_behavior = .Pipe; + // In the snap on Linux the launcher exports LD_LIBRARY_PATH pointing at + // the snap's bundled libraries. Leaking this into child process can + // can be problematic, so let's drop it from the env + var snap_env: std.process.EnvMap = if (comptime build_config.snap) blk: { + var env = try std.process.getEnvMap(alloc); + env.remove("LD_LIBRARY_PATH"); + break :blk env; + } else undefined; + defer if (comptime build_config.snap) snap_env.deinit(); + if (comptime build_config.snap) exe.env_map = &snap_env; + // Spawn the process on our same thread so we can detect failure // quickly. try exe.spawn(); diff --git a/src/os/windows.zig b/src/os/windows.zig index 1853f4162..e92a54537 100644 --- a/src/os/windows.zig +++ b/src/os/windows.zig @@ -53,22 +53,22 @@ pub const exp = struct { hWritePipe: *windows.HANDLE, lpPipeAttributes: ?*const windows.SECURITY_ATTRIBUTES, nSize: windows.DWORD, - ) callconv(windows.WINAPI) windows.BOOL; + ) callconv(.winapi) windows.BOOL; pub extern "kernel32" fn CreatePseudoConsole( size: windows.COORD, hInput: windows.HANDLE, hOutput: windows.HANDLE, dwFlags: windows.DWORD, phPC: *HPCON, - ) callconv(windows.WINAPI) windows.HRESULT; - pub extern "kernel32" fn ResizePseudoConsole(hPC: HPCON, size: windows.COORD) callconv(windows.WINAPI) windows.HRESULT; - pub extern "kernel32" fn ClosePseudoConsole(hPC: HPCON) callconv(windows.WINAPI) void; + ) callconv(.winapi) windows.HRESULT; + pub extern "kernel32" fn ResizePseudoConsole(hPC: HPCON, size: windows.COORD) callconv(.winapi) windows.HRESULT; + pub extern "kernel32" fn ClosePseudoConsole(hPC: HPCON) callconv(.winapi) void; pub extern "kernel32" fn InitializeProcThreadAttributeList( lpAttributeList: LPPROC_THREAD_ATTRIBUTE_LIST, dwAttributeCount: windows.DWORD, dwFlags: windows.DWORD, lpSize: *windows.SIZE_T, - ) callconv(windows.WINAPI) windows.BOOL; + ) callconv(.winapi) windows.BOOL; pub extern "kernel32" fn UpdateProcThreadAttribute( lpAttributeList: LPPROC_THREAD_ATTRIBUTE_LIST, dwFlags: windows.DWORD, @@ -77,7 +77,7 @@ pub const exp = struct { cbSize: windows.SIZE_T, lpPreviousValue: ?windows.PVOID, lpReturnSize: ?*windows.SIZE_T, - ) callconv(windows.WINAPI) windows.BOOL; + ) callconv(.winapi) windows.BOOL; pub extern "kernel32" fn PeekNamedPipe( hNamedPipe: windows.HANDLE, lpBuffer: ?windows.LPVOID, @@ -85,7 +85,7 @@ pub const exp = struct { lpBytesRead: ?*windows.DWORD, lpTotalBytesAvail: ?*windows.DWORD, lpBytesLeftThisMessage: ?*windows.DWORD, - ) callconv(windows.WINAPI) windows.BOOL; + ) callconv(.winapi) windows.BOOL; // Duplicated here because lpCommandLine is not marked optional in zig std pub extern "kernel32" fn CreateProcessW( lpApplicationName: ?windows.LPWSTR, @@ -98,7 +98,12 @@ pub const exp = struct { lpCurrentDirectory: ?windows.LPWSTR, lpStartupInfo: *windows.STARTUPINFOW, lpProcessInformation: *windows.PROCESS_INFORMATION, - ) callconv(windows.WINAPI) windows.BOOL; + ) callconv(.winapi) windows.BOOL; + /// https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getcomputernamea + pub extern "kernel32" fn GetComputerNameA( + lpBuffer: windows.LPSTR, + nSize: *windows.DWORD, + ) callconv(.winapi) windows.BOOL; }; pub const PROC_THREAD_ATTRIBUTE_NUMBER = 0x0000FFFF; diff --git a/src/os/xdg.zig b/src/os/xdg.zig index a813b0a98..f2a495af4 100644 --- a/src/os/xdg.zig +++ b/src/os/xdg.zig @@ -132,7 +132,7 @@ test { test "cache directory paths" { const testing = std.testing; const alloc = testing.allocator; - const mock_home = "/Users/test"; + const mock_home = if (builtin.os.tag == .windows) "C:\\Users\\test" else "/Users/test"; // Test when XDG_CACHE_HOME is not set { @@ -140,7 +140,9 @@ test "cache directory paths" { { const cache_path = try cache(alloc, .{ .home = mock_home }); defer alloc.free(cache_path); - try testing.expectEqualStrings("/Users/test/.cache", cache_path); + const expected = try std.fs.path.join(alloc, &.{ mock_home, ".cache" }); + defer alloc.free(expected); + try testing.expectEqualStrings(expected, cache_path); } // Test with subdir @@ -150,7 +152,9 @@ test "cache directory paths" { .subdir = "ghostty", }); defer alloc.free(cache_path); - try testing.expectEqualStrings("/Users/test/.cache/ghostty", cache_path); + const expected = try std.fs.path.join(alloc, &.{ mock_home, ".cache", "ghostty" }); + defer alloc.free(expected); + try testing.expectEqualStrings(expected, cache_path); } } } diff --git a/src/pty.c b/src/pty.c new file mode 100644 index 000000000..fe50d2b4b --- /dev/null +++ b/src/pty.c @@ -0,0 +1,40 @@ +#if defined(__FreeBSD__) + + #include <termios.h> // ioctl and constants + #include <libutil.h> // openpty + #include <stdlib.h> // ptsname_r + #include <unistd.h> // tcgetpgrp + +#elif defined(__linux__) + + #define _GNU_SOURCE // ptsname_r + #include <pty.h> // openpty + #include <stdlib.h> // ptsname_r + #include <sys/ioctl.h> // ioctl and constants + #include <unistd.h> // tcgetpgrp, setsid + +#elif defined(__APPLE__) + + #include <sys/ioctl.h> // ioctl and constants + #include <sys/ttycom.h> // ioctl and constants for TIOCPTYGNAME + #include <sys/types.h> + #include <unistd.h> // tcgetpgrp + #include <util.h> // openpty + + #ifndef tiocsctty + #define tiocsctty 536900705 + #endif + + #ifndef tiocswinsz + #define tiocswinsz 2148037735 + #endif + + #ifndef tiocgwinsz + #define tiocgwinsz 1074295912 + #endif + +#else + + #error "unsupported platform" + +#endif diff --git a/src/pty.zig b/src/pty.zig index 1ab88d40f..40277a8ba 100644 --- a/src/pty.zig +++ b/src/pty.zig @@ -2,6 +2,7 @@ const std = @import("std"); const builtin = @import("builtin"); const windows = @import("os/main.zig").windows; const posix = std.posix; +const assert = @import("quirks.zig").inlineAssert; const log = std.log.scoped(.pty); @@ -35,6 +36,21 @@ pub const Mode = packed struct { echo: bool = true, }; +pub const ProcessInfo = enum { + /// The PID of the process that controls the PTY. + foreground_pid, + /// Gets the name of the slave PTY. Returned name points to an internal buffer + /// so it should not be modified or freed. + tty_name, + + pub fn Type(comptime info: ProcessInfo) type { + return switch (info) { + .foreground_pid => u64, + .tty_name => [:0]const u8, + }; + } +}; + // A pty implementation that does nothing. // // TODO: This should be removed. This is only temporary until we have @@ -78,36 +94,24 @@ const NullPty = struct { pub fn childPreExec(self: Pty) ChildPreExecError!void { _ = self; } + + /// Get information about the process(es) attached to the PTY. Returns + /// `null` if there was an error getting the information or the information + /// is not available on a particular platform. + pub fn getProcessInfo(_: *Pty, comptime info: ProcessInfo) ?ProcessInfo.Type(info) { + return null; + } }; -/// Linux PTY creation and management. This is just a thin layer on top -/// of Linux syscalls. The caller is responsible for detail-oriented handling +/// Posix PTY creation and management. This is just a thin layer on top +/// of Posix syscalls. The caller is responsible for detail-oriented handling /// of the returned file handles. const PosixPty = struct { pub const Error = OpenError || GetModeError || GetSizeError || SetSizeError || ChildPreExecError; pub const Fd = posix.fd_t; - // https://github.com/ziglang/zig/issues/13277 - // Once above is fixed, use `c.TIOCSCTTY` - const TIOCSCTTY = if (builtin.os.tag == .macos) 536900705 else c.TIOCSCTTY; - const TIOCSWINSZ = if (builtin.os.tag == .macos) 2148037735 else c.TIOCSWINSZ; - const TIOCGWINSZ = if (builtin.os.tag == .macos) 1074295912 else c.TIOCGWINSZ; - extern "c" fn setsid() std.c.pid_t; - const c = switch (builtin.os.tag) { - .macos => @cImport({ - @cInclude("sys/ioctl.h"); // ioctl and constants - @cInclude("util.h"); // openpty() - }), - .freebsd => @cImport({ - @cInclude("termios.h"); // ioctl and constants - @cInclude("libutil.h"); // openpty() - }), - else => @cImport({ - @cInclude("sys/ioctl.h"); // ioctl and constants - @cInclude("pty.h"); - }), - }; + const c = @import("pty-c"); /// The file descriptors for the master and slave side of the pty. /// The slave side is never closed automatically by this struct @@ -116,6 +120,14 @@ const PosixPty = struct { master: Fd, slave: Fd, + /// Buffer for storage of slave tty name so that we don't have to recompute + /// it every time we need it. + tty_name_buf: [std.fs.max_path_bytes:0]u8 = undefined, + /// The name of slave tty. If `null` it has not yet been computed or + /// may not be available. Should not be accessed directly, but through + /// `self.getProcessInfo(.tty_name)` + tty_name: ?[:0]const u8 = null, + pub const OpenError = error{OpenptyFailed}; /// Open a new PTY with the given initial size. @@ -141,15 +153,15 @@ const PosixPty = struct { // Set CLOEXEC on the master fd, only the slave fd should be inherited // by the child process (shell/command). cloexec: { - const flags = std.posix.fcntl(master_fd, std.posix.F.GETFD, 0) catch |err| { + const flags = posix.fcntl(master_fd, posix.F.GETFD, 0) catch |err| { log.warn("error getting flags for master fd err={}", .{err}); break :cloexec; }; - _ = std.posix.fcntl( + _ = posix.fcntl( master_fd, - std.posix.F.SETFD, - flags | std.posix.FD_CLOEXEC, + posix.F.SETFD, + flags | posix.FD_CLOEXEC, ) catch |err| { log.warn("error setting CLOEXEC on master fd err={}", .{err}); break :cloexec; @@ -168,6 +180,8 @@ const PosixPty = struct { return .{ .master = master_fd, .slave = slave_fd, + .tty_name_buf = undefined, + .tty_name = null, }; } @@ -194,7 +208,7 @@ const PosixPty = struct { /// Return the size of the pty. pub fn getSize(self: Pty) GetSizeError!winsize { var ws: winsize = undefined; - if (c.ioctl(self.master, TIOCGWINSZ, @intFromPtr(&ws)) < 0) + if (c.ioctl(self.master, c.TIOCGWINSZ, @intFromPtr(&ws)) < 0) return error.IoctlFailed; return ws; @@ -204,7 +218,7 @@ const PosixPty = struct { /// Set the size of the pty. pub fn setSize(self: *Pty, size: winsize) SetSizeError!void { - if (c.ioctl(self.master, TIOCSWINSZ, @intFromPtr(&size)) < 0) + if (c.ioctl(self.master, c.TIOCSWINSZ, @intFromPtr(&size)) < 0) return error.IoctlFailed; } @@ -234,10 +248,10 @@ const PosixPty = struct { posix.sigaction(posix.SIG.QUIT, &sa, null); // Create a new process group - if (setsid() < 0) return error.ProcessGroupFailed; + if (c.setsid() < 0) return error.ProcessGroupFailed; // Set controlling terminal - switch (posix.errno(c.ioctl(self.slave, TIOCSCTTY, @as(c_ulong, 0)))) { + switch (posix.errno(c.ioctl(self.slave, c.TIOCSCTTY, @as(c_ulong, 0)))) { .SUCCESS => {}, else => |err| { log.err("error setting controlling terminal errno={}", .{err}); @@ -249,6 +263,62 @@ const PosixPty = struct { posix.close(self.slave); posix.close(self.master); } + + /// Get information about the process(es) attached to the PTY. Returns + /// `null` if there was an error getting the information or the information + /// is not available on a particular platform. + pub fn getProcessInfo(self: *PosixPty, comptime info: ProcessInfo) ?ProcessInfo.Type(info) { + return switch (info) { + .foreground_pid => { + switch (builtin.os.tag) { + .linux => { + const linux = std.os.linux; + var pgrp: i32 = undefined; + const rc = linux.tcgetpgrp(self.master, &pgrp); + switch (linux.E.init(rc)) { + .SUCCESS => return @intCast(pgrp), + else => return null, + } + }, + else => { + const rc = c.tcgetpgrp(self.master); + if (rc < 0) return null; + return @intCast(rc); + }, + } + }, + .tty_name => { + if (self.tty_name) |tty_name| return tty_name; + + switch (builtin.os.tag) { + .macos => { + // The macOS TIOCPTYGNAME ioctl does not allow us to + // specify the length of the buffer passed to it, but + // expects it to be at least 128 bytes long. + assert(self.tty_name_buf.len >= 128); + switch (posix.errno(c.ioctl(self.master, c.TIOCPTYGNAME, @intFromPtr(&self.tty_name_buf)))) { + .SUCCESS => { + const tty_name: [:0]const u8 = std.mem.sliceTo(&self.tty_name_buf, 0); + self.tty_name = tty_name; + return tty_name; + }, + else => |err| { + log.err("error getting name of slave PTY errno={t}", .{err}); + return null; + }, + } + }, + .linux => { + if (c.ptsname_r(self.master, &self.tty_name_buf, self.tty_name_buf.len) != 0) return null; + const tty_name: [:0]const u8 = std.mem.sliceTo(&self.tty_name_buf, 0); + self.tty_name = tty_name; + return tty_name; + }, + else => return null, + } + }, + }; + } }; /// Windows PTY creation and management. @@ -398,6 +468,13 @@ const WindowsPty = struct { if (result != windows.S_OK) return error.ResizeFailed; self.size = size; } + + /// Get information about the process(es) attached to the PTY. Returns + /// `null` if there was an error getting the information or the information + /// is not available on a particular platform. + pub fn getProcessInfo(_: *WindowsPty, comptime info: ProcessInfo) ?ProcessInfo.Type(info) { + return null; + } }; test { @@ -419,4 +496,11 @@ test { ws.ws_row *= 2; try pty.setSize(ws); try testing.expectEqual(ws, try pty.getSize()); + + switch (builtin.os.tag) { + .freebsd => try testing.expect(std.mem.startsWith(u8, pty.getProcessInfo(.tty_name).?, "/dev/")), + .linux => try testing.expect(std.mem.startsWith(u8, pty.getProcessInfo(.tty_name).?, "/dev/pts/")), + .macos => try testing.expect(std.mem.startsWith(u8, pty.getProcessInfo(.tty_name).?, "/dev/")), + else => try testing.expect(pty.getProcessInfo(.tty_name) == null), + } } diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 5ea5b7ab0..196ebb175 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -528,7 +528,7 @@ test "Cell constraint widths" { // symbol->nothing: 2 { t.fullReset(); - try s.nextSlice(""); + s.nextSlice(""); try state.update(alloc, &t); try testing.expectEqual(2, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -540,7 +540,7 @@ test "Cell constraint widths" { // symbol->character: 1 { t.fullReset(); - try s.nextSlice("z"); + s.nextSlice("z"); try state.update(alloc, &t); try testing.expectEqual(1, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -552,7 +552,7 @@ test "Cell constraint widths" { // symbol->space: 2 { t.fullReset(); - try s.nextSlice(" z"); + s.nextSlice(" z"); try state.update(alloc, &t); try testing.expectEqual(2, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -563,7 +563,7 @@ test "Cell constraint widths" { // symbol->no-break space: 1 { t.fullReset(); - try s.nextSlice("\u{00a0}z"); + s.nextSlice("\u{00a0}z"); try state.update(alloc, &t); try testing.expectEqual(1, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -575,7 +575,7 @@ test "Cell constraint widths" { // symbol->end of row: 1 { t.fullReset(); - try s.nextSlice(" "); + s.nextSlice(" "); try state.update(alloc, &t); try testing.expectEqual(1, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -587,7 +587,7 @@ test "Cell constraint widths" { // character->symbol: 2 { t.fullReset(); - try s.nextSlice("z"); + s.nextSlice("z"); try state.update(alloc, &t); try testing.expectEqual(2, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -599,7 +599,7 @@ test "Cell constraint widths" { // symbol->symbol: 1,1 { t.fullReset(); - try s.nextSlice(""); + s.nextSlice(""); try state.update(alloc, &t); try testing.expectEqual(1, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -616,7 +616,7 @@ test "Cell constraint widths" { // symbol->space->symbol: 2,2 { t.fullReset(); - try s.nextSlice(" "); + s.nextSlice(" "); try state.update(alloc, &t); try testing.expectEqual(2, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -633,7 +633,7 @@ test "Cell constraint widths" { // symbol->powerline: 1 (dedicated test because powerline is special-cased in cellpkg) { t.fullReset(); - try s.nextSlice(""); + s.nextSlice(""); try state.update(alloc, &t); try testing.expectEqual(1, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -645,7 +645,7 @@ test "Cell constraint widths" { // powerline->symbol: 2 (dedicated test because powerline is special-cased in cellpkg) { t.fullReset(); - try s.nextSlice(""); + s.nextSlice(""); try state.update(alloc, &t); try testing.expectEqual(2, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -657,7 +657,7 @@ test "Cell constraint widths" { // powerline->nothing: 2 (dedicated test because powerline is special-cased in cellpkg) { t.fullReset(); - try s.nextSlice(""); + s.nextSlice(""); try state.update(alloc, &t); try testing.expectEqual(2, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -669,7 +669,7 @@ test "Cell constraint widths" { // powerline->space: 2 (dedicated test because powerline is special-cased in cellpkg) { t.fullReset(); - try s.nextSlice(" z"); + s.nextSlice(" z"); try state.update(alloc, &t); try testing.expectEqual(2, constraintWidth( state.row_data.get(0).cells.items(.raw), diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index ff632f64a..0f4a294bc 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -554,7 +554,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { search_foreground: configpkg.Config.TerminalColor, search_selected_background: configpkg.Config.TerminalColor, search_selected_foreground: configpkg.Config.TerminalColor, - bold_color: ?configpkg.BoldColor, + bold_color: ?terminal.Style.BoldColor, faint_opacity: u8, min_contrast: f32, padding_color: configpkg.WindowPaddingColor, @@ -619,7 +619,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .background = config.background.toTerminalRGB(), .foreground = config.foreground.toTerminalRGB(), - .bold_color = config.@"bold-color", + .bold_color = if (config.@"bold-color") |b| b.toTerminal() else null, .faint_opacity = @intFromFloat(@ceil(config.@"faint-opacity" * 255)), .min_contrast = @floatCast(config.@"minimum-contrast"), diff --git a/src/renderer/link.zig b/src/renderer/link.zig index 74df3e596..c5de61574 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -148,7 +148,7 @@ test "renderCellMap" { var s = t.vtStream(); defer s.deinit(); const str = "1ABCD2EFGH\r\n3IJKL"; - try s.nextSlice(str); + s.nextSlice(str); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -201,7 +201,7 @@ test "renderCellMap hover links" { var s = t.vtStream(); defer s.deinit(); const str = "1ABCD2EFGH\r\n3IJKL"; - try s.nextSlice(str); + s.nextSlice(str); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -279,7 +279,7 @@ test "renderCellMap mods no match" { var s = t.vtStream(); defer s.deinit(); const str = "1ABCD2EFGH\r\n3IJKL"; - try s.nextSlice(str); + s.nextSlice(str); var state: terminal.RenderState = .empty; defer state.deinit(alloc); diff --git a/src/renderer/size.zig b/src/renderer/size.zig index d8b529c26..6cf05f58a 100644 --- a/src/renderer/size.zig +++ b/src/renderer/size.zig @@ -1,10 +1,22 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const font = @import("../font/main.zig"); -const terminal = @import("../terminal/main.zig"); +const terminal_size = @import("../terminal/size.zig"); const log = std.log.scoped(.renderer_size); +/// Controls how extra whitespace around the terminal grid is distributed. +pub const PaddingBalance = enum { + /// No balancing; padding is applied as specified explicitly. + false, + /// Balances padding but caps the top padding so the first row doesn't + /// drift too far from the top of the window. Excess vertical space is + /// shifted to the bottom. + true, + /// Distributes leftover space equally on all sides so the grid is + /// centered within the screen. + equal, +}; + /// All relevant sizes for a rendered terminal. These are all the sizes that /// any functionality should need to know about the terminal in order to /// convert between any coordinate systems. @@ -34,7 +46,11 @@ pub const Size = struct { /// Set the padding to be balanced around the grid. The balanced /// padding is calculated AFTER the explicit padding is taken /// into account. - pub fn balancePadding(self: *Size, explicit: Padding) void { + pub fn balancePadding( + self: *Size, + explicit: Padding, + mode: PaddingBalance, + ) void { // This ensure grid() does the right thing self.padding = explicit; @@ -45,14 +61,20 @@ pub const Size = struct { self.cell, ); - // The top/bottom padding is interesting. Subjectively, lots of padding - // at the top looks bad. So instead of always being equal (like left/right), - // we force the top padding to be at most equal to the maximum left padding, - // which is the balanced explicit horizontal padding plus half a cell width. - const max_padding_left = (explicit.left + explicit.right + self.cell.width) / 2; - const vshift = self.padding.top -| max_padding_left; - self.padding.top -= vshift; - self.padding.bottom += vshift; + switch (mode) { + .false => unreachable, + .equal => {}, + .true => { + // Cap the top padding to avoid excessive space above the + // first row. The maximum is the balanced explicit horizontal + // padding plus half a cell width. Any excess is shifted to + // the bottom. + const max_top = (explicit.left + explicit.right + self.cell.width) / 2; + const vshift = self.padding.top -| max_top; + self.padding.top -= vshift; + self.padding.bottom += vshift; + }, + } } }; @@ -161,7 +183,7 @@ pub const Coordinate = union(enum) { /// /// The units for the width and height are in world space. They have to /// be normalized for any renderer implementation. -pub const CellSize = struct { +pub const CellSize = extern struct { width: u32, height: u32, }; @@ -169,7 +191,7 @@ pub const CellSize = struct { /// The dimensions of the screen that the grid is rendered to. This is the /// terminal screen, so it is likely a subset of the window size. The dimensions /// should be in pixels. -pub const ScreenSize = struct { +pub const ScreenSize = extern struct { width: u32, height: u32, @@ -213,8 +235,8 @@ pub const ScreenSize = struct { }; /// The dimensions of the grid itself, in rows/columns units. -pub const GridSize = struct { - pub const Unit = terminal.size.CellCountInt; +pub const GridSize = extern struct { + pub const Unit = terminal_size.CellCountInt; columns: Unit = 0, rows: Unit = 0, @@ -246,7 +268,7 @@ pub const GridSize = struct { }; /// The padding to add to a screen. -pub const Padding = struct { +pub const Padding = extern struct { top: u32 = 0, bottom: u32 = 0, right: u32 = 0, @@ -302,6 +324,45 @@ pub const Padding = struct { } }; +test "Size.balancePadding equal distributes whitespace equally" { + const testing = std.testing; + + // screen=1050x850, cell=10x20, explicit=4 each side + // grid: (1050-8)/10=104 cols, (850-8)/20=42 rows + // leftover: 1050-1040=10 horizontal, 850-840=10 vertical + // balanced: left=right=5, top=bottom=5 + var size: Size = .{ + .screen = .{ .width = 1050, .height = 850 }, + .cell = .{ .width = 10, .height = 20 }, + .padding = .{}, + }; + size.balancePadding(.{ .top = 4, .bottom = 4, .left = 4, .right = 4 }, .equal); + try testing.expectEqual(size.padding.left, size.padding.right); + try testing.expectEqual(size.padding.top, size.padding.bottom); + try testing.expect(size.padding.top > 0); +} + +test "Size.balancePadding true shifts excess top to bottom" { + const testing = std.testing; + + // screen=1090x1070, cell=20x40, explicit=0 + // grid: 1090/20=54 cols, 1070/40=26 rows + // leftover: 1090-1080=10, 1070-1040=30 + // balanced: left=right=5, top=bottom=15 + // vshift cap: (0+0+20)/2=10, vshift=15-10=5 + // result: top=10, bottom=20 + var size: Size = .{ + .screen = .{ .width = 1090, .height = 1070 }, + .cell = .{ .width = 20, .height = 40 }, + .padding = .{}, + }; + size.balancePadding(.{}, .true); + try testing.expectEqual(size.padding.left, size.padding.right); + try testing.expect(size.padding.top < size.padding.bottom); + try testing.expectEqual(@as(u32, 10), size.padding.top); + try testing.expectEqual(@as(u32, 20), size.padding.bottom); +} + test "Padding balanced on zero" { // On some systems, our screen can be zero-sized for a bit, and we // don't want to end up with negative padding. diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 49d8de450..7eaf1397b 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -123,8 +123,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then # Configure environment variables for remote session if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then - ssh_opts+=(-o "SetEnv COLORTERM=truecolor") - ssh_opts+=(-o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION") + ssh_opts+=(-o "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION") fi # Install terminfo on remote host if needed @@ -180,7 +179,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then fi # Execute SSH with TERM environment variable - TERM="$ssh_term" builtin command ssh "${ssh_opts[@]}" "$@" + TERM="$ssh_term" COLORTERM=truecolor builtin command ssh "${ssh_opts[@]}" "$@" } fi @@ -195,20 +194,22 @@ function __ghostty_precmd() { _GHOSTTY_SAVE_PS1="$PS1" _GHOSTTY_SAVE_PS2="$PS2" - # Marks. We need to do fresh line (A) at the beginning of the prompt - # since if the cursor is not at the beginning of a line, the terminal - # will emit a newline. - PS1='\[\e]133;A;redraw=last;cl=line;aid='"$BASHPID"'\a\]'$PS1'\[\e]133;B\a\]' - PS2='\[\e]133;A;k=s\a\]'$PS2'\[\e]133;B\a\]' + # Use 133;P (not 133;A) inside PS1 to avoid fresh-line behavior on + # readline redraws (e.g., vi mode switches, Ctrl-L). The initial + # 133;A with fresh-line is emitted once via printf below. + PS1='\[\e]133;P;k=i\a\]'$PS1'\[\e]133;B\a\]' + PS2='\[\e]133;P;k=s\a\]'$PS2'\[\e]133;B\a\]' - # Bash doesn't redraw the leading lines in a multiline prompt so - # we mark the start of each line (after each newline) as a secondary - # prompt. This correctly handles multiline prompts by setting the first - # to primary and the subsequent lines to secondary. - if [[ "${PS1}" == *"\n"* || "${PS1}" == *$'\n'* ]]; then - builtin local __ghostty_mark=$'\\[\\e]133;A;k=s\\a\\]' - PS1="${PS1//$'\n'/$'\n'$__ghostty_mark}" - PS1="${PS1//\\n/\\n$__ghostty_mark}" + # Bash doesn't redraw the leading lines in a multiline prompt so we mark + # the start of each line (after each newline) as a secondary prompt. This + # correctly handles multiline prompts by setting the first to primary and + # the subsequent lines to secondary. + # + # We only replace the \n prompt escape, not literal newlines ($'\n'), + # because literal newlines may appear inside $(...) command substitutions + # where inserting escape sequences would break shell syntax. + if [[ "$PS1" == *"\n"* ]]; then + PS1="${PS1//\\n/\\n$'\\[\\e]133;P;k=s\\a\\]'}" fi # Cursor @@ -231,6 +232,17 @@ function __ghostty_precmd() { builtin printf "\e]133;D;%s;aid=%s\a" "$ret" "$BASHPID" fi + # Fresh line and start of prompt. When ble.sh is active, emit 133;P instead + # of 133;A because ble.sh maintains its own cursor position tracking. 133;A's + # cursor movement (CR+LF when not at column 0) is invisible to ble.sh and + # desyncs its position state, causing display artifacts like duplicate + # prompts. See: https://github.com/akinomyoga/ble.sh/issues/684 + if [[ -n "${BLE_VERSION-}" ]]; then + builtin printf "\e]133;P;k=i\a" + else + builtin printf "\e]133;A;redraw=last;cl=line;aid=%s\a" "$BASHPID" + fi + # unfortunately bash provides no hooks to detect cwd changes # in particular this means cwd reporting will not happen for a # command like cd /test && cat. PS0 is evaluated before cd is run. @@ -266,38 +278,43 @@ if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) ) [[ -n "$cmd" ]] && __ghostty_preexec "$cmd" } - # Use function substitution in 5.3+. Otherwise, use command substitution. - # Any output (including escape sequences) goes to the terminal. - if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3) )); then - # shellcheck disable=SC2016 - builtin readonly __ghostty_ps0='${ __ghostty_preexec_hook; }' - else - # shellcheck disable=SC2016 - builtin readonly __ghostty_ps0='$(__ghostty_preexec_hook >/dev/tty)' - fi - __ghostty_hook() { builtin local ret=$? __ghostty_precmd "$ret" - if [[ "$PS0" != *"$__ghostty_ps0"* ]]; then - PS0=$PS0"${__ghostty_ps0}" + + # Append preexec hook to PS0 if not already present. + # Use function substitution in 5.3+, otherwise command substitution. + if [[ "$PS0" != *"__ghostty_preexec_hook"* ]]; then + if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3) )); then + # shellcheck disable=SC2016 + PS0+='${ __ghostty_preexec_hook; }' + else + # shellcheck disable=SC2016 + PS0+='$(__ghostty_preexec_hook >/dev/tty)' + fi fi } # Append our hook to PROMPT_COMMAND, preserving its existing type. - if [[ ";${PROMPT_COMMAND[*]:-};" != *";__ghostty_hook;"* ]]; then + # + # The 2>/dev/null suppresses "command not found" in subshells that inherit + # PROMPT_COMMAND without the function definition. This also silences any + # errors from inside __ghostty_hook itself, but those are all terminal escape + # sequences and non-actionable. + # + # shellcheck disable=SC2128,SC2178,SC2179 + if [[ ";${PROMPT_COMMAND[*]:-};" != *";__ghostty_hook 2>/dev/null;"* ]]; then if [[ -z "${PROMPT_COMMAND[*]}" ]]; then if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then - PROMPT_COMMAND=(__ghostty_hook) + PROMPT_COMMAND=("__ghostty_hook 2>/dev/null") else - # shellcheck disable=SC2178 - PROMPT_COMMAND="__ghostty_hook" + PROMPT_COMMAND="__ghostty_hook 2>/dev/null" fi elif [[ $(builtin declare -p PROMPT_COMMAND 2>/dev/null) == "declare -a "* ]]; then - PROMPT_COMMAND+=(__ghostty_hook) + PROMPT_COMMAND+=("__ghostty_hook 2>/dev/null") else - # shellcheck disable=SC2179 - PROMPT_COMMAND+="; __ghostty_hook" + [[ "${PROMPT_COMMAND}" =~ (\;[[:space:]]*|$'\n')$ ]] || PROMPT_COMMAND+=";" + PROMPT_COMMAND+="__ghostty_hook 2>/dev/null" fi fi else diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 776aab676..31ebf0941 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -11,15 +11,16 @@ # List of enabled shell integration features var features = [(str:split ',' $E:GHOSTTY_SHELL_FEATURES)] - # helper used by `mark-*` functions + # State tracking for semantic prompt sequences + # Values: 'prompt-start', 'pre-exec', 'post-exec' fn set-prompt-state {|new| set-env __ghostty_prompt_state $new } fn mark-prompt-start { - if (not-eq prompt-start (constantly $E:__ghostty_prompt_state)) { - printf "\e]133;D\a" + if (not-eq $E:__ghostty_prompt_state 'prompt-start') { + printf "\e]133;D;aid="$pid"\a" } set-prompt-state 'prompt-start' - printf "\e]133;A\a" + printf "\e]133;A;aid="$pid"\a" } fn mark-output-start {|_| @@ -44,9 +45,15 @@ } } - printf "\e]133;D;"$exit-status"\a" + printf "\e]133;D;"$exit-status";aid="$pid"\a" } + # NOTE: OSC 133;B (end of prompt, start of input) cannot be reliably + # implemented at the script level in Elvish. The prompt function's output is + # escaped, and writing to /dev/tty has timing issues because Elvish renders + # its prompts on a background thread. Full semantic prompt support requires a + # native implementation: https://github.com/elves/elvish/pull/1917 + fn sudo-with-terminfo {|@args| var sudoedit = $false for arg $args { @@ -76,8 +83,7 @@ # Configure environment variables for remote session if (has-value $features ssh-env) { set ssh-opts = (conj $ssh-opts ^ - -o "SetEnv COLORTERM=truecolor" ^ - -o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION") + -o "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION") } if (has-value $features ssh-terminfo) { @@ -141,7 +147,7 @@ } } - with [E:TERM = $ssh-term] { + with [E:TERM = $ssh-term E:COLORTERM = truecolor] { (external ssh) $@ssh-opts $@args } } diff --git a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish index 3f1f6099e..f8bfe0910 100644 --- a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +++ b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish @@ -129,8 +129,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" # Configure environment variables for remote session if contains ssh-env $features - set -a ssh_opts -o "SetEnv COLORTERM=truecolor" - set -a ssh_opts -o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION" + set -a ssh_opts -o "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION" end # Install terminfo on remote host if needed @@ -198,7 +197,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end # Execute SSH with TERM environment variable - TERM="$ssh_term" command ssh $ssh_opts $argv + TERM="$ssh_term" COLORTERM=truecolor command ssh $ssh_opts $argv end end diff --git a/src/shell-integration/nushell/vendor/autoload/ghostty.nu b/src/shell-integration/nushell/vendor/autoload/ghostty.nu index 78c981ebe..17970f513 100644 --- a/src/shell-integration/nushell/vendor/autoload/ghostty.nu +++ b/src/shell-integration/nushell/vendor/autoload/ghostty.nu @@ -12,9 +12,9 @@ export module ghostty { # `ssh-env`: use xterm-256color and propagate COLORTERM/TERM_PROGRAM vars if (has_feature "ssh-env") { $ssh_env.TERM = "xterm-256color" + $ssh_env.COLORTERM = "truecolor" $ssh_opts = [ - "-o" "SetEnv COLORTERM=truecolor" - "-o" "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION" + "-o" "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION" ] } diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 33a2c01d1..76c5ce246 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -131,32 +131,56 @@ _ghostty_deferred_init() { # SIGCHLD if notify is set. Themes that update prompt # asynchronously from a `zle -F` handler might still remove our # marks. Oh well. - builtin local mark2=$'%{\e]133;A;k=s\a%}' - builtin local markB=$'%{\e]133;B\a%}' - # Add marks conditionally to avoid a situation where we have - # several marks in place. These conditions can have false - # positives and false negatives though. - # - # - False positive (with prompt_percent): PS1="%(?.$mark1.)" - # - False negative (with prompt_subst): PS1='$mark1' - [[ $PS1 == *$mark1* ]] || PS1=${mark1}${PS1} - [[ $PS1 == *$markB* ]] || PS1=${PS1}${markB} - # Handle multiline prompts by marking newline-separated - # continuation lines with k=s (mark2). We skip the newline - # immediately after mark1 to avoid introducing a double - # newline due to OSC 133;A's fresh-line behavior. - if [[ $PS1 == ${mark1}$'\n'* ]]; then - builtin local rest=${PS1#${mark1}$'\n'} - if [[ $rest == *$'\n'* ]]; then - PS1=${mark1}$'\n'${rest//$'\n'/$'\n'${mark2}} + + # Restore PS1/PS2 to their pre-mark state if nothing else has + # modified them since we last added marks. This avoids exposing + # PS1 with our marks to other hooks (which can break themes like + # Pure that use pattern matching to strip/rebuild the prompt). + # If PS1 was modified (by a theme, async update, etc.), we + # keep the modified version, prioritizing the theme's changes. + builtin local ps1_changed=0 + if [[ -n ${_ghostty_saved_ps1+x} ]]; then + if [[ $PS1 == $_ghostty_marked_ps1 ]]; then + PS1=$_ghostty_saved_ps1 + PS2=$_ghostty_saved_ps2 + elif [[ $PS1 != $_ghostty_saved_ps1 ]]; then + ps1_changed=1 fi - elif [[ $PS1 == *$'\n'* ]]; then + fi + + # Save the clean PS1/PS2 before we add marks. + _ghostty_saved_ps1=$PS1 + _ghostty_saved_ps2=$PS2 + + # Add our marks. Since we always start from a clean PS1 + # (either restored above or freshly set by a theme), we can + # unconditionally add mark1 and markB. + builtin local mark2=$'%{\e]133;P;k=s\a%}' + builtin local markB=$'%{\e]133;B\a%}' + # If PS1 ends with a bare '%', it combines with the '{' + # in markB to form a '%{' prompt escape, swallowing the + # marker and producing a visible '{'. Fix by doubling the + # trailing '%' so it becomes a literal '%%'. + [[ $PS1 == *[^%]% || $PS1 == % ]] && PS1=$PS1% + PS1=${mark1}${PS1}${markB} + + # Handle multiline prompts by marking newline-separated + # continuation lines with k=s (mark2). + # + # We skip this when PS1 changed because injecting marks into + # newlines can break pattern matching in themes that + # strip/rebuild the prompt dynamically (e.g., Pure). + if (( ! ps1_changed )) && [[ $PS1 == *$'\n'* ]]; then PS1=${PS1//$'\n'/$'\n'${mark2}} fi # PS2 mark is needed when clearing the prompt on resize - [[ $PS2 == *$mark2* ]] || PS2=${mark2}${PS2} - [[ $PS2 == *$markB* ]] || PS2=${PS2}${markB} + [[ $PS2 == *[^%]% || $PS2 == % ]] && PS2=$PS2% + PS2=${mark2}${PS2}${markB} + + # Save the marked PS1 so we can detect modifications + # by other hooks in the next cycle. + _ghostty_marked_ps1=$PS1 (( _ghostty_state = 2 )) else # If our precmd hook is not the last, we cannot rely on prompt @@ -188,17 +212,14 @@ _ghostty_deferred_init() { _ghostty_preexec() { builtin emulate -L zsh -o no_warn_create_global -o no_aliases - # This can potentially break user prompt. Oh well. The robustness of - # this code can be improved in the case prompt_subst is set because - # it'll allow us distinguish (not perfectly but close enough) between - # our own prompt, user prompt, and our own prompt with user additions on - # top. We cannot force prompt_subst on the user though, so we would - # still need this code for the no_prompt_subst case. - PS1=${PS1//$'%{\e]133;A;cl=line\a%}'} - PS1=${PS1//$'%{\e]133;A;k=s\a%}'} - PS1=${PS1//$'%{\e]133;B\a%}'} - PS2=${PS2//$'%{\e]133;A;k=s\a%}'} - PS2=${PS2//$'%{\e]133;B\a%}'} + # Restore the original PS1/PS2 if nothing else has modified them + # since our precmd added marks. This ensures other preexec hooks + # see a clean PS1 without our marks. If PS1 was modified (e.g., + # by an async theme update), we leave it alone. + if [[ -n ${_ghostty_saved_ps1+x} && $PS1 == $_ghostty_marked_ps1 ]]; then + PS1=$_ghostty_saved_ps1 + PS2=$_ghostty_saved_ps2 + fi # This will work incorrectly in the presence of a preexec hook that # prints. For example, if MichaelAquilina/zsh-you-should-use installs @@ -268,7 +289,7 @@ _ghostty_deferred_init() { # Sudo if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* ]] && [[ -n "$TERMINFO" ]]; then # Wrap `sudo` command to ensure Ghostty terminfo is preserved - sudo() { + function sudo() { builtin local sudo_has_sudoedit_flags="no" for arg in "$@"; do # Check if argument is '-e' or '--edit' (sudoedit flags) @@ -291,7 +312,7 @@ _ghostty_deferred_init() { # SSH Integration if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then - ssh() { + function ssh() { emulate -L zsh setopt local_options no_glob_subst @@ -301,8 +322,7 @@ _ghostty_deferred_init() { # Configure environment variables for remote session if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then - ssh_opts+=(-o "SetEnv COLORTERM=truecolor") - ssh_opts+=(-o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION") + ssh_opts+=(-o "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION") fi # Install terminfo on remote host if needed @@ -358,7 +378,7 @@ _ghostty_deferred_init() { fi # Execute SSH with TERM environment variable - TERM="$ssh_term" command ssh "${ssh_opts[@]}" "$@" + TERM="$ssh_term" COLORTERM=truecolor command ssh "${ssh_opts[@]}" "$@" } fi @@ -419,7 +439,7 @@ _ghostty_deferred_init() { builtin typeset -ag precmd_functions if (( $+functions[_ghostty_precmd] )); then - precmd_functions=(${precmd_functions:/_ghostty_deferred_init/_ghostty_precmd}) + precmd_functions=(${precmd_functions:#_ghostty_deferred_init} _ghostty_precmd) _ghostty_precmd else precmd_functions=(${precmd_functions:#_ghostty_deferred_init}) diff --git a/src/simd/codepoint_width.cpp b/src/simd/codepoint_width.cpp index 4eb7da66d..294922c65 100644 --- a/src/simd/codepoint_width.cpp +++ b/src/simd/codepoint_width.cpp @@ -6,6 +6,7 @@ #include <hwy/print-inl.h> #include <cassert> +#include <iterator> HWY_BEFORE_NAMESPACE(); namespace ghostty { diff --git a/src/simd/index_of.h b/src/simd/index_of.h index 8c214d9d0..531af9f8f 100644 --- a/src/simd/index_of.h +++ b/src/simd/index_of.h @@ -6,6 +6,8 @@ #endif #include <hwy/highway.h> + +#include <cstddef> #include <optional> HWY_BEFORE_NAMESPACE(); diff --git a/src/surface_mouse.zig b/src/surface_mouse.zig index 691f1b23c..8fa53d240 100644 --- a/src/surface_mouse.zig +++ b/src/surface_mouse.zig @@ -9,14 +9,14 @@ const std = @import("std"); const builtin = @import("builtin"); const input = @import("input.zig"); const terminal = @import("terminal/main.zig"); -const MouseShape = @import("terminal/mouse_shape.zig").MouseShape; +const MouseShape = terminal.MouseShape; /// For processing key events; the key that was physically pressed on the /// keyboard. physical_key: input.Key, /// The mouse event tracking mode, if any. -mouse_event: terminal.Terminal.MouseEvents, +mouse_event: terminal.MouseEvent, /// The current terminal's mouse shape. mouse_shape: MouseShape, diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index b6d53beee..6e39428db 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2682,11 +2682,22 @@ fn scrollPrompt(self: *PageList, delta: isize) void { // delta so that we don't land back on our current viewport. const start_pin = start: { const tl = self.getTopLeft(.viewport); - const adjusted: ?Pin = if (delta > 0) - tl.down(1) - else - tl.up(1); - break :start adjusted orelse return; + + // If we're moving up we can just move the viewport up because + // promptIterator handles jumpting to the start of prompts. + if (delta <= 0) break :start tl.up(1) orelse return; + + // If we're moving down and we're presently at some kind of + // prompt, we need to skip all the continuation lines because + // promptIterator can't know if we're cutoff or continuing. + var adjusted: Pin = tl.down(1) orelse return; + if (tl.rowAndCell().row.semantic_prompt != .none) skip: { + while (adjusted.rowAndCell().row.semantic_prompt == .prompt_continuation) { + adjusted = adjusted.down(1) orelse break :skip; + } + } + + break :start adjusted; }; // Go through prompts delta times @@ -6866,6 +6877,55 @@ test "Screen: jump back one prompt" { } } +test "Screen: jump forward prompt skips multiline continuation" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, null); + defer s.deinit(); + try s.growRows(7); + + // Multiline prompt on rows 1-3. + { + const p = s.pin(.{ .screen = .{ .y = 1 } }).?; + p.rowAndCell().row.semantic_prompt = .prompt; + } + { + const p = s.pin(.{ .screen = .{ .y = 2 } }).?; + p.rowAndCell().row.semantic_prompt = .prompt_continuation; + } + { + const p = s.pin(.{ .screen = .{ .y = 3 } }).?; + p.rowAndCell().row.semantic_prompt = .prompt_continuation; + } + + // Next prompt after command output. + { + const p = s.pin(.{ .screen = .{ .y = 6 } }).?; + p.rowAndCell().row.semantic_prompt = .prompt; + } + + // Starting at the first prompt line should jump to the next prompt, + // not to continuation lines. + s.scroll(.{ .row = 1 }); + s.scroll(.{ .delta_prompt = 1 }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 6, + } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); + + // Starting in the middle of continuation lines should also jump to + // the next prompt. + s.scroll(.{ .row = 2 }); + s.scroll(.{ .delta_prompt = 1 }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 6, + } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); +} + test "PageList grow fit in capacity" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/ScreenSet.zig b/src/terminal/ScreenSet.zig index cbaa03f47..d0856da51 100644 --- a/src/terminal/ScreenSet.zig +++ b/src/terminal/ScreenSet.zig @@ -9,15 +9,16 @@ const ScreenSet = @This(); const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; +const lib = @import("lib.zig"); const testing = std.testing; const Allocator = std.mem.Allocator; const Screen = @import("Screen.zig"); /// The possible keys for screens in the screen set. -pub const Key = enum(u1) { - primary, - alternate, -}; +pub const Key = lib.Enum(lib.target, &.{ + "primary", + "alternate", +}); /// The key value of the currently active screen. Useful for simple /// comparisons, e.g. "is this screen the primary screen". diff --git a/src/terminal/StringMap.zig b/src/terminal/StringMap.zig index f7d88d1c8..18dd7b19c 100644 --- a/src/terminal/StringMap.zig +++ b/src/terminal/StringMap.zig @@ -11,6 +11,12 @@ const Screen = @import("Screen.zig"); const Pin = @import("PageList.zig").Pin; const Allocator = std.mem.Allocator; +// Retry budget for StringMap regex searches. +// +// Units are Oniguruma retry steps (internal backtracking/retry counter), +// not bytes/characters/time. +const oni_search_retry_limit = 100_000; + string: [:0]const u8, map: []Pin, @@ -44,11 +50,26 @@ pub const SearchIterator = struct { pub fn next(self: *SearchIterator) !?Match { if (self.offset >= self.map.string.len) return null; - var region = self.regex.search( + // Use per-search match params so we can bound regex retry steps + // (Oniguruma's internal backtracking work counter). + var match_param = try oni.MatchParam.init(); + defer match_param.deinit(); + try match_param.setRetryLimitInSearch(oni_search_retry_limit); + + var region = self.regex.searchWithParam( self.map.string[self.offset..], .{}, + &match_param, ) catch |err| switch (err) { - error.Mismatch => { + // Retry/stack-limit errors mean we hit our work budget and + // aborted matching. + // For iterator callers this is equivalent to "no further matches". + error.Mismatch, + error.RetryLimitInMatchOver, + error.RetryLimitInSearchOver, + error.MatchStackLimitOver, + error.SubexpCallLimitInSearchOver, + => { self.offset = self.map.string.len; return null; }, diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 323e4e97a..c09eb9981 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -5,6 +5,7 @@ const Terminal = @This(); const std = @import("std"); const build_options = @import("terminal_options"); +const lib = @import("lib.zig"); const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; @@ -22,9 +23,8 @@ const point = @import("point.zig"); const sgr = @import("sgr.zig"); const Tabstops = @import("Tabstops.zig"); const color = @import("color.zig"); -const mouse_shape_pkg = @import("mouse_shape.zig"); -const ReadonlyHandler = @import("stream_readonly.zig").Handler; -const ReadonlyStream = @import("stream_readonly.zig").Stream; +const mouse = @import("mouse.zig"); +const Stream = @import("stream_terminal.zig").Stream; const size = @import("size.zig"); const pagepkg = @import("page.zig"); @@ -65,6 +65,9 @@ scrolling_region: ScrollingRegion, /// The last reported pwd, if any. pwd: std.ArrayList(u8), +/// The title of the terminal as set by escape sequences (e.g. OSC 0/2). +title: std.ArrayList(u8), + /// The color state for this terminal. colors: Colors, @@ -76,7 +79,7 @@ previous_char: ?u21 = null, modes: modespkg.ModeState = .{}, /// The most recently set mouse shape for the terminal. -mouse_shape: mouse_shape_pkg.MouseShape = .text, +mouse_shape: mouse.Shape = .text, /// These are just a packed set of flags we may set on the terminal. flags: packed struct { @@ -93,8 +96,8 @@ flags: packed struct { /// set mode in modes. You can't get the right event/format to use /// based on modes alone because modes don't show you what order /// this was called so we have to track it separately. - mouse_event: MouseEvents = .none, - mouse_format: MouseFormat = .x10, + mouse_event: mouse.Event = .none, + mouse_format: mouse.Format = .x10, /// Set via the XTSHIFTESCAPE sequence. If true (XTSHIFTESCAPE = 1) /// then we want to capture the shift key for the mouse protocol @@ -164,31 +167,6 @@ pub const Dirty = packed struct { preedit: bool = false, }; -/// The event types that can be reported for mouse-related activities. -/// These are all mutually exclusive (hence in a single enum). -pub const MouseEvents = enum(u3) { - none = 0, - x10 = 1, // 9 - normal = 2, // 1000 - button = 3, // 1002 - any = 4, // 1003 - - /// Returns true if this event sends motion events. - pub fn motion(self: MouseEvents) bool { - return self == .button or self == .any; - } -}; - -/// The format of mouse events when enabled. -/// These are all mutually exclusive (hence in a single enum). -pub const MouseFormat = enum(u3) { - x10 = 0, - utf8 = 1, // 1005 - sgr = 2, // 1006 - urxvt = 3, // 1015 - sgr_pixels = 4, // 1016 -}; - /// Scrolling region is the area of the screen designated where scrolling /// occurs. When scrolling the screen, only this viewport is scrolled. pub const ScrollingRegion = struct { @@ -242,6 +220,7 @@ pub fn init( .right = cols - 1, }, .pwd = .empty, + .title = .empty, .colors = opts.colors, .modes = .{ .values = opts.default_modes, @@ -254,24 +233,35 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void { self.tabstops.deinit(alloc); self.screens.deinit(alloc); self.pwd.deinit(alloc); + self.title.deinit(alloc); self.* = undefined; } /// Return a terminal.Stream that can process VT streams and update this /// terminal state. The streams will only process read-only data that -/// modifies terminal state. Sequences that query or otherwise require -/// output will be ignored. -pub fn vtStream(self: *Terminal) ReadonlyStream { +/// modifies terminal state. +/// +/// Sequences that query or otherwise require output will be ignored. +/// If you want to handle side effects, use `vtHandler` and set the +/// effects field yourself, then initialize a stream. +/// +/// This must be deinitialized by the caller. +/// +/// Important: this creates a new stream each time with fresh parser state. +/// If you need to persist parser state across multiple writes (e.g. +/// 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()); } /// This is the handler-side only for vtStream. -pub fn vtHandler(self: *Terminal) ReadonlyHandler { +pub fn vtHandler(self: *Terminal) Stream.Handler { return .init(self); } /// The general allocator we should use for this terminal. -fn gpa(self: *Terminal) Allocator { +pub fn gpa(self: *Terminal) Allocator { return self.screens.active.alloc; } @@ -424,13 +414,19 @@ pub fn print(self: *Terminal, c: u21) !void { if (self.screens.active.cursor.x == right_limit - 1) { if (!self.modes.get(.wraparound)) return; - const prev_cp = prev.cell.content.codepoint; + // This path can write a spacer_head before printWrap + // which can trigger integrity violations so mark + // the wrap first to keep the intermediary state valid + // if we're wrapping. + const row_wrap = right_limit == self.cols; + if (row_wrap) self.screens.active.cursor.page_row.wrap = true; + const prev_cp = prev.cell.content.codepoint; if (prev.cell.hasGrapheme()) { // This is like printCell but without clearing the // grapheme data from the cell, so we can move it // later. - prev.cell.wide = if (right_limit == self.cols) .spacer_head else .narrow; + prev.cell.wide = if (row_wrap) .spacer_head else .narrow; prev.cell.content.codepoint = 0; try self.printWrap(); @@ -466,7 +462,7 @@ pub fn print(self: *Terminal, c: u21) !void { } else { self.printCell( 0, - if (right_limit == self.cols) .spacer_head else .narrow, + if (row_wrap) .spacer_head else .narrow, ); try self.printWrap(); self.printCell(prev_cp, .wide); @@ -1698,7 +1694,7 @@ pub fn scrollUp(self: *Terminal, count: usize) !void { } /// Options for scrolling the viewport of the terminal grid. -pub const ScrollViewport = union(enum) { +pub const ScrollViewport = union(Tag) { /// Scroll to the top of the scrollback top, @@ -1707,6 +1703,23 @@ pub const ScrollViewport = union(enum) { /// Scroll by some delta amount, up is negative. delta: isize, + + pub const Tag = lib.Enum(lib.target, &.{ + "top", + "bottom", + "delta", + }); + + const c_union = lib.TaggedUnion( + lib.target, + @This(), + // Padding: largest variant is isize (8 bytes on 64-bit). + // Use [2]u64 (16 bytes) for future expansion. + [2]u64, + ); + pub const C = c_union.C; + pub const CValue = c_union.CValue; + pub const cval = c_union.cval; }; /// Scroll the viewport of the terminal grid. @@ -2861,14 +2874,33 @@ pub fn resize( /// Set the pwd for the terminal. pub fn setPwd(self: *Terminal, pwd: []const u8) !void { self.pwd.clearRetainingCapacity(); - try self.pwd.appendSlice(self.gpa(), pwd); + if (pwd.len > 0) { + try self.pwd.appendSlice(self.gpa(), pwd); + try self.pwd.append(self.gpa(), 0); + } } /// Returns the pwd for the terminal, if any. The memory is owned by the /// Terminal and is not copied. It is safe until a reset or setPwd. -pub fn getPwd(self: *const Terminal) ?[]const u8 { +pub fn getPwd(self: *const Terminal) ?[:0]const u8 { if (self.pwd.items.len == 0) return null; - return self.pwd.items; + return self.pwd.items[0 .. self.pwd.items.len - 1 :0]; +} + +/// Set the title for the terminal, as set by escape sequences (e.g. OSC 0/2). +pub fn setTitle(self: *Terminal, t: []const u8) !void { + self.title.clearRetainingCapacity(); + if (t.len > 0) { + try self.title.appendSlice(self.gpa(), t); + try self.title.append(self.gpa(), 0); + } +} + +/// Returns the title for the terminal, if any. The memory is owned by the +/// Terminal and is not copied. It is safe until a reset or setTitle. +pub fn getTitle(self: *const Terminal) ?[:0]const u8 { + if (self.title.items.len == 0) return null; + return self.title.items[0 .. self.title.items.len - 1 :0]; } /// Switch to the given screen type (alternate or primary). @@ -3080,6 +3112,7 @@ pub fn fullReset(self: *Terminal) void { self.tabstops.reset(TABSTOP_INTERVAL); self.previous_char = null; self.pwd.clearRetainingCapacity(); + self.title.clearRetainingCapacity(); self.status_display = .main; self.scrolling_region = .{ .top = 0, @@ -4081,6 +4114,58 @@ test "Terminal: VS16 to make wide character on next line" { } } +test "Terminal: VS16 to make wide character on next line with hyperlink" { + // Regression test for the crash fixed in print's grapheme `.wide` path: + // writing a spacer_head at the screen edge before row.wrap was set. + var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering and activate a hyperlink so printCell + // calls cursorSetHyperlink (which runs page integrity checks). + t.modes.set(.grapheme_cluster, true); + try t.screens.active.startHyperlink("http://example.com", null); + + t.cursorRight(2); + try t.print('#'); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + + // Without the fix, this panicked with UnwrappedSpacerHead. + try t.print(0xFE0F); // VS16 to make wide + + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + { + // Previous cell turns into spacer_head and remains hyperlinked. + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + try testing.expect(cell.hyperlink); + try testing.expect(list_cell.row.wrap); + } + { + // '#' cell is now wide and still hyperlinked. + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, '#'), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expect(cell.hyperlink); + } + { + // spacer_tail inherits hyperlink as part of the same grapheme cell. + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + try testing.expect(cell.hyperlink); + } +} + test "Terminal: VS16 to make wide character with pending wrap" { var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 }); defer t.deinit(testing.allocator); diff --git a/src/terminal/ansi.zig b/src/terminal/ansi.zig index c9cd53666..4e777b7c6 100644 --- a/src/terminal/ansi.zig +++ b/src/terminal/ansi.zig @@ -1,6 +1,4 @@ -const build_options = @import("terminal_options"); -const lib = @import("../lib/main.zig"); -const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; +const lib = @import("lib.zig"); /// C0 (7-bit) control characters from ANSI. /// @@ -52,19 +50,9 @@ pub const RenditionAspect = enum(u16) { _, }; -/// The device attribute request type (ESC [ c). -pub const DeviceAttributeReq = lib.Enum( - lib_target, - &.{ - "primary", // Blank - "secondary", // > - "tertiary", // = - }, -); - /// Possible cursor styles (ESC [ q) pub const CursorStyle = lib.Enum( - lib_target, + lib.target, &.{ "default", "blinking_block", @@ -88,7 +76,7 @@ pub const StatusLineType = enum(u16) { /// The display to target for status updates (DECSASD). pub const StatusDisplay = lib.Enum( - lib_target, + lib.target, &.{ "main", "status_line", @@ -98,7 +86,7 @@ pub const StatusDisplay = lib.Enum( /// The possible modify key formats to ESC[>{a};{b}m /// Note: this is not complete, we should add more as we support more pub const ModifyKeyFormat = lib.Enum( - lib_target, + lib.target, &.{ "legacy", "cursor_keys", diff --git a/src/terminal/c/AGENTS.md b/src/terminal/c/AGENTS.md new file mode 100644 index 000000000..63f7fc6cc --- /dev/null +++ b/src/terminal/c/AGENTS.md @@ -0,0 +1,22 @@ +# libghostty-vt C API + +- C API must be designed with ABI compatibility in mind +- Zig tagged unions must be converted to C ABI compatible unions + via `lib.TaggedUnion`. +- Any functions must be updated all the way through from here to + `src/terminal/c/main.zig` to `src/lib_vt.zig` and the headers + in `include/ghostty/vt.h`. +- In `include/ghostty/vt.h`, always sort the header contents by: + (1) macros, (2) forward declarations, (3) types, (4) functions + +## ABI Compatibility + +- Prefer opaque pointers for long-lived objects, such as + `GhosttyTerminal`. +- Structs: + - May contain padding bytes if we're confident we'll never grow + beyond a certain size. + - May use the "sized struct" pattern: an `extern struct` with + `size: usize = @sizeOf(Self)` as the first field. In the C header, + callers use `GHOSTTY_INIT_SIZED` from `types.h` to zero-initialize and + set the size. diff --git a/src/terminal/c/allocator.zig b/src/terminal/c/allocator.zig new file mode 100644 index 000000000..ca698f3c8 --- /dev/null +++ b/src/terminal/c/allocator.zig @@ -0,0 +1,67 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; + +/// Allocate a buffer of `len` bytes using the given allocator +/// (or the default allocator if NULL). +/// +/// Returns a pointer to the allocated buffer, or NULL if the +/// allocation failed. +pub fn alloc( + alloc_: ?*const CAllocator, + len: usize, +) callconv(lib.calling_conv) ?[*]u8 { + const allocator = lib.alloc.default(alloc_); + const buf = allocator.alloc(u8, len) catch return null; + return buf.ptr; +} + +/// Free memory that was allocated by a libghostty-vt function. +/// +/// This must be used to free buffers returned by functions like +/// `format_alloc`. Pass the same allocator (or NULL for the default) +/// that was used for the allocation. +pub fn free( + alloc_: ?*const CAllocator, + ptr: ?[*]u8, + len: usize, +) callconv(lib.calling_conv) void { + const mem = ptr orelse return; + const allocator = lib.alloc.default(alloc_); + allocator.free(mem[0..len]); +} + +test "alloc returns non-null" { + const ptr = alloc(&lib.alloc.test_allocator, 16); + try testing.expect(ptr != null); + free(&lib.alloc.test_allocator, ptr, 16); +} + +test "alloc with null allocator" { + const ptr = alloc(null, 8); + try testing.expect(ptr != null); + free(null, ptr, 8); +} + +test "alloc zero length" { + const ptr = alloc(&lib.alloc.test_allocator, 0); + defer free(&lib.alloc.test_allocator, ptr, 0); +} + +test "free null pointer" { + free(&lib.alloc.test_allocator, null, 0); +} + +test "free allocated memory" { + const allocator = lib.alloc.default(&lib.alloc.test_allocator); + const mem = try allocator.alloc(u8, 16); + free(&lib.alloc.test_allocator, mem.ptr, mem.len); +} + +test "free with null allocator" { + // null allocator falls back to the default (test allocator in tests) + const allocator = lib.alloc.default(null); + const mem = try allocator.alloc(u8, 8); + free(null, mem.ptr, mem.len); +} diff --git a/src/terminal/c/build_info.zig b/src/terminal/c/build_info.zig new file mode 100644 index 000000000..5e55e50d5 --- /dev/null +++ b/src/terminal/c/build_info.zig @@ -0,0 +1,110 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const lib = @import("../lib.zig"); +const build_options = @import("terminal_options"); +const Result = @import("result.zig").Result; + +const log = std.log.scoped(.build_info_c); + +/// C: GhosttyOptimizeMode +pub const OptimizeMode = enum(c_int) { + debug = 0, + release_safe = 1, + release_small = 2, + release_fast = 3, +}; + +/// C: GhosttyBuildInfo +pub const BuildInfo = enum(c_int) { + invalid = 0, + simd = 1, + kitty_graphics = 2, + tmux_control_mode = 3, + optimize = 4, + + /// Output type expected for querying the data of the given kind. + pub fn OutType(comptime self: BuildInfo) type { + return switch (self) { + .invalid => void, + .simd, .kitty_graphics, .tmux_control_mode => bool, + .optimize => OptimizeMode, + }; + } +}; + +pub fn get( + data: BuildInfo, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(BuildInfo, @intFromEnum(data)) catch { + log.warn("build_info invalid data value={d}", .{@intFromEnum(data)}); + return .invalid_value; + }; + } + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| getTyped( + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn getTyped( + comptime data: BuildInfo, + out: *data.OutType(), +) Result { + switch (data) { + .invalid => return .invalid_value, + .simd => out.* = build_options.simd, + .kitty_graphics => out.* = build_options.kitty_graphics, + .tmux_control_mode => out.* = build_options.tmux_control_mode, + .optimize => out.* = switch (builtin.mode) { + .Debug => .debug, + .ReleaseSafe => .release_safe, + .ReleaseSmall => .release_small, + .ReleaseFast => .release_fast, + }, + } + + return .success; +} + +test "get simd" { + const testing = std.testing; + var value: bool = undefined; + try testing.expectEqual(Result.success, get(.simd, @ptrCast(&value))); + try testing.expectEqual(build_options.simd, value); +} + +test "get kitty_graphics" { + const testing = std.testing; + var value: bool = undefined; + try testing.expectEqual(Result.success, get(.kitty_graphics, @ptrCast(&value))); + try testing.expectEqual(build_options.kitty_graphics, value); +} + +test "get tmux_control_mode" { + const testing = std.testing; + var value: bool = undefined; + try testing.expectEqual(Result.success, get(.tmux_control_mode, @ptrCast(&value))); + try testing.expectEqual(build_options.tmux_control_mode, value); +} + +test "get optimize" { + const testing = std.testing; + var value: OptimizeMode = undefined; + try testing.expectEqual(Result.success, get(.optimize, @ptrCast(&value))); + try testing.expectEqual(switch (builtin.mode) { + .Debug => .debug, + .ReleaseSafe => .release_safe, + .ReleaseSmall => .release_small, + .ReleaseFast => .release_fast, + }, value); +} + +test "get invalid" { + try std.testing.expectEqual(Result.invalid_value, get(.invalid, null)); +} diff --git a/src/terminal/c/cell.zig b/src/terminal/c/cell.zig new file mode 100644 index 000000000..c9746da72 --- /dev/null +++ b/src/terminal/c/cell.zig @@ -0,0 +1,178 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const page = @import("../page.zig"); +const Cell = page.Cell; +const color = @import("../color.zig"); +const style_c = @import("style.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttyCell +pub const CCell = u64; + +/// C: GhosttyCellContentTag +pub const ContentTag = enum(c_int) { + codepoint = 0, + codepoint_grapheme = 1, + bg_color_palette = 2, + bg_color_rgb = 3, +}; + +/// C: GhosttyCellWide +pub const Wide = enum(c_int) { + narrow = 0, + wide = 1, + spacer_tail = 2, + spacer_head = 3, +}; + +/// C: GhosttyCellSemanticContent +pub const SemanticContent = enum(c_int) { + output = 0, + input = 1, + prompt = 2, +}; + +/// C: GhosttyCellData +pub const CellData = enum(c_int) { + invalid = 0, + + /// The codepoint of the cell (0 if empty or bg-color-only). + /// Output type: uint32_t * (stored as u21, zero-extended) + codepoint = 1, + + /// The content tag describing what kind of content is in the cell. + /// Output type: GhosttyCellContentTag * + content_tag = 2, + + /// The wide property of the cell. + /// Output type: GhosttyCellWide * + wide = 3, + + /// Whether the cell has text to render. + /// Output type: bool * + has_text = 4, + + /// Whether the cell has styling (non-default style). + /// Output type: bool * + has_styling = 5, + + /// The style ID for the cell (for use with style lookups). + /// Output type: uint16_t * + style_id = 6, + + /// Whether the cell has a hyperlink. + /// Output type: bool * + has_hyperlink = 7, + + /// Whether the cell is protected. + /// Output type: bool * + protected = 8, + + /// The semantic content type of the cell (from OSC 133). + /// Output type: GhosttyCellSemanticContent * + semantic_content = 9, + + /// The palette index for the cell's background color. + /// Only valid when content_tag is bg_color_palette. + /// Output type: GhosttyColorPaletteIndex * + color_palette = 10, + + /// The RGB value for the cell's background color. + /// Only valid when content_tag is bg_color_rgb. + /// Output type: GhosttyColorRgb * + color_rgb = 11, + + /// Output type expected for querying the data of the given kind. + pub fn OutType(comptime self: CellData) type { + return switch (self) { + .invalid => void, + .codepoint => u32, + .content_tag => ContentTag, + .wide => Wide, + .has_text, .has_styling, .has_hyperlink, .protected => bool, + .style_id => u16, + .semantic_content => SemanticContent, + .color_palette => u8, + .color_rgb => color.RGB.C, + }; + } +}; + +pub fn get( + cell_: CCell, + data: CellData, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(CellData, @intFromEnum(data)) catch { + return .invalid_value; + }; + } + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| getTyped( + cell_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn getTyped( + cell_: CCell, + comptime data: CellData, + out: *data.OutType(), +) Result { + const cell: Cell = @bitCast(cell_); + switch (data) { + .invalid => return .invalid_value, + .codepoint => out.* = @intCast(cell.codepoint()), + .content_tag => out.* = @enumFromInt(@intFromEnum(cell.content_tag)), + .wide => out.* = @enumFromInt(@intFromEnum(cell.wide)), + .has_text => out.* = cell.hasText(), + .has_styling => out.* = cell.hasStyling(), + .style_id => out.* = cell.style_id, + .has_hyperlink => out.* = cell.hyperlink, + .protected => out.* = cell.protected, + .semantic_content => out.* = @enumFromInt(@intFromEnum(cell.semantic_content)), + .color_palette => out.* = cell.content.color_palette, + .color_rgb => { + const rgb = cell.content.color_rgb; + out.* = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b }; + }, + } + + return .success; +} + +test "get codepoint" { + const cell: CCell = @bitCast(Cell.init('A')); + var cp: u32 = 0; + try testing.expectEqual(Result.success, get(cell, .codepoint, @ptrCast(&cp))); + try testing.expectEqual(@as(u32, 'A'), cp); +} + +test "get has_text" { + const cell: CCell = @bitCast(Cell.init('A')); + var has: bool = false; + try testing.expectEqual(Result.success, get(cell, .has_text, @ptrCast(&has))); + try testing.expect(has); +} + +test "get empty cell" { + const cell: CCell = @bitCast(Cell.init(0)); + var has: bool = true; + try testing.expectEqual(Result.success, get(cell, .has_text, @ptrCast(&has))); + try testing.expect(!has); +} + +test "get wide" { + var zig_cell = Cell.init('A'); + zig_cell.wide = .wide; + const cell: CCell = @bitCast(zig_cell); + var w: Wide = .narrow; + try testing.expectEqual(Result.success, get(cell, .wide, @ptrCast(&w))); + try testing.expectEqual(Wide.wide, w); +} diff --git a/src/terminal/c/color.zig b/src/terminal/c/color.zig index 199339706..3d244a19e 100644 --- a/src/terminal/c/color.zig +++ b/src/terminal/c/color.zig @@ -1,3 +1,4 @@ +const lib = @import("../lib.zig"); const color = @import("../color.zig"); pub fn rgb_get( @@ -5,7 +6,7 @@ pub fn rgb_get( r: *u8, g: *u8, b: *u8, -) callconv(.c) void { +) callconv(lib.calling_conv) void { r.* = c.r; g.* = c.g; b.* = c.b; diff --git a/src/terminal/c/focus.zig b/src/terminal/c/focus.zig new file mode 100644 index 000000000..2c53191db --- /dev/null +++ b/src/terminal/c/focus.zig @@ -0,0 +1,55 @@ +const std = @import("std"); +const lib = @import("../lib.zig"); +const terminal_focus = @import("../focus.zig"); +const Result = @import("result.zig").Result; + +pub fn encode( + event: terminal_focus.Event, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(lib.calling_conv) Result { + var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{}); + terminal_focus.encode(&writer, event) catch |err| switch (err) { + error.WriteFailed => { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + terminal_focus.encode(&discarding.writer, event) catch unreachable; + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + }; + + out_written.* = writer.end; + return .success; +} + +test "encode focus gained" { + var buf: [terminal_focus.max_encode_size]u8 = undefined; + var written: usize = 0; + const result = encode(.gained, &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[I", buf[0..written]); +} + +test "encode focus lost" { + var buf: [terminal_focus.max_encode_size]u8 = undefined; + var written: usize = 0; + const result = encode(.lost, &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[O", buf[0..written]); +} + +test "encode with insufficient buffer" { + var buf: [1]u8 = undefined; + var written: usize = 0; + const result = encode(.gained, &buf, buf.len, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expectEqual(terminal_focus.max_encode_size, written); +} + +test "encode with null buffer" { + var written: usize = 0; + const result = encode(.gained, null, 0, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expectEqual(terminal_focus.max_encode_size, written); +} diff --git a/src/terminal/c/formatter.zig b/src/terminal/c/formatter.zig new file mode 100644 index 000000000..11717bc22 --- /dev/null +++ b/src/terminal/c/formatter.zig @@ -0,0 +1,417 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const terminal_c = @import("terminal.zig"); +const ZigTerminal = @import("../Terminal.zig"); +const formatterpkg = @import("../formatter.zig"); +const Result = @import("result.zig").Result; + +/// Wrapper around formatter that tracks the allocator for C API usage. +const FormatterWrapper = struct { + kind: Kind, + alloc: std.mem.Allocator, + + const Kind = union(enum) { + terminal: formatterpkg.TerminalFormatter, + }; +}; + +/// C: GhosttyFormatter +pub const Formatter = ?*FormatterWrapper; + +/// C: GhosttyFormatterFormat +pub const Format = formatterpkg.Format; + +/// C: GhosttyFormatterScreenOptions +pub const ScreenOptions = extern struct { + /// C: GhosttyFormatterScreenExtra + pub const Extra = extern struct { + size: usize = @sizeOf(Extra), + cursor: bool, + style: bool, + hyperlink: bool, + protection: bool, + kitty_keyboard: bool, + charsets: bool, + + comptime { + for (std.meta.fieldNames(formatterpkg.ScreenFormatter.Extra)) |name| { + if (!@hasField(Extra, name)) + @compileError("ScreenOptions.Extra missing field: " ++ name); + } + } + + fn toZig(self: Extra) formatterpkg.ScreenFormatter.Extra { + return .{ + .cursor = self.cursor, + .style = self.style, + .hyperlink = self.hyperlink, + .protection = self.protection, + .kitty_keyboard = self.kitty_keyboard, + .charsets = self.charsets, + }; + } + }; +}; + +/// C: GhosttyFormatterTerminalOptions +pub const TerminalOptions = extern struct { + size: usize = @sizeOf(TerminalOptions), + emit: Format, + unwrap: bool, + trim: bool, + extra: Extra, + + /// C: GhosttyFormatterTerminalExtra + pub const Extra = extern struct { + size: usize = @sizeOf(Extra), + palette: bool, + modes: bool, + scrolling_region: bool, + tabstops: bool, + pwd: bool, + keyboard: bool, + screen: ScreenOptions.Extra, + + comptime { + for (std.meta.fieldNames(formatterpkg.TerminalFormatter.Extra)) |name| { + if (!@hasField(Extra, name)) + @compileError("TerminalOptions.Extra missing field: " ++ name); + } + } + + fn toZig(self: Extra) formatterpkg.TerminalFormatter.Extra { + return .{ + .palette = self.palette, + .modes = self.modes, + .scrolling_region = self.scrolling_region, + .tabstops = self.tabstops, + .pwd = self.pwd, + .keyboard = self.keyboard, + .screen = self.screen.toZig(), + }; + } + }; +}; + +pub fn terminal_new( + alloc_: ?*const CAllocator, + result: *Formatter, + terminal_: terminal_c.Terminal, + opts: TerminalOptions, +) callconv(lib.calling_conv) Result { + result.* = terminal_new_( + alloc_, + terminal_, + opts, + ) catch |err| { + result.* = null; + return switch (err) { + error.InvalidValue => .invalid_value, + error.OutOfMemory => .out_of_memory, + }; + }; + + return .success; +} + +fn terminal_new_( + alloc_: ?*const CAllocator, + terminal_: terminal_c.Terminal, + opts: TerminalOptions, +) error{ + InvalidValue, + OutOfMemory, +}!*FormatterWrapper { + const t: *ZigTerminal = (terminal_ orelse return error.InvalidValue).terminal; + + const alloc = lib.alloc.default(alloc_); + const ptr = alloc.create(FormatterWrapper) catch + return error.OutOfMemory; + errdefer alloc.destroy(ptr); + + var formatter: formatterpkg.TerminalFormatter = .init(t, .{ + .emit = opts.emit, + .unwrap = opts.unwrap, + .trim = opts.trim, + }); + formatter.extra = opts.extra.toZig(); + + ptr.* = .{ + .kind = .{ .terminal = formatter }, + .alloc = alloc, + }; + + return ptr; +} + +pub fn format_buf( + formatter_: Formatter, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(lib.calling_conv) Result { + const wrapper = formatter_ orelse return .invalid_value; + + var writer: std.Io.Writer = .fixed(if (out_) |out| + out[0..out_len] + else + &.{}); + + switch (wrapper.kind) { + .terminal => |*t| t.format(&writer) catch |err| switch (err) { + error.WriteFailed => { + // On write failed we always report how much + // space we actually needed. + var discarding: std.Io.Writer.Discarding = .init(&.{}); + t.format(&discarding.writer) catch unreachable; + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + }, + } + + out_written.* = writer.end; + return .success; +} + +pub fn format_alloc( + formatter_: Formatter, + alloc_: ?*const CAllocator, + out_ptr: *?[*]u8, + out_len: *usize, +) callconv(lib.calling_conv) Result { + const wrapper = formatter_ orelse return .invalid_value; + const alloc = lib.alloc.default(alloc_); + + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + + switch (wrapper.kind) { + .terminal => |*t| t.format(&aw.writer) catch return .out_of_memory, + } + + const buf = aw.toOwnedSlice() catch return .out_of_memory; + out_ptr.* = buf.ptr; + out_len.* = buf.len; + return .success; +} + +pub fn free(formatter_: Formatter) callconv(lib.calling_conv) void { + const wrapper = formatter_ orelse return; + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +test "terminal_new/free" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + try testing.expect(f != null); + free(f); +} + +test "terminal_new invalid_value on null terminal" { + var f: Formatter = null; + try testing.expectEqual(Result.invalid_value, terminal_new( + &lib.alloc.test_allocator, + &f, + null, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + try testing.expect(f == null); +} + +test "free null" { + free(null); +} + +test "format plain" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello", 5); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + defer free(f); + + var buf: [1024]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format_buf(f, &buf, buf.len, &written)); + try testing.expectEqualStrings("Hello", buf[0..written]); +} + +test "format reflects terminal changes" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello", 5); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + defer free(f); + + var buf: [1024]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format_buf(f, &buf, buf.len, &written)); + try testing.expectEqualStrings("Hello", buf[0..written]); + + // Write more data and re-format + terminal_c.vt_write(t, "\r\nWorld", 7); + + try testing.expectEqual(Result.success, format_buf(f, &buf, buf.len, &written)); + try testing.expectEqualStrings("Hello\nWorld", buf[0..written]); +} + +test "format null returns required size" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello", 5); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + defer free(f); + + // Pass null buffer to query required size + var required: usize = 0; + try testing.expectEqual(Result.out_of_space, format_buf(f, null, 0, &required)); + try testing.expect(required > 0); + + // Now allocate and format + var buf: [1024]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format_buf(f, &buf, buf.len, &written)); + try testing.expectEqual(required, written); +} + +test "format buffer too small" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello", 5); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + defer free(f); + + // Buffer too small + var buf: [2]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.out_of_space, format_buf(f, &buf, buf.len, &written)); + // written contains the required size + try testing.expectEqual(@as(usize, 5), written); +} + +test "format null formatter" { + var written: usize = 0; + try testing.expectEqual(Result.invalid_value, format_buf(null, null, 0, &written)); +} + +test "format vt" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Test", 4); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .vt, .unwrap = false, .trim = true, .extra = .{ .palette = true, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = true, .hyperlink = true, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + defer free(f); + + var buf: [65536]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format_buf(f, &buf, buf.len, &written)); + try testing.expect(written > 0); + try testing.expect(std.mem.indexOf(u8, buf[0..written], "Test") != null); +} + +test "format html" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Html", 4); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .html, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + defer free(f); + + var buf: [65536]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format_buf(f, &buf, buf.len, &written)); + try testing.expect(written > 0); + try testing.expect(std.mem.indexOf(u8, buf[0..written], "Html") != null); +} diff --git a/src/terminal/c/grid_ref.zig b/src/terminal/c/grid_ref.zig new file mode 100644 index 000000000..d029c5951 --- /dev/null +++ b/src/terminal/c/grid_ref.zig @@ -0,0 +1,156 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const page = @import("../page.zig"); +const PageList = @import("../PageList.zig"); +const size = @import("../size.zig"); +const stylepkg = @import("../style.zig"); +const cell_c = @import("cell.zig"); +const row_c = @import("row.zig"); +const style_c = @import("style.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttyGridRef +/// +/// A sized struct that holds a reference to a position in the terminal grid. +/// The ref points to a specific cell position within the terminal's +/// internal page structure. +pub const CGridRef = extern struct { + size: usize = @sizeOf(CGridRef), + node: ?*PageList.List.Node = null, + x: size.CellCountInt = 0, + y: size.CellCountInt = 0, + + pub fn fromPin(pin: PageList.Pin) CGridRef { + return .{ + .node = pin.node, + .x = pin.x, + .y = pin.y, + }; + } + + fn toPin(self: CGridRef) ?PageList.Pin { + return .{ + .node = self.node orelse return null, + .x = self.x, + .y = self.y, + }; + } +}; + +pub fn grid_ref_cell( + ref: *const CGridRef, + out: ?*cell_c.CCell, +) callconv(lib.calling_conv) Result { + const p = ref.toPin() orelse return .invalid_value; + if (out) |o| o.* = @bitCast(p.rowAndCell().cell.*); + return .success; +} + +pub fn grid_ref_row( + ref: *const CGridRef, + out: ?*row_c.CRow, +) callconv(lib.calling_conv) Result { + const p = ref.toPin() orelse return .invalid_value; + if (out) |o| o.* = @bitCast(p.rowAndCell().row.*); + return .success; +} + +pub fn grid_ref_graphemes( + ref: *const CGridRef, + out_buf: ?[*]u32, + buf_len: usize, + out_len: *usize, +) callconv(lib.calling_conv) Result { + const p = ref.toPin() orelse return .invalid_value; + const cell = p.rowAndCell().cell; + + if (!cell.hasText()) { + out_len.* = 0; + return .success; + } + + const cp = cell.codepoint(); + const extra = if (cell.hasGrapheme()) p.grapheme(cell) else null; + const total = 1 + if (extra) |e| e.len else 0; + + if (out_buf == null or buf_len < total) { + out_len.* = total; + return .out_of_space; + } + + const buf = out_buf.?[0..buf_len]; + buf[0] = cp; + if (extra) |e| for (e, 1..) |c, i| { + buf[i] = c; + }; + + out_len.* = total; + return .success; +} + +pub fn grid_ref_style( + ref: *const CGridRef, + out: ?*style_c.Style, +) callconv(lib.calling_conv) Result { + const p = ref.toPin() orelse return .invalid_value; + if (out) |o| { + const cell = p.rowAndCell().cell; + if (cell.style_id == stylepkg.default_id) { + o.* = .fromStyle(.{}); + } else { + o.* = .fromStyle(p.node.data.styles.get( + p.node.data.memory, + cell.style_id, + ).*); + } + } + return .success; +} + +test "grid_ref_cell null node" { + const ref = CGridRef{}; + var out: cell_c.CCell = undefined; + try testing.expectEqual(Result.invalid_value, grid_ref_cell(&ref, &out)); +} + +test "grid_ref_row null node" { + const ref = CGridRef{}; + var out: row_c.CRow = undefined; + try testing.expectEqual(Result.invalid_value, grid_ref_row(&ref, &out)); +} + +test "grid_ref_cell null out" { + const ref = CGridRef{}; + try testing.expectEqual(Result.invalid_value, grid_ref_cell(&ref, null)); +} + +test "grid_ref_row null out" { + const ref = CGridRef{}; + try testing.expectEqual(Result.invalid_value, grid_ref_row(&ref, null)); +} + +test "grid_ref_graphemes null node" { + const ref = CGridRef{}; + var len: usize = undefined; + try testing.expectEqual(Result.invalid_value, grid_ref_graphemes(&ref, null, 0, &len)); +} + +test "grid_ref_graphemes null buf returns out_of_space" { + const ref = CGridRef{}; + var len: usize = undefined; + // With null node this returns invalid_value before checking the buffer, + // so we can only test null node here. Full buffer tests require a real page. + try testing.expectEqual(Result.invalid_value, grid_ref_graphemes(&ref, null, 0, &len)); +} + +test "grid_ref_style null node" { + const ref = CGridRef{}; + var out: style_c.Style = undefined; + try testing.expectEqual(Result.invalid_value, grid_ref_style(&ref, &out)); +} + +test "grid_ref_style null out" { + const ref = CGridRef{}; + try testing.expectEqual(Result.invalid_value, grid_ref_style(&ref, null)); +} diff --git a/src/terminal/c/key_encode.zig b/src/terminal/c/key_encode.zig index 063cd8df7..15fa74dd8 100644 --- a/src/terminal/c/key_encode.zig +++ b/src/terminal/c/key_encode.zig @@ -1,13 +1,15 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const lib_alloc = @import("../../lib/allocator.zig"); -const CAllocator = lib_alloc.Allocator; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; const key_encode = @import("../../input/key_encode.zig"); const key_event = @import("key_event.zig"); const KittyFlags = @import("../../terminal/kitty/key.zig").Flags; const OptionAsAlt = @import("../../input/config.zig").OptionAsAlt; const Result = @import("result.zig").Result; const KeyEvent = @import("key_event.zig").Event; +const Terminal = @import("terminal.zig").Terminal; +const ZigTerminal = @import("../Terminal.zig"); const log = std.log.scoped(.key_encode); @@ -23,8 +25,8 @@ pub const Encoder = ?*KeyEncoderWrapper; pub fn new( alloc_: ?*const CAllocator, result: *Encoder, -) callconv(.c) Result { - const alloc = lib_alloc.default(alloc_); +) callconv(lib.calling_conv) Result { + const alloc = lib.alloc.default(alloc_); const ptr = alloc.create(KeyEncoderWrapper) catch return .out_of_memory; ptr.* = .{ @@ -35,7 +37,7 @@ pub fn new( return .success; } -pub fn free(encoder_: Encoder) callconv(.c) void { +pub fn free(encoder_: Encoder) callconv(lib.calling_conv) void { const wrapper = encoder_ orelse return; const alloc = wrapper.alloc; alloc.destroy(wrapper); @@ -70,7 +72,7 @@ pub fn setopt( encoder_: Encoder, option: Option, value: ?*const anyopaque, -) callconv(.c) void { +) callconv(lib.calling_conv) void { if (comptime std.debug.runtime_safety) { _ = std.meta.intToEnum(Option, @intFromEnum(option)) catch { log.warn("setopt invalid option value={d}", .{@intFromEnum(option)}); @@ -115,13 +117,22 @@ fn setoptTyped( } } +pub fn setopt_from_terminal( + encoder_: Encoder, + terminal_: Terminal, +) callconv(lib.calling_conv) void { + const wrapper = encoder_ orelse return; + const t: *ZigTerminal = (terminal_ orelse return).terminal; + wrapper.opts = .fromTerminal(t); +} + pub fn encode( encoder_: Encoder, event_: KeyEvent, out_: ?[*]u8, out_len: usize, out_written: *usize, -) callconv(.c) Result { +) callconv(lib.calling_conv) Result { // Attempt to write to this buffer var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{}); key_encode.encode( @@ -142,7 +153,7 @@ pub fn encode( // Discarding always uses a u64. If we're on 32-bit systems // we cast down. We should make this safer in the future. out_written.* = @intCast(discarding.count); - return .out_of_memory; + return .out_of_space; }, }; @@ -154,7 +165,7 @@ test "alloc" { const testing = std.testing; var e: Encoder = undefined; try testing.expectEqual(Result.success, new( - &lib_alloc.test_allocator, + &lib.alloc.test_allocator, &e, )); free(e); @@ -164,7 +175,7 @@ test "setopt bool" { const testing = std.testing; var e: Encoder = undefined; try testing.expectEqual(Result.success, new( - &lib_alloc.test_allocator, + &lib.alloc.test_allocator, &e, )); defer free(e); @@ -186,7 +197,7 @@ test "setopt kitty flags" { const testing = std.testing; var e: Encoder = undefined; try testing.expectEqual(Result.success, new( - &lib_alloc.test_allocator, + &lib.alloc.test_allocator, &e, )); defer free(e); @@ -207,7 +218,7 @@ test "setopt macos option as alt" { const testing = std.testing; var e: Encoder = undefined; try testing.expectEqual(Result.success, new( - &lib_alloc.test_allocator, + &lib.alloc.test_allocator, &e, )); defer free(e); @@ -222,13 +233,71 @@ test "setopt macos option as alt" { try testing.expectEqual(OptionAsAlt.true, e.?.opts.macos_option_as_alt); } +test "setopt_from_terminal" { + const testing = std.testing; + const terminal_c = @import("terminal.zig"); + + // Create encoder + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + defer free(e); + + // Create terminal + var t: Terminal = undefined; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // Apply terminal state to encoder + setopt_from_terminal(e, t); + + // Options should reflect defaults from a fresh terminal + try testing.expect(!e.?.opts.cursor_key_application); + try testing.expect(e.?.opts.alt_esc_prefix); + try testing.expectEqual(KittyFlags.disabled, e.?.opts.kitty_flags); + try testing.expectEqual(OptionAsAlt.false, e.?.opts.macos_option_as_alt); +} + +test "setopt_from_terminal null" { + // Both null should be no-ops + setopt_from_terminal(null, null); + + const testing = std.testing; + + // Encoder null with valid terminal + const terminal_c = @import("terminal.zig"); + var t: Terminal = undefined; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + setopt_from_terminal(null, t); + + // Valid encoder with null terminal + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + defer free(e); + setopt_from_terminal(e, null); +} + test "encode: kitty ctrl release with ctrl mod set" { const testing = std.testing; // Create encoder var encoder: Encoder = undefined; try testing.expectEqual(Result.success, new( - &lib_alloc.test_allocator, + &lib.alloc.test_allocator, &encoder, )); defer free(encoder); @@ -249,7 +318,7 @@ test "encode: kitty ctrl release with ctrl mod set" { // Create key event var event: key_event.Event = undefined; try testing.expectEqual(Result.success, key_event.new( - &lib_alloc.test_allocator, + &lib.alloc.test_allocator, &event, )); defer key_event.free(event); @@ -261,7 +330,7 @@ test "encode: kitty ctrl release with ctrl mod set" { // Encode null should give us the length required var required: usize = 0; - try testing.expectEqual(Result.out_of_memory, encode( + try testing.expectEqual(Result.out_of_space, encode( encoder, event, null, diff --git a/src/terminal/c/key_event.zig b/src/terminal/c/key_event.zig index 748b8799c..1feac9ac5 100644 --- a/src/terminal/c/key_event.zig +++ b/src/terminal/c/key_event.zig @@ -1,7 +1,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const lib_alloc = @import("../../lib/allocator.zig"); -const CAllocator = lib_alloc.Allocator; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; const key = @import("../../input/key.zig"); const Result = @import("result.zig").Result; @@ -21,8 +21,8 @@ pub const Event = ?*KeyEventWrapper; pub fn new( alloc_: ?*const CAllocator, result: *Event, -) callconv(.c) Result { - const alloc = lib_alloc.default(alloc_); +) callconv(lib.calling_conv) Result { + const alloc = lib.alloc.default(alloc_); const ptr = alloc.create(KeyEventWrapper) catch return .out_of_memory; ptr.* = .{ .alloc = alloc }; @@ -30,13 +30,13 @@ pub fn new( return .success; } -pub fn free(event_: Event) callconv(.c) void { +pub fn free(event_: Event) callconv(lib.calling_conv) void { const wrapper = event_ orelse return; const alloc = wrapper.alloc; alloc.destroy(wrapper); } -pub fn set_action(event_: Event, action: key.Action) callconv(.c) void { +pub fn set_action(event_: Event, action: key.Action) callconv(lib.calling_conv) void { if (comptime std.debug.runtime_safety) { _ = std.meta.intToEnum(key.Action, @intFromEnum(action)) catch { log.warn("set_action invalid action value={d}", .{@intFromEnum(action)}); @@ -48,12 +48,12 @@ pub fn set_action(event_: Event, action: key.Action) callconv(.c) void { event.action = action; } -pub fn get_action(event_: Event) callconv(.c) key.Action { +pub fn get_action(event_: Event) callconv(lib.calling_conv) key.Action { const event: *key.KeyEvent = &event_.?.event; return event.action; } -pub fn set_key(event_: Event, k: key.Key) callconv(.c) void { +pub fn set_key(event_: Event, k: key.Key) callconv(lib.calling_conv) void { if (comptime std.debug.runtime_safety) { _ = std.meta.intToEnum(key.Key, @intFromEnum(k)) catch { log.warn("set_key invalid key value={d}", .{@intFromEnum(k)}); @@ -65,58 +65,58 @@ pub fn set_key(event_: Event, k: key.Key) callconv(.c) void { event.key = k; } -pub fn get_key(event_: Event) callconv(.c) key.Key { +pub fn get_key(event_: Event) callconv(lib.calling_conv) key.Key { const event: *key.KeyEvent = &event_.?.event; return event.key; } -pub fn set_mods(event_: Event, mods: key.Mods) callconv(.c) void { +pub fn set_mods(event_: Event, mods: key.Mods) callconv(lib.calling_conv) void { const event: *key.KeyEvent = &event_.?.event; event.mods = mods; } -pub fn get_mods(event_: Event) callconv(.c) key.Mods { +pub fn get_mods(event_: Event) callconv(lib.calling_conv) key.Mods { const event: *key.KeyEvent = &event_.?.event; return event.mods; } -pub fn set_consumed_mods(event_: Event, consumed_mods: key.Mods) callconv(.c) void { +pub fn set_consumed_mods(event_: Event, consumed_mods: key.Mods) callconv(lib.calling_conv) void { const event: *key.KeyEvent = &event_.?.event; event.consumed_mods = consumed_mods; } -pub fn get_consumed_mods(event_: Event) callconv(.c) key.Mods { +pub fn get_consumed_mods(event_: Event) callconv(lib.calling_conv) key.Mods { const event: *key.KeyEvent = &event_.?.event; return event.consumed_mods; } -pub fn set_composing(event_: Event, composing: bool) callconv(.c) void { +pub fn set_composing(event_: Event, composing: bool) callconv(lib.calling_conv) void { const event: *key.KeyEvent = &event_.?.event; event.composing = composing; } -pub fn get_composing(event_: Event) callconv(.c) bool { +pub fn get_composing(event_: Event) callconv(lib.calling_conv) bool { const event: *key.KeyEvent = &event_.?.event; return event.composing; } -pub fn set_utf8(event_: Event, utf8: ?[*]const u8, len: usize) callconv(.c) void { +pub fn set_utf8(event_: Event, utf8: ?[*]const u8, len: usize) callconv(lib.calling_conv) void { const event: *key.KeyEvent = &event_.?.event; event.utf8 = if (utf8) |ptr| ptr[0..len] else ""; } -pub fn get_utf8(event_: Event, len: ?*usize) callconv(.c) ?[*]const u8 { +pub fn get_utf8(event_: Event, len: ?*usize) callconv(lib.calling_conv) ?[*]const u8 { const event: *key.KeyEvent = &event_.?.event; if (len) |l| l.* = event.utf8.len; return if (event.utf8.len == 0) null else event.utf8.ptr; } -pub fn set_unshifted_codepoint(event_: Event, codepoint: u32) callconv(.c) void { +pub fn set_unshifted_codepoint(event_: Event, codepoint: u32) callconv(lib.calling_conv) void { const event: *key.KeyEvent = &event_.?.event; event.unshifted_codepoint = @truncate(codepoint); } -pub fn get_unshifted_codepoint(event_: Event) callconv(.c) u32 { +pub fn get_unshifted_codepoint(event_: Event) callconv(lib.calling_conv) u32 { const event: *key.KeyEvent = &event_.?.event; return event.unshifted_codepoint; } @@ -125,7 +125,7 @@ test "alloc" { const testing = std.testing; var e: Event = undefined; try testing.expectEqual(Result.success, new( - &lib_alloc.test_allocator, + &lib.alloc.test_allocator, &e, )); free(e); @@ -135,7 +135,7 @@ test "set" { const testing = std.testing; var e: Event = undefined; try testing.expectEqual(Result.success, new( - &lib_alloc.test_allocator, + &lib.alloc.test_allocator, &e, )); defer free(e); @@ -182,7 +182,7 @@ test "get" { const testing = std.testing; var e: Event = undefined; try testing.expectEqual(Result.success, new( - &lib_alloc.test_allocator, + &lib.alloc.test_allocator, &e, )); defer free(e); @@ -231,7 +231,7 @@ test "complete key event" { const testing = std.testing; var e: Event = undefined; try testing.expectEqual(Result.success, new( - &lib_alloc.test_allocator, + &lib.alloc.test_allocator, &e, )); defer free(e); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index bc92597f5..788790c69 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -1,11 +1,29 @@ +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; + +const buildpkg = @import("build_info.zig"); +pub const allocator = @import("allocator.zig"); +pub const cell = @import("cell.zig"); pub const color = @import("color.zig"); +pub const focus = @import("focus.zig"); +pub const formatter = @import("formatter.zig"); +pub const modes = @import("modes.zig"); pub const osc = @import("osc.zig"); +pub const render = @import("render.zig"); pub const key_event = @import("key_event.zig"); pub const key_encode = @import("key_encode.zig"); +pub const mouse_event = @import("mouse_event.zig"); +pub const mouse_encode = @import("mouse_encode.zig"); 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 style = @import("style.zig"); +pub const terminal = @import("terminal.zig"); // The full C API, unexported. +pub const build_info = buildpkg.get; + pub const osc_new = osc.new; pub const osc_free = osc.free; pub const osc_reset = osc.reset; @@ -16,6 +34,32 @@ pub const osc_command_data = osc.commandData; pub const color_rgb_get = color.rgb_get; +pub const focus_encode = focus.encode; + +pub const mode_report_encode = modes.report_encode; + +pub const formatter_terminal_new = formatter.terminal_new; +pub const formatter_format_buf = formatter.format_buf; +pub const formatter_format_alloc = formatter.format_alloc; +pub const formatter_free = formatter.free; + +pub const render_state_new = render.new; +pub const render_state_free = render.free; +pub const render_state_update = render.update; +pub const render_state_get = render.get; +pub const render_state_set = render.set; +pub const render_state_colors_get = render.colors_get; +pub const render_state_row_iterator_new = render.row_iterator_new; +pub const render_state_row_iterator_next = render.row_iterator_next; +pub const render_state_row_get = render.row_get; +pub const render_state_row_set = render.row_set; +pub const render_state_row_iterator_free = render.row_iterator_free; +pub const render_state_row_cells_new = render.row_cells_new; +pub const render_state_row_cells_next = render.row_cells_next; +pub const render_state_row_cells_select = render.row_cells_select; +pub const render_state_row_cells_get = render.row_cells_get; +pub const render_state_row_cells_free = render.row_cells_free; + pub const sgr_new = sgr.new; pub const sgr_free = sgr.free; pub const sgr_reset = sgr.reset; @@ -48,17 +92,81 @@ pub const key_event_get_unshifted_codepoint = key_event.get_unshifted_codepoint; pub const key_encoder_new = key_encode.new; pub const key_encoder_free = key_encode.free; pub const key_encoder_setopt = key_encode.setopt; +pub const key_encoder_setopt_from_terminal = key_encode.setopt_from_terminal; pub const key_encoder_encode = key_encode.encode; +pub const mouse_event_new = mouse_event.new; +pub const mouse_event_free = mouse_event.free; +pub const mouse_event_set_action = mouse_event.set_action; +pub const mouse_event_get_action = mouse_event.get_action; +pub const mouse_event_set_button = mouse_event.set_button; +pub const mouse_event_clear_button = mouse_event.clear_button; +pub const mouse_event_get_button = mouse_event.get_button; +pub const mouse_event_set_mods = mouse_event.set_mods; +pub const mouse_event_get_mods = mouse_event.get_mods; +pub const mouse_event_set_position = mouse_event.set_position; +pub const mouse_event_get_position = mouse_event.get_position; + +pub const mouse_encoder_new = mouse_encode.new; +pub const mouse_encoder_free = mouse_encode.free; +pub const mouse_encoder_setopt = mouse_encode.setopt; +pub const mouse_encoder_setopt_from_terminal = mouse_encode.setopt_from_terminal; +pub const mouse_encoder_reset = mouse_encode.reset; +pub const mouse_encoder_encode = mouse_encode.encode; + pub const paste_is_safe = paste.is_safe; +pub const alloc_alloc = allocator.alloc; +pub const alloc_free = allocator.free; + +pub const size_report_encode = size_report.encode; + +pub const cell_get = cell.get; + +pub const row_get = row.get; + +pub const style_default = style.default_style; +pub const style_is_default = style.style_is_default; + +pub const terminal_new = terminal.new; +pub const terminal_free = terminal.free; +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_scroll_viewport = terminal.scroll_viewport; +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_grid_ref = terminal.grid_ref; + +const grid_ref = @import("grid_ref.zig"); +pub const grid_ref_cell = grid_ref.grid_ref_cell; +pub const grid_ref_row = grid_ref.grid_ref_row; +pub const grid_ref_graphemes = grid_ref.grid_ref_graphemes; +pub const grid_ref_style = grid_ref.grid_ref_style; + test { + _ = allocator; + _ = buildpkg; + _ = cell; _ = color; + _ = grid_ref; + _ = row; + _ = focus; + _ = formatter; + _ = modes; _ = osc; + _ = render; _ = key_event; _ = key_encode; + _ = mouse_event; + _ = mouse_encode; _ = paste; _ = sgr; + _ = size_report; + _ = style; + _ = terminal; // We want to make sure we run the tests for the C allocator interface. _ = @import("../../lib/allocator.zig"); diff --git a/src/terminal/c/modes.zig b/src/terminal/c/modes.zig new file mode 100644 index 000000000..449ce63e3 --- /dev/null +++ b/src/terminal/c/modes.zig @@ -0,0 +1,104 @@ +const std = @import("std"); +const lib = @import("../lib.zig"); +const modes = @import("../modes.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttyModeReportState +pub const ReportState = enum(c_int) { + _, + + fn toZig(self: ReportState) ?modes.Report.State { + return std.meta.intToEnum( + modes.Report.State, + @intFromEnum(self), + ) catch null; + } +}; + +pub fn report_encode( + tag: modes.ModeTag.Backing, + state: ReportState, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(lib.calling_conv) Result { + const mode_tag: modes.ModeTag = @bitCast(tag); + const report: modes.Report = .{ + .tag = mode_tag, + .state = state.toZig() orelse return .invalid_value, + }; + + var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{}); + report.encode(&writer) catch |err| switch (err) { + error.WriteFailed => { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + report.encode(&discarding.writer) catch unreachable; + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + }; + + out_written.* = writer.end; + return .success; +} + +test "encode DEC mode set" { + var buf: [modes.Report.max_size]u8 = undefined; + var written: usize = 0; + const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1, .ansi = false }); + const result = report_encode(tag, @enumFromInt(1), &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[?1;1$y", buf[0..written]); +} + +test "encode DEC mode reset" { + var buf: [modes.Report.max_size]u8 = undefined; + var written: usize = 0; + const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1, .ansi = false }); + const result = report_encode(tag, @enumFromInt(2), &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[?1;2$y", buf[0..written]); +} + +test "encode ANSI mode" { + var buf: [modes.Report.max_size]u8 = undefined; + var written: usize = 0; + const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 4, .ansi = true }); + const result = report_encode(tag, @enumFromInt(1), &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[4;1$y", buf[0..written]); +} + +test "encode not recognized" { + var buf: [modes.Report.max_size]u8 = undefined; + var written: usize = 0; + const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 9999, .ansi = false }); + const result = report_encode(tag, @enumFromInt(0), &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[?9999;0$y", buf[0..written]); +} + +test "encode with insufficient buffer" { + var buf: [1]u8 = undefined; + var written: usize = 0; + const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1, .ansi = false }); + const result = report_encode(tag, @enumFromInt(1), &buf, buf.len, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expect(written > 1); +} + +test "encode with invalid state" { + var buf: [modes.Report.max_size]u8 = undefined; + var written: usize = 0; + const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1, .ansi = false }); + const result = report_encode(tag, @enumFromInt(99), &buf, buf.len, &written); + try std.testing.expectEqual(.invalid_value, result); +} + +test "encode with null buffer" { + var written: usize = 0; + const tag: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 1, .ansi = false }); + const result = report_encode(tag, @enumFromInt(1), null, 0, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expect(written > 0); +} diff --git a/src/terminal/c/mouse_encode.zig b/src/terminal/c/mouse_encode.zig new file mode 100644 index 000000000..331dd6060 --- /dev/null +++ b/src/terminal/c/mouse_encode.zig @@ -0,0 +1,530 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const testing = std.testing; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const input_mouse_encode = @import("../../input/mouse_encode.zig"); +const renderer_size = @import("../../renderer/size.zig"); +const point = @import("../point.zig"); +const terminal_mouse = @import("../mouse.zig"); +const mouse_event = @import("mouse_event.zig"); +const Result = @import("result.zig").Result; +const Event = mouse_event.Event; +const Terminal = @import("terminal.zig").Terminal; +const ZigTerminal = @import("../Terminal.zig"); + +const log = std.log.scoped(.mouse_encode); + +/// Wrapper around mouse encoding options that tracks the allocator for C API usage. +const MouseEncoderWrapper = struct { + opts: input_mouse_encode.Options, + track_last_cell: bool = false, + last_cell: ?point.Coordinate = null, + alloc: Allocator, +}; + +/// C: GhosttyMouseEncoder +pub const Encoder = ?*MouseEncoderWrapper; + +/// C: GhosttyMouseTrackingMode +pub const TrackingMode = terminal_mouse.Event; + +/// C: GhosttyMouseFormat +pub const Format = terminal_mouse.Format; + +/// C: GhosttyMouseEncoderSize +pub const Size = extern struct { + size: usize = @sizeOf(Size), + screen_width: u32, + screen_height: u32, + cell_width: u32, + cell_height: u32, + padding_top: u32, + padding_bottom: u32, + padding_right: u32, + padding_left: u32, + + fn toRenderer(self: Size) ?renderer_size.Size { + if (self.cell_width == 0 or self.cell_height == 0) return null; + return .{ + .screen = .{ + .width = self.screen_width, + .height = self.screen_height, + }, + .cell = .{ + .width = self.cell_width, + .height = self.cell_height, + }, + .padding = .{ + .top = self.padding_top, + .bottom = self.padding_bottom, + .right = self.padding_right, + .left = self.padding_left, + }, + }; + } +}; + +/// C: GhosttyMouseEncoderOption +pub const Option = enum(c_int) { + event = 0, + format = 1, + size = 2, + any_button_pressed = 3, + track_last_cell = 4, + + /// Input type expected for setting the option. + pub fn InType(comptime self: Option) type { + return switch (self) { + .event => TrackingMode, + .format => Format, + .size => Size, + .any_button_pressed, + .track_last_cell, + => bool, + }; + } +}; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Encoder, +) callconv(lib.calling_conv) Result { + const alloc = lib.alloc.default(alloc_); + const ptr = alloc.create(MouseEncoderWrapper) catch + return .out_of_memory; + ptr.* = .{ + .opts = .{ .size = defaultSize() }, + .alloc = alloc, + }; + result.* = ptr; + return .success; +} + +pub fn free(encoder_: Encoder) callconv(lib.calling_conv) void { + const wrapper = encoder_ orelse return; + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +pub fn setopt( + encoder_: Encoder, + option: Option, + value: ?*const anyopaque, +) callconv(lib.calling_conv) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Option, @intFromEnum(option)) catch { + log.warn("setopt invalid option value={d}", .{@intFromEnum(option)}); + return; + }; + } + + return switch (option) { + inline else => |comptime_option| setoptTyped( + encoder_, + comptime_option, + @ptrCast(@alignCast(value orelse return)), + ), + }; +} + +fn setoptTyped( + encoder_: Encoder, + comptime option: Option, + value: *const option.InType(), +) void { + const wrapper = encoder_.?; + switch (option) { + .event => { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(TrackingMode, @intFromEnum(value.*)) catch { + log.warn("setopt invalid TrackingMode value={d}", .{@intFromEnum(value.*)}); + return; + }; + } + + if (wrapper.opts.event != value.*) wrapper.last_cell = null; + wrapper.opts.event = value.*; + }, + + .format => { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Format, @intFromEnum(value.*)) catch { + log.warn("setopt invalid Format value={d}", .{@intFromEnum(value.*)}); + return; + }; + } + + if (wrapper.opts.format != value.*) wrapper.last_cell = null; + wrapper.opts.format = value.*; + }, + + .size => { + if (value.size < @sizeOf(Size)) { + log.warn("setopt size struct too small size={d} expected>={d}", .{ + value.size, + @sizeOf(Size), + }); + return; + } + + wrapper.opts.size = value.toRenderer() orelse { + log.warn("setopt invalid size values (cell width and height must be non-zero)", .{}); + return; + }; + wrapper.last_cell = null; + }, + + .any_button_pressed => wrapper.opts.any_button_pressed = value.*, + + .track_last_cell => { + wrapper.track_last_cell = value.*; + if (!value.*) wrapper.last_cell = null; + }, + } +} + +pub fn setopt_from_terminal( + encoder_: Encoder, + terminal_: Terminal, +) callconv(lib.calling_conv) void { + const wrapper = encoder_ orelse return; + const t: *ZigTerminal = (terminal_ orelse return).terminal; + wrapper.opts.event = t.flags.mouse_event; + wrapper.opts.format = t.flags.mouse_format; + wrapper.last_cell = null; +} + +pub fn reset(encoder_: Encoder) callconv(lib.calling_conv) void { + const wrapper = encoder_ orelse return; + wrapper.last_cell = null; +} + +pub fn encode( + encoder_: Encoder, + event_: Event, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(lib.calling_conv) Result { + const wrapper = encoder_ orelse return .invalid_value; + const event = event_ orelse return .invalid_value; + + const prev_last_cell = wrapper.last_cell; + + var opts = wrapper.opts; + opts.last_cell = if (wrapper.track_last_cell) &wrapper.last_cell else null; + + var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{}); + input_mouse_encode.encode( + &writer, + event.event, + opts, + ) catch |err| switch (err) { + error.WriteFailed => { + // Failed writes should not mutate motion dedupe state because no + // complete sequence was produced. + wrapper.last_cell = prev_last_cell; + + // Use a discarding writer to count how much space we would have needed. + var count_last_cell = prev_last_cell; + var count_opts = wrapper.opts; + count_opts.last_cell = if (wrapper.track_last_cell) &count_last_cell else null; + + var discarding: std.Io.Writer.Discarding = .init(&.{}); + input_mouse_encode.encode( + &discarding.writer, + event.event, + count_opts, + ) catch unreachable; + + // Discarding always uses a u64. If we're on 32-bit systems + // we cast down. We should make this safer in the future. + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + }; + + out_written.* = writer.end; + return .success; +} + +fn defaultSize() renderer_size.Size { + return .{ + .screen = .{ .width = 1, .height = 1 }, + .cell = .{ .width = 1, .height = 1 }, + .padding = .{}, + }; +} + +fn testSize() Size { + return .{ + .size = @sizeOf(Size), + .screen_width = 1_000, + .screen_height = 1_000, + .cell_width = 1, + .cell_height = 1, + .padding_top = 0, + .padding_bottom = 0, + .padding_right = 0, + .padding_left = 0, + }; +} + +test "alloc" { + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + free(e); +} + +test "setopt" { + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + defer free(e); + + const event_mode: TrackingMode = .any; + setopt(e, .event, &event_mode); + try testing.expectEqual(TrackingMode.any, e.?.opts.event); + + const format_mode: Format = .sgr; + setopt(e, .format, &format_mode); + try testing.expectEqual(Format.sgr, e.?.opts.format); + + const size = testSize(); + setopt(e, .size, &size); + try testing.expectEqual(size.screen_width, e.?.opts.size.screen.width); + try testing.expectEqual(size.screen_height, e.?.opts.size.screen.height); + + const any_button_pressed = true; + setopt(e, .any_button_pressed, &any_button_pressed); + try testing.expect(e.?.opts.any_button_pressed); + + const track_last_cell = true; + setopt(e, .track_last_cell, &track_last_cell); + try testing.expect(e.?.track_last_cell); +} + +test "setopt_from_terminal" { + const terminal_c = @import("terminal.zig"); + + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + defer free(e); + + var t: Terminal = undefined; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + const event_mode: TrackingMode = .any; + setopt(e, .event, &event_mode); + const format_mode: Format = .sgr; + setopt(e, .format, &format_mode); + + setopt_from_terminal(e, t); + try testing.expectEqual(TrackingMode.none, e.?.opts.event); + try testing.expectEqual(Format.x10, e.?.opts.format); +} + +test "setopt_from_terminal null" { + setopt_from_terminal(null, null); + + const terminal_c = @import("terminal.zig"); + var t: Terminal = undefined; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + setopt_from_terminal(null, t); + + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + defer free(e); + setopt_from_terminal(e, null); +} + +test "encode: sgr press left" { + var encoder: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &encoder, + )); + defer free(encoder); + + const event_mode: TrackingMode = .any; + setopt(encoder, .event, &event_mode); + const format_mode: Format = .sgr; + setopt(encoder, .format, &format_mode); + const size = testSize(); + setopt(encoder, .size, &size); + + var event: Event = undefined; + try testing.expectEqual(Result.success, mouse_event.new( + &lib.alloc.test_allocator, + &event, + )); + defer mouse_event.free(event); + + mouse_event.set_action(event, .press); + mouse_event.set_button(event, .left); + mouse_event.set_position(event, .{ .x = 0, .y = 0 }); + + var required: usize = 0; + try testing.expectEqual(Result.out_of_space, encode( + encoder, + event, + null, + 0, + &required, + )); + try testing.expectEqual(@as(usize, 9), required); + + var buf: [32]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, encode( + encoder, + event, + &buf, + buf.len, + &written, + )); + try testing.expectEqual(required, written); + try testing.expectEqualStrings("\x1b[<0;1;1M", buf[0..written]); +} + +test "encode: motion dedupe and reset" { + var encoder: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &encoder, + )); + defer free(encoder); + + const event_mode: TrackingMode = .any; + setopt(encoder, .event, &event_mode); + const format_mode: Format = .sgr; + setopt(encoder, .format, &format_mode); + const size = testSize(); + setopt(encoder, .size, &size); + const track_last_cell = true; + setopt(encoder, .track_last_cell, &track_last_cell); + + var event: Event = undefined; + try testing.expectEqual(Result.success, mouse_event.new( + &lib.alloc.test_allocator, + &event, + )); + defer mouse_event.free(event); + + mouse_event.set_action(event, .motion); + mouse_event.set_button(event, .left); + mouse_event.set_position(event, .{ .x = 5, .y = 6 }); + + { + var buf: [32]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, encode( + encoder, + event, + &buf, + buf.len, + &written, + )); + try testing.expect(written > 0); + } + + { + var buf: [32]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, encode( + encoder, + event, + &buf, + buf.len, + &written, + )); + try testing.expectEqual(@as(usize, 0), written); + } + + reset(encoder); + + { + var buf: [32]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, encode( + encoder, + event, + &buf, + buf.len, + &written, + )); + try testing.expect(written > 0); + } +} + +test "encode: querying required size doesn't update dedupe state" { + var encoder: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &encoder, + )); + defer free(encoder); + + const event_mode: TrackingMode = .any; + setopt(encoder, .event, &event_mode); + const format_mode: Format = .sgr; + setopt(encoder, .format, &format_mode); + const size = testSize(); + setopt(encoder, .size, &size); + const track_last_cell = true; + setopt(encoder, .track_last_cell, &track_last_cell); + + var event: Event = undefined; + try testing.expectEqual(Result.success, mouse_event.new( + &lib.alloc.test_allocator, + &event, + )); + defer mouse_event.free(event); + + mouse_event.set_action(event, .motion); + mouse_event.set_button(event, .left); + mouse_event.set_position(event, .{ .x = 5, .y = 6 }); + + var required: usize = 0; + try testing.expectEqual(Result.out_of_space, encode( + encoder, + event, + null, + 0, + &required, + )); + try testing.expect(required > 0); + + var buf: [32]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, encode( + encoder, + event, + &buf, + buf.len, + &written, + )); + try testing.expect(written > 0); +} diff --git a/src/terminal/c/mouse_event.zig b/src/terminal/c/mouse_event.zig new file mode 100644 index 000000000..b3c19dd39 --- /dev/null +++ b/src/terminal/c/mouse_event.zig @@ -0,0 +1,155 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const testing = std.testing; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const key = @import("../../input/key.zig"); +const mouse = @import("../../input/mouse.zig"); +const mouse_encode = @import("../../input/mouse_encode.zig"); +const Result = @import("result.zig").Result; + +const log = std.log.scoped(.mouse_event); + +/// Wrapper around mouse event that tracks the allocator for C API usage. +const MouseEventWrapper = struct { + event: mouse_encode.Event = .{}, + alloc: Allocator, +}; + +/// C: GhosttyMouseEvent +pub const Event = ?*MouseEventWrapper; + +/// C: GhosttyMouseAction +pub const Action = mouse.Action; + +/// C: GhosttyMouseButton +pub const Button = mouse.Button; + +/// C: GhosttyMousePosition +pub const Position = mouse_encode.Event.Pos; + +/// C: GhosttyMods +pub const Mods = key.Mods; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Event, +) callconv(lib.calling_conv) Result { + const alloc = lib.alloc.default(alloc_); + const ptr = alloc.create(MouseEventWrapper) catch + return .out_of_memory; + ptr.* = .{ .alloc = alloc }; + result.* = ptr; + return .success; +} + +pub fn free(event_: Event) callconv(lib.calling_conv) void { + const wrapper = event_ orelse return; + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +pub fn set_action(event_: Event, action: Action) callconv(lib.calling_conv) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Action, @intFromEnum(action)) catch { + log.warn("set_action invalid action value={d}", .{@intFromEnum(action)}); + return; + }; + } + + event_.?.event.action = action; +} + +pub fn get_action(event_: Event) callconv(lib.calling_conv) Action { + return event_.?.event.action; +} + +pub fn set_button(event_: Event, button: Button) callconv(lib.calling_conv) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Button, @intFromEnum(button)) catch { + log.warn("set_button invalid button value={d}", .{@intFromEnum(button)}); + return; + }; + } + + event_.?.event.button = button; +} + +pub fn clear_button(event_: Event) callconv(lib.calling_conv) void { + event_.?.event.button = null; +} + +pub fn get_button(event_: Event, out: ?*Button) callconv(lib.calling_conv) bool { + if (event_.?.event.button) |button| { + if (out) |ptr| ptr.* = button; + return true; + } + + return false; +} + +pub fn set_mods(event_: Event, mods: Mods) callconv(lib.calling_conv) void { + event_.?.event.mods = mods; +} + +pub fn get_mods(event_: Event) callconv(lib.calling_conv) Mods { + return event_.?.event.mods; +} + +pub fn set_position(event_: Event, pos: Position) callconv(lib.calling_conv) void { + event_.?.event.pos = pos; +} + +pub fn get_position(event_: Event) callconv(lib.calling_conv) Position { + return event_.?.event.pos; +} + +test "alloc" { + var e: Event = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + free(e); +} + +test "free null" { + free(null); +} + +test "set/get" { + var e: Event = undefined; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &e, + )); + defer free(e); + + // Action + set_action(e, .motion); + try testing.expectEqual(Action.motion, get_action(e)); + + // Button + set_button(e, .left); + var button: Button = .unknown; + try testing.expect(get_button(e, &button)); + try testing.expectEqual(Button.left, button); + try testing.expect(get_button(e, null)); + + clear_button(e); + try testing.expect(!get_button(e, &button)); + + // Mods + const mods: Mods = .{ .shift = true, .ctrl = true }; + set_mods(e, mods); + const got_mods = get_mods(e); + try testing.expect(got_mods.shift); + try testing.expect(got_mods.ctrl); + try testing.expect(!got_mods.alt); + + // Position + set_position(e, .{ .x = 12.5, .y = -4.0 }); + const pos = get_position(e); + try testing.expectEqual(@as(f32, 12.5), pos.x); + try testing.expectEqual(@as(f32, -4.0), pos.y); +} diff --git a/src/terminal/c/osc.zig b/src/terminal/c/osc.zig index c4cdaad3b..7ba323001 100644 --- a/src/terminal/c/osc.zig +++ b/src/terminal/c/osc.zig @@ -1,6 +1,6 @@ const std = @import("std"); -const lib_alloc = @import("../../lib/allocator.zig"); -const CAllocator = lib_alloc.Allocator; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; const osc = @import("../osc.zig"); const Result = @import("result.zig").Result; @@ -15,8 +15,8 @@ pub const Command = ?*osc.Command; pub fn new( alloc_: ?*const CAllocator, result: *Parser, -) callconv(.c) Result { - const alloc = lib_alloc.default(alloc_); +) callconv(lib.calling_conv) Result { + const alloc = lib.alloc.default(alloc_); const ptr = alloc.create(osc.Parser) catch return .out_of_memory; ptr.* = .init(alloc); @@ -24,7 +24,7 @@ pub fn new( return .success; } -pub fn free(parser_: Parser) callconv(.c) void { +pub fn free(parser_: Parser) callconv(lib.calling_conv) void { // C-built parsers always have an associated allocator. const parser = parser_ orelse return; const alloc = parser.alloc.?; @@ -32,19 +32,19 @@ pub fn free(parser_: Parser) callconv(.c) void { alloc.destroy(parser); } -pub fn reset(parser_: Parser) callconv(.c) void { +pub fn reset(parser_: Parser) callconv(lib.calling_conv) void { parser_.?.reset(); } -pub fn next(parser_: Parser, byte: u8) callconv(.c) void { +pub fn next(parser_: Parser, byte: u8) callconv(lib.calling_conv) void { parser_.?.next(byte); } -pub fn end(parser_: Parser, terminator: u8) callconv(.c) Command { +pub fn end(parser_: Parser, terminator: u8) callconv(lib.calling_conv) Command { return parser_.?.end(terminator); } -pub fn commandType(command_: Command) callconv(.c) osc.Command.Key { +pub fn commandType(command_: Command) callconv(lib.calling_conv) osc.Command.Key { const command = command_ orelse return .invalid; return command.*; } @@ -67,7 +67,7 @@ pub fn commandData( command_: Command, data: CommandData, out: ?*anyopaque, -) callconv(.c) bool { +) callconv(lib.calling_conv) bool { if (comptime std.debug.runtime_safety) { _ = std.meta.intToEnum(CommandData, @intFromEnum(data)) catch { log.warn("commandData invalid data value={d}", .{@intFromEnum(data)}); @@ -76,6 +76,7 @@ pub fn commandData( } return switch (data) { + .invalid => false, inline else => |comptime_data| commandDataTyped( command_, comptime_data, @@ -105,7 +106,7 @@ test "alloc" { const testing = std.testing; var p: Parser = undefined; try testing.expectEqual(Result.success, new( - &lib_alloc.test_allocator, + &lib.alloc.test_allocator, &p, )); free(p); @@ -120,7 +121,7 @@ test "change window title" { const testing = std.testing; var p: Parser = undefined; try testing.expectEqual(Result.success, new( - &lib_alloc.test_allocator, + &lib.alloc.test_allocator, &p, )); defer free(p); diff --git a/src/terminal/c/paste.zig b/src/terminal/c/paste.zig index eb4117a70..69df416c0 100644 --- a/src/terminal/c/paste.zig +++ b/src/terminal/c/paste.zig @@ -1,7 +1,8 @@ const std = @import("std"); +const lib = @import("../lib.zig"); const paste = @import("../../input/paste.zig"); -pub fn is_safe(data: ?[*]const u8, len: usize) callconv(.c) bool { +pub fn is_safe(data: ?[*]const u8, len: usize) callconv(lib.calling_conv) bool { const slice: []const u8 = if (data) |v| v[0..len] else &.{}; return paste.isSafe(slice); } diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig new file mode 100644 index 000000000..08a5c00f3 --- /dev/null +++ b/src/terminal/c/render.zig @@ -0,0 +1,1386 @@ +const std = @import("std"); +const testing = std.testing; +const Allocator = std.mem.Allocator; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const colorpkg = @import("../color.zig"); +const cursorpkg = @import("../cursor.zig"); +const page = @import("../page.zig"); +const size = @import("../size.zig"); +const Style = @import("../style.zig").Style; +const terminal_c = @import("terminal.zig"); +const ZigTerminal = @import("../Terminal.zig"); +const renderpkg = @import("../render.zig"); +const Result = @import("result.zig").Result; +const row = @import("row.zig"); +const style_c = @import("style.zig"); + +const log = std.log.scoped(.render_state_c); + +const RenderStateWrapper = struct { + alloc: std.mem.Allocator, + state: renderpkg.RenderState = .empty, +}; + +const RowIteratorWrapper = struct { + alloc: std.mem.Allocator, + + /// The current index (also y value) into the row list. + y: ?size.CellCountInt, + + /// These are the raw pointers into the render state data. + raws: []const page.Row, + cells: []const std.MultiArrayList(renderpkg.RenderState.Cell), + dirty: []bool, + + /// The color palette from the render state, needed to resolve + /// palette-indexed background colors on cells. + palette: *const colorpkg.Palette, +}; + +const RowCellsWrapper = struct { + alloc: std.mem.Allocator, + x: ?size.CellCountInt, + raws: []const page.Cell, + graphemes: []const []const u21, + styles: []const Style, + + /// The color palette, needed to resolve palette-indexed background colors. + palette: *const colorpkg.Palette, +}; + +/// C: GhosttyRenderState +pub const RenderState = ?*RenderStateWrapper; + +/// C: GhosttyRenderStateRowIterator +pub const RowIterator = ?*RowIteratorWrapper; + +/// C: GhosttyRenderStateRowCells +pub const RowCells = ?*RowCellsWrapper; + +/// C: GhosttyRenderStateDirty +pub const Dirty = renderpkg.RenderState.Dirty; + +/// C: GhosttyRenderStateCursorVisualStyle +pub const CursorVisualStyle = enum(c_int) { + bar = 0, + block = 1, + underline = 2, + block_hollow = 3, + + pub fn fromCursorStyle(s: cursorpkg.Style) CursorVisualStyle { + return switch (s) { + .bar => .bar, + .block => .block, + .underline => .underline, + .block_hollow => .block_hollow, + }; + } +}; + +/// C: GhosttyRenderStateData +pub const Data = enum(c_int) { + invalid = 0, + cols = 1, + rows = 2, + dirty = 3, + row_iterator = 4, + color_background = 5, + color_foreground = 6, + color_cursor = 7, + color_cursor_has_value = 8, + color_palette = 9, + cursor_visual_style = 10, + cursor_visible = 11, + cursor_blinking = 12, + cursor_password_input = 13, + cursor_viewport_has_value = 14, + cursor_viewport_x = 15, + cursor_viewport_y = 16, + cursor_viewport_wide_tail = 17, + + /// Output type expected for querying the data of the given kind. + pub fn OutType(comptime self: Data) type { + return switch (self) { + .invalid => void, + .cols, .rows => size.CellCountInt, + .dirty => Dirty, + .row_iterator => RowIterator, + .color_background, .color_foreground, .color_cursor => colorpkg.RGB.C, + .color_cursor_has_value => bool, + .color_palette => [256]colorpkg.RGB.C, + .cursor_visual_style => CursorVisualStyle, + .cursor_visible, .cursor_blinking, .cursor_password_input => bool, + .cursor_viewport_has_value, .cursor_viewport_wide_tail => bool, + .cursor_viewport_x, .cursor_viewport_y => size.CellCountInt, + }; + } +}; + +/// C: GhosttyRenderStateOption +pub const SetOption = enum(c_int) { + dirty = 0, + + /// Input type expected for setting the option. + pub fn InType(comptime self: SetOption) type { + return switch (self) { + .dirty => Dirty, + }; + } +}; + +/// C: GhosttyRenderStateColors +pub const Colors = extern struct { + size: usize = @sizeOf(Colors), + background: colorpkg.RGB.C, + foreground: colorpkg.RGB.C, + cursor: colorpkg.RGB.C, + cursor_has_value: bool, + palette: [256]colorpkg.RGB.C, +}; + +pub fn new( + alloc_: ?*const CAllocator, + result: *RenderState, +) callconv(lib.calling_conv) Result { + result.* = new_(alloc_) catch |err| { + result.* = null; + return switch (err) { + error.OutOfMemory => .out_of_memory, + }; + }; + + return .success; +} + +fn new_(alloc_: ?*const CAllocator) error{OutOfMemory}!*RenderStateWrapper { + const alloc = lib.alloc.default(alloc_); + const ptr = alloc.create(RenderStateWrapper) catch + return error.OutOfMemory; + ptr.* = .{ .alloc = alloc }; + return ptr; +} + +pub fn free(state_: RenderState) callconv(lib.calling_conv) void { + const state = state_ orelse return; + const alloc = state.alloc; + state.state.deinit(alloc); + alloc.destroy(state); +} + +pub fn update( + state_: RenderState, + terminal_: terminal_c.Terminal, +) callconv(lib.calling_conv) Result { + const state = state_ orelse return .invalid_value; + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + + state.state.update(state.alloc, t) catch return .out_of_memory; + return .success; +} + +pub fn get( + state_: RenderState, + data: Data, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Data, @intFromEnum(data)) catch { + log.warn("render_state_get invalid data value={d}", .{@intFromEnum(data)}); + return .invalid_value; + }; + } + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| getTyped( + state_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn getTyped( + state_: RenderState, + comptime data: Data, + out: *data.OutType(), +) Result { + const state = state_ orelse return .invalid_value; + switch (data) { + .invalid => return .invalid_value, + .cols => out.* = state.state.cols, + .rows => out.* = state.state.rows, + .dirty => out.* = state.state.dirty, + .row_iterator => { + const it = out.* orelse return .invalid_value; + const row_data = state.state.row_data.slice(); + it.* = .{ + .alloc = it.alloc, + .y = null, + .raws = row_data.items(.raw), + .cells = row_data.items(.cells), + .dirty = row_data.items(.dirty), + .palette = &state.state.colors.palette, + }; + }, + .color_background => out.* = state.state.colors.background.cval(), + .color_foreground => out.* = state.state.colors.foreground.cval(), + .color_cursor => { + const cursor = state.state.colors.cursor orelse return .invalid_value; + out.* = cursor.cval(); + }, + .color_cursor_has_value => out.* = state.state.colors.cursor != null, + .color_palette => { + for (&out.*, state.state.colors.palette) |*dst, src| { + dst.* = src.cval(); + } + }, + .cursor_visual_style => out.* = CursorVisualStyle.fromCursorStyle(state.state.cursor.visual_style), + .cursor_visible => out.* = state.state.cursor.visible, + .cursor_blinking => out.* = state.state.cursor.blinking, + .cursor_password_input => out.* = state.state.cursor.password_input, + .cursor_viewport_has_value => out.* = state.state.cursor.viewport != null, + .cursor_viewport_x => { + const vp = state.state.cursor.viewport orelse return .invalid_value; + out.* = vp.x; + }, + .cursor_viewport_y => { + const vp = state.state.cursor.viewport orelse return .invalid_value; + out.* = vp.y; + }, + .cursor_viewport_wide_tail => { + const vp = state.state.cursor.viewport orelse return .invalid_value; + out.* = vp.wide_tail; + }, + } + + return .success; +} + +pub fn set( + state_: RenderState, + option: SetOption, + value: ?*const anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(SetOption, @intFromEnum(option)) catch { + log.warn("render_state_set invalid option value={d}", .{@intFromEnum(option)}); + return .invalid_value; + }; + } + + return switch (option) { + inline else => |comptime_option| setTyped( + state_, + comptime_option, + @ptrCast(@alignCast(value orelse return .invalid_value)), + ), + }; +} + +fn setTyped( + state_: RenderState, + comptime option: SetOption, + value: *const option.InType(), +) Result { + const state = state_ orelse return .invalid_value; + switch (option) { + .dirty => state.state.dirty = value.*, + } + + return .success; +} + +pub fn colors_get( + state_: RenderState, + out_colors_: ?*Colors, +) callconv(lib.calling_conv) Result { + const state = state_ orelse return .invalid_value; + const out_colors = out_colors_ orelse return .invalid_value; + const out_size = out_colors.size; + if (out_size < @sizeOf(usize)) return .invalid_value; + + const colors = state.state.colors; + if (lib.structSizedFieldFits( + Colors, + out_size, + "background", + )) { + out_colors.background = colors.background.cval(); + } + + if (lib.structSizedFieldFits( + Colors, + out_size, + "foreground", + )) { + out_colors.foreground = colors.foreground.cval(); + } + + if (colors.cursor) |cursor| { + if (lib.structSizedFieldFits( + Colors, + out_size, + "cursor", + )) { + out_colors.cursor = cursor.cval(); + } + } + + if (lib.structSizedFieldFits( + Colors, + out_size, + "cursor_has_value", + )) { + out_colors.cursor_has_value = colors.cursor != null; + } + + { + const palette_offset = @offsetOf(Colors, "palette"); + if (out_size > palette_offset) { + const available = out_size - palette_offset; + const max_entries = @min(colors.palette.len, available / @sizeOf(colorpkg.RGB.C)); + for (0..max_entries) |i| { + out_colors.palette[i] = colors.palette[i].cval(); + } + } + } + + return .success; +} + +pub fn row_iterator_new( + alloc_: ?*const CAllocator, + result: *RowIterator, +) callconv(lib.calling_conv) Result { + const alloc = lib.alloc.default(alloc_); + const ptr = alloc.create(RowIteratorWrapper) catch { + result.* = null; + return .out_of_memory; + }; + ptr.* = .{ + .alloc = alloc, + .y = undefined, + .raws = undefined, + .cells = undefined, + .dirty = undefined, + .palette = undefined, + }; + result.* = ptr; + return .success; +} + +pub fn row_iterator_free(iterator_: RowIterator) callconv(lib.calling_conv) void { + const iterator = iterator_ orelse return; + const alloc = iterator.alloc; + alloc.destroy(iterator); +} + +pub fn row_iterator_next(iterator_: RowIterator) callconv(lib.calling_conv) bool { + const it = iterator_ orelse return false; + const next_y: size.CellCountInt = if (it.y) |y| y + 1 else 0; + if (next_y >= it.raws.len) return false; + it.y = next_y; + return true; +} + +pub fn row_cells_new( + alloc_: ?*const CAllocator, + result: *RowCells, +) callconv(lib.calling_conv) Result { + const alloc = lib.alloc.default(alloc_); + const ptr = alloc.create(RowCellsWrapper) catch { + result.* = null; + return .out_of_memory; + }; + ptr.* = .{ + .alloc = alloc, + .x = undefined, + .raws = undefined, + .graphemes = undefined, + .styles = undefined, + .palette = undefined, + }; + result.* = ptr; + return .success; +} + +pub fn row_cells_next(cells_: RowCells) callconv(lib.calling_conv) bool { + const cells = cells_ orelse return false; + const next_x: size.CellCountInt = if (cells.x) |x| x + 1 else 0; + if (next_x >= cells.raws.len) return false; + cells.x = next_x; + return true; +} + +pub fn row_cells_select(cells_: RowCells, x: size.CellCountInt) callconv(lib.calling_conv) Result { + const cells = cells_ orelse return .invalid_value; + if (x >= cells.raws.len) return .invalid_value; + cells.x = x; + return .success; +} + +pub fn row_cells_free(cells_: RowCells) callconv(lib.calling_conv) void { + const cells = cells_ orelse return; + const alloc = cells.alloc; + alloc.destroy(cells); +} + +/// C: GhosttyRenderStateRowCellsData +pub const RowCellsData = enum(c_int) { + invalid = 0, + raw = 1, + style = 2, + graphemes_len = 3, + graphemes_buf = 4, + bg_color = 5, + fg_color = 6, + + /// Output type expected for querying the data of the given kind. + pub fn OutType(comptime self: RowCellsData) type { + return switch (self) { + .invalid => void, + .raw => page.Cell.C, + .style => style_c.Style, + .graphemes_len => u32, + .graphemes_buf => u32, + .bg_color, .fg_color => colorpkg.RGB.C, + }; + } +}; + +pub fn row_cells_get( + cells_: RowCells, + data: RowCellsData, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(RowCellsData, @intFromEnum(data)) catch { + log.warn("render_state_row_cells_get invalid data value={d}", .{@intFromEnum(data)}); + return .invalid_value; + }; + } + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| rowCellsGetTyped( + cells_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn rowCellsGetTyped( + cells_: RowCells, + comptime data: RowCellsData, + out: *data.OutType(), +) Result { + const cells = cells_ orelse return .invalid_value; + const x = cells.x orelse return .invalid_value; + const cell = cells.raws[x]; + switch (data) { + .invalid => return .invalid_value, + .raw => out.* = cell.cval(), + .style => out.* = if (cell.hasStyling()) + style_c.Style.fromStyle(cells.styles[x]) + else + style_c.Style.fromStyle(.{}), + .graphemes_len => { + if (!cell.hasText()) { + out.* = 0; + return .success; + } + const extra = if (cell.hasGrapheme()) cells.graphemes[x] else &[_]u21{}; + out.* = @intCast(1 + extra.len); + }, + .graphemes_buf => { + if (!cell.hasText()) return .success; + const extra = if (cell.hasGrapheme()) cells.graphemes[x] else &[_]u21{}; + const buf: [*]u32 = @ptrCast(out); + buf[0] = cell.codepoint(); + for (extra, 1..) |cp, i| { + buf[i] = cp; + } + }, + .bg_color => { + const s: Style = if (cell.hasStyling()) cells.styles[x] else .{}; + const bg = s.bg(&cell, cells.palette) orelse return .invalid_value; + out.* = bg.cval(); + }, + .fg_color => { + const s: Style = if (cell.hasStyling()) cells.styles[x] else .{}; + if (s.fg_color == .none) return .invalid_value; + const fg = s.fg(.{ .default = .{}, .palette = cells.palette }); + out.* = fg.cval(); + }, + } + + return .success; +} + +/// C: GhosttyRenderStateRowData +pub const RowData = enum(c_int) { + invalid = 0, + dirty = 1, + raw = 2, + cells = 3, + + /// Output type expected for querying the data of the given kind. + pub fn OutType(comptime self: RowData) type { + return switch (self) { + .invalid => void, + .dirty => bool, + .raw => row.CRow, + .cells => RowCells, + }; + } +}; + +/// C: GhosttyRenderStateRowOption +pub const RowOption = enum(c_int) { + dirty = 0, + + /// Input type expected for setting the option. + pub fn InType(comptime self: RowOption) type { + return switch (self) { + .dirty => bool, + }; + } +}; + +pub fn row_get( + iterator_: RowIterator, + data: RowData, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(RowData, @intFromEnum(data)) catch { + log.warn("render_state_row_get invalid data value={d}", .{@intFromEnum(data)}); + return .invalid_value; + }; + } + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| rowGetTyped( + iterator_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn rowGetTyped( + iterator_: RowIterator, + comptime data: RowData, + out: *data.OutType(), +) Result { + const it = iterator_ orelse return .invalid_value; + const y = it.y orelse return .invalid_value; + switch (data) { + .invalid => return .invalid_value, + .dirty => out.* = it.dirty[y], + .raw => out.* = it.raws[y].cval(), + .cells => { + const cells = out.* orelse return .invalid_value; + const cell_data = it.cells[y].slice(); + cells.* = .{ + .alloc = cells.alloc, + .x = null, + .raws = cell_data.items(.raw), + .graphemes = cell_data.items(.grapheme), + .styles = cell_data.items(.style), + .palette = it.palette, + }; + }, + } + + return .success; +} + +pub fn row_set( + iterator_: RowIterator, + option: RowOption, + value: ?*const anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(RowOption, @intFromEnum(option)) catch { + log.warn("render_state_row_set invalid option value={d}", .{@intFromEnum(option)}); + return .invalid_value; + }; + } + + return switch (option) { + inline else => |comptime_option| rowSetTyped( + iterator_, + comptime_option, + @ptrCast(@alignCast(value orelse return .invalid_value)), + ), + }; +} + +fn rowSetTyped( + iterator_: RowIterator, + comptime option: RowOption, + value: *const option.InType(), +) Result { + const it = iterator_ orelse return .invalid_value; + const y = it.y orelse return .invalid_value; + switch (option) { + .dirty => it.dirty[y] = value.*, + } + + return .success; +} + +test "render: new/free" { + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + try testing.expect(state != null); + free(state); +} + +test "render: free null" { + free(null); +} + +test "render: update invalid value" { + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.invalid_value, update(null, null)); + try testing.expectEqual(Result.invalid_value, update(state, null)); +} + +test "render: get invalid value" { + var cols: size.CellCountInt = 0; + try testing.expectEqual(Result.invalid_value, get(null, .cols, @ptrCast(&cols))); +} + +test "render: get invalid data" { + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.invalid_value, get(state, .invalid, null)); +} + +test "render: colors get invalid value" { + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + var colors: Colors = std.mem.zeroes(Colors); + colors.size = @sizeOf(Colors); + + try testing.expectEqual(Result.invalid_value, colors_get(null, &colors)); + try testing.expectEqual(Result.invalid_value, colors_get(state, null)); + + colors.size = @sizeOf(usize) - 1; + try testing.expectEqual(Result.invalid_value, colors_get(state, &colors)); +} + +test "render: get/set dirty invalid value" { + var dirty: Dirty = .false; + try testing.expectEqual(Result.invalid_value, get(null, .dirty, @ptrCast(&dirty))); + const dirty_full: Dirty = .full; + try testing.expectEqual(Result.invalid_value, set(null, .dirty, @ptrCast(&dirty_full))); +} + +test "render: get/set dirty" { + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + var dirty: Dirty = undefined; + try testing.expectEqual(Result.success, get(state, .dirty, @ptrCast(&dirty))); + try testing.expectEqual(Dirty.false, dirty); + + const dirty_partial: Dirty = .partial; + try testing.expectEqual(Result.success, set(state, .dirty, @ptrCast(&dirty_partial))); + try testing.expectEqual(Result.success, get(state, .dirty, @ptrCast(&dirty))); + try testing.expectEqual(Dirty.partial, dirty); + + const dirty_full: Dirty = .full; + try testing.expectEqual(Result.success, set(state, .dirty, @ptrCast(&dirty_full))); + try testing.expectEqual(Result.success, get(state, .dirty, @ptrCast(&dirty))); + try testing.expectEqual(Dirty.full, dirty); +} + +test "render: set null value" { + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.invalid_value, set(state, .dirty, null)); +} + +test "render: row iterator get invalid value" { + var iterator: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &iterator, + )); + defer row_iterator_free(iterator); + + try testing.expectEqual(Result.invalid_value, get(null, .row_iterator, @ptrCast(&iterator))); +} + +test "render: row iterator new/free" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var iterator: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &iterator, + )); + defer row_iterator_free(iterator); + + try testing.expect(iterator != null); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&iterator))); + + const iterator_ptr = iterator.?; + const row_data = state.?.state.row_data.slice(); + + try testing.expectEqual(@as(?size.CellCountInt, null), iterator_ptr.y); + try testing.expectEqual(row_data.items(.raw).len, iterator_ptr.raws.len); + try testing.expectEqual(row_data.items(.cells).len, iterator_ptr.cells.len); + try testing.expectEqual(row_data.items(.dirty).len, iterator_ptr.dirty.len); +} + +test "render: row iterator free null" { + row_iterator_free(null); +} + +test "render: row iterator next null" { + try testing.expect(!row_iterator_next(null)); +} + +test "render: row get null" { + var dirty: bool = undefined; + try testing.expectEqual(Result.invalid_value, row_get(null, .dirty, @ptrCast(&dirty))); +} + +test "render: row get invalid data" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var iterator: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &iterator, + )); + defer row_iterator_free(iterator); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&iterator))); + try testing.expect(row_iterator_next(iterator)); + try testing.expectEqual(Result.invalid_value, row_get(iterator, .invalid, null)); +} + +test "render: row set null" { + const dirty = false; + try testing.expectEqual(Result.invalid_value, row_set(null, .dirty, @ptrCast(&dirty))); +} + +test "render: row set before iteration" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var iterator: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &iterator, + )); + defer row_iterator_free(iterator); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&iterator))); + const dirty = false; + try testing.expectEqual(Result.invalid_value, row_set(iterator, .dirty, @ptrCast(&dirty))); +} + +test "render: row get before iteration" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var iterator: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &iterator, + )); + defer row_iterator_free(iterator); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&iterator))); + var dirty: bool = undefined; + try testing.expectEqual(Result.invalid_value, row_get(iterator, .dirty, @ptrCast(&dirty))); +} + +test "render: row get/set dirty" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + // Dirty the first row so the iterator has at least one dirty row to observe. + terminal_c.vt_write(terminal, "hello", 5); + try testing.expectEqual(Result.success, update(state, terminal)); + + // Create an iterator and verify it is dirty. + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + var dirty: bool = undefined; + try testing.expectEqual(Result.success, row_get(it, .dirty, @ptrCast(&dirty))); + try testing.expect(dirty); + + // Clear dirty on this row. + const dirty_false = false; + try testing.expectEqual(Result.success, row_set(it, .dirty, @ptrCast(&dirty_false))); + + // It should not be dirty anymore. + var it2: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it2, + )); + defer row_iterator_free(it2); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it2))); + try testing.expect(row_iterator_next(it2)); + try testing.expectEqual(Result.success, row_get(it2, .dirty, @ptrCast(&dirty))); + try testing.expect(!dirty); +} + +test "render: row iterator next" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var iterator: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &iterator, + )); + defer row_iterator_free(iterator); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&iterator))); + + const rows = state.?.state.rows; + if (rows == 0) { + try testing.expect(!row_iterator_next(iterator)); + return; + } + + try testing.expect(row_iterator_next(iterator)); + try testing.expectEqual(@as(?size.CellCountInt, 0), iterator.?.y); + + var i: size.CellCountInt = 1; + while (i < rows) : (i += 1) { + try testing.expect(row_iterator_next(iterator)); + try testing.expectEqual(@as(?size.CellCountInt, i), iterator.?.y); + } + + try testing.expect(!row_iterator_next(iterator)); + try testing.expectEqual(@as(?size.CellCountInt, rows - 1), iterator.?.y); +} + +test "render: update" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + terminal_c.vt_write(terminal, "hello", 5); + try testing.expectEqual(Result.success, update(state, terminal)); + + var cols: size.CellCountInt = 0; + var rows_val: size.CellCountInt = 0; + try testing.expectEqual(Result.success, get(state, .cols, @ptrCast(&cols))); + try testing.expectEqual(Result.success, get(state, .rows, @ptrCast(&rows_val))); + try testing.expectEqual(@as(size.CellCountInt, 80), cols); + try testing.expectEqual(@as(size.CellCountInt, 24), rows_val); +} + +test "render: colors get" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var colors: Colors = std.mem.zeroes(Colors); + colors.size = @sizeOf(Colors); + try testing.expectEqual(Result.success, colors_get(state, &colors)); + + const state_colors = &state.?.state.colors; + try testing.expectEqual(state_colors.background.cval(), colors.background); + try testing.expectEqual(state_colors.foreground.cval(), colors.foreground); + + if (state_colors.cursor) |cursor| { + try testing.expect(colors.cursor_has_value); + try testing.expectEqual(cursor.cval(), colors.cursor); + } else { + try testing.expect(!colors.cursor_has_value); + } + + for (state_colors.palette, colors.palette) |expected, actual| { + try testing.expectEqual(expected.cval(), actual); + } +} + +test "render: row cells bg_color no background" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + // Write plain text (no background color set). + terminal_c.vt_write(terminal, "hello", 5); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib.alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + try testing.expect(row_cells_next(cells)); + + // No background set, should return invalid_value. + var bg: colorpkg.RGB.C = undefined; + try testing.expectEqual(Result.invalid_value, row_cells_get(cells, .bg_color, @ptrCast(&bg))); +} + +test "render: row cells bg_color from style" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + // Set an RGB background via SGR 48;2;R;G;B and write text. + terminal_c.vt_write(terminal, "\x1b[48;2;10;20;30mA", 18); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib.alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + try testing.expect(row_cells_next(cells)); + + var bg: colorpkg.RGB.C = undefined; + try testing.expectEqual(Result.success, row_cells_get(cells, .bg_color, @ptrCast(&bg))); + try testing.expectEqual(@as(u8, 10), bg.r); + try testing.expectEqual(@as(u8, 20), bg.g); + try testing.expectEqual(@as(u8, 30), bg.b); +} + +test "render: row cells bg_color from content tag" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + // Set an RGB background and then erase the line. The erased cells + // should carry the background color via the content tag (bg_color_rgb) + // rather than through the style. + terminal_c.vt_write(terminal, "\x1b[48;2;10;20;30m\x1b[2K", 21); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib.alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + try testing.expect(row_cells_next(cells)); + + var bg: colorpkg.RGB.C = undefined; + try testing.expectEqual(Result.success, row_cells_get(cells, .bg_color, @ptrCast(&bg))); + try testing.expectEqual(@as(u8, 10), bg.r); + try testing.expectEqual(@as(u8, 20), bg.g); + try testing.expectEqual(@as(u8, 30), bg.b); +} + +test "render: row cells fg_color no foreground" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + // Write plain text (no foreground color set). + terminal_c.vt_write(terminal, "hello", 5); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib.alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + try testing.expect(row_cells_next(cells)); + + // No foreground set, should return invalid_value. + var fg: colorpkg.RGB.C = undefined; + try testing.expectEqual(Result.invalid_value, row_cells_get(cells, .fg_color, @ptrCast(&fg))); +} + +test "render: row cells fg_color from style" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + // Set an RGB foreground via SGR 38;2;R;G;B and write text. + terminal_c.vt_write(terminal, "\x1b[38;2;10;20;30mA", 18); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib.alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + try testing.expect(row_cells_next(cells)); + + var fg: colorpkg.RGB.C = undefined; + try testing.expectEqual(Result.success, row_cells_get(cells, .fg_color, @ptrCast(&fg))); + try testing.expectEqual(@as(u8, 10), fg.r); + try testing.expectEqual(@as(u8, 20), fg.g); + try testing.expectEqual(@as(u8, 30), fg.b); +} + +test "render: colors get supports truncated sized struct" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var colors: Colors = std.mem.zeroes(Colors); + const sentinel: colorpkg.RGB.C = .{ .r = 0xAA, .g = 0xBB, .b = 0xCC }; + for (&colors.palette) |*entry| entry.* = sentinel; + + colors.size = @offsetOf(Colors, "palette") + @sizeOf(colorpkg.RGB.C) * 2; + try testing.expectEqual(Result.success, colors_get(state, &colors)); + + const state_colors = &state.?.state.colors; + try testing.expectEqual(state_colors.palette[0].cval(), colors.palette[0]); + try testing.expectEqual(state_colors.palette[1].cval(), colors.palette[1]); + try testing.expectEqual(sentinel, colors.palette[2]); +} diff --git a/src/terminal/c/result.zig b/src/terminal/c/result.zig index e9b5fc5e6..b76326e46 100644 --- a/src/terminal/c/result.zig +++ b/src/terminal/c/result.zig @@ -3,4 +3,5 @@ pub const Result = enum(c_int) { success = 0, out_of_memory = -1, invalid_value = -2, + out_of_space = -3, }; diff --git a/src/terminal/c/row.zig b/src/terminal/c/row.zig new file mode 100644 index 000000000..d45327066 --- /dev/null +++ b/src/terminal/c/row.zig @@ -0,0 +1,132 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const page = @import("../page.zig"); +const Row = page.Row; +const Result = @import("result.zig").Result; + +/// C: GhosttyRow +pub const CRow = Row.C; + +/// C: GhosttyRowSemanticPrompt +pub const SemanticPrompt = enum(c_int) { + none = 0, + prompt = 1, + prompt_continuation = 2, +}; + +/// C: GhosttyRowData +pub const RowData = enum(c_int) { + invalid = 0, + + /// Whether this row is soft-wrapped. + /// Output type: bool * + wrap = 1, + + /// Whether this row is a continuation of a soft-wrapped row. + /// Output type: bool * + wrap_continuation = 2, + + /// Whether any cells in this row have grapheme clusters. + /// Output type: bool * + grapheme = 3, + + /// Whether any cells in this row have styling (may have false positives). + /// Output type: bool * + styled = 4, + + /// Whether any cells in this row have hyperlinks (may have false positives). + /// Output type: bool * + hyperlink = 5, + + /// The semantic prompt state of this row. + /// Output type: GhosttyRowSemanticPrompt * + semantic_prompt = 6, + + /// Whether this row contains a Kitty virtual placeholder. + /// Output type: bool * + kitty_virtual_placeholder = 7, + + /// Whether this row is dirty and requires a redraw. + /// Output type: bool * + dirty = 8, + + /// Output type expected for querying the data of the given kind. + pub fn OutType(comptime self: RowData) type { + return switch (self) { + .invalid => void, + .wrap, .wrap_continuation, .grapheme, .styled, .hyperlink => bool, + .kitty_virtual_placeholder, .dirty => bool, + .semantic_prompt => SemanticPrompt, + }; + } +}; + +pub fn get( + row_: CRow, + data: RowData, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(RowData, @intFromEnum(data)) catch { + return .invalid_value; + }; + } + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| getTyped( + row_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn getTyped( + row_: CRow, + comptime data: RowData, + out: *data.OutType(), +) Result { + const row: Row = @bitCast(row_); + switch (data) { + .invalid => return .invalid_value, + .wrap => out.* = row.wrap, + .wrap_continuation => out.* = row.wrap_continuation, + .grapheme => out.* = row.grapheme, + .styled => out.* = row.styled, + .hyperlink => out.* = row.hyperlink, + .semantic_prompt => out.* = @enumFromInt(@intFromEnum(row.semantic_prompt)), + .kitty_virtual_placeholder => out.* = row.kitty_virtual_placeholder, + .dirty => out.* = row.dirty, + } + + return .success; +} + +test "get wrap" { + var zig_row: Row = @bitCast(@as(u64, 0)); + zig_row.wrap = true; + const row: CRow = @bitCast(zig_row); + var wrap: bool = false; + try testing.expectEqual(Result.success, get(row, .wrap, @ptrCast(&wrap))); + try testing.expect(wrap); +} + +test "get semantic_prompt" { + var zig_row: Row = @bitCast(@as(u64, 0)); + zig_row.semantic_prompt = .prompt; + const row: CRow = @bitCast(zig_row); + var sp: SemanticPrompt = .none; + try testing.expectEqual(Result.success, get(row, .semantic_prompt, @ptrCast(&sp))); + try testing.expectEqual(SemanticPrompt.prompt, sp); +} + +test "get dirty" { + var zig_row: Row = @bitCast(@as(u64, 0)); + zig_row.dirty = true; + const row: CRow = @bitCast(zig_row); + var dirty: bool = false; + try testing.expectEqual(Result.success, get(row, .dirty, @ptrCast(&dirty))); + try testing.expect(dirty); +} diff --git a/src/terminal/c/sgr.zig b/src/terminal/c/sgr.zig index 53536417f..b0ef23d05 100644 --- a/src/terminal/c/sgr.zig +++ b/src/terminal/c/sgr.zig @@ -1,8 +1,8 @@ const std = @import("std"); const testing = std.testing; const Allocator = std.mem.Allocator; -const lib_alloc = @import("../../lib/allocator.zig"); -const CAllocator = lib_alloc.Allocator; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; const sgr = @import("../sgr.zig"); const Result = @import("result.zig").Result; @@ -20,8 +20,8 @@ pub const Parser = ?*ParserWrapper; pub fn new( alloc_: ?*const CAllocator, result: *Parser, -) callconv(.c) Result { - const alloc = lib_alloc.default(alloc_); +) callconv(lib.calling_conv) Result { + const alloc = lib.alloc.default(alloc_); const ptr = alloc.create(ParserWrapper) catch return .out_of_memory; ptr.* = .{ @@ -32,7 +32,7 @@ pub fn new( return .success; } -pub fn free(parser_: Parser) callconv(.c) void { +pub fn free(parser_: Parser) callconv(lib.calling_conv) void { const wrapper = parser_ orelse return; const alloc = wrapper.alloc; const parser: *sgr.Parser = &wrapper.parser; @@ -40,7 +40,7 @@ pub fn free(parser_: Parser) callconv(.c) void { alloc.destroy(wrapper); } -pub fn reset(parser_: Parser) callconv(.c) void { +pub fn reset(parser_: Parser) callconv(lib.calling_conv) void { const wrapper = parser_ orelse return; const parser: *sgr.Parser = &wrapper.parser; parser.idx = 0; @@ -51,7 +51,7 @@ pub fn setParams( params: [*]const u16, seps_: ?[*]const u8, len: usize, -) callconv(.c) Result { +) callconv(lib.calling_conv) Result { const wrapper = parser_ orelse return .invalid_value; const alloc = wrapper.alloc; const parser: *sgr.Parser = &wrapper.parser; @@ -87,7 +87,7 @@ pub fn setParams( pub fn next( parser_: Parser, result: *sgr.Attribute.C, -) callconv(.c) bool { +) callconv(lib.calling_conv) bool { const wrapper = parser_ orelse return false; const parser: *sgr.Parser = &wrapper.parser; if (parser.next()) |attr| { @@ -101,7 +101,7 @@ pub fn next( pub fn unknown_full( unknown: sgr.Attribute.Unknown.C, ptr: ?*[*]const u16, -) callconv(.c) usize { +) callconv(lib.calling_conv) usize { if (ptr) |p| p.* = unknown.full_ptr; return unknown.full_len; } @@ -109,30 +109,30 @@ pub fn unknown_full( pub fn unknown_partial( unknown: sgr.Attribute.Unknown.C, ptr: ?*[*]const u16, -) callconv(.c) usize { +) callconv(lib.calling_conv) usize { if (ptr) |p| p.* = unknown.partial_ptr; return unknown.partial_len; } pub fn attribute_tag( attr: sgr.Attribute.C, -) callconv(.c) sgr.Attribute.Tag { +) callconv(lib.calling_conv) sgr.Attribute.Tag { return attr.tag; } pub fn attribute_value( attr: *sgr.Attribute.C, -) callconv(.c) *sgr.Attribute.CValue { +) callconv(lib.calling_conv) *sgr.Attribute.CValue { return &attr.value; } -pub fn wasm_alloc_attribute() callconv(.c) *sgr.Attribute.C { +pub fn wasm_alloc_attribute() callconv(lib.calling_conv) *sgr.Attribute.C { const alloc = std.heap.wasm_allocator; const ptr = alloc.create(sgr.Attribute.C) catch @panic("out of memory"); return ptr; } -pub fn wasm_free_attribute(attr: *sgr.Attribute.C) callconv(.c) void { +pub fn wasm_free_attribute(attr: *sgr.Attribute.C) callconv(lib.calling_conv) void { const alloc = std.heap.wasm_allocator; alloc.destroy(attr); } @@ -140,7 +140,7 @@ pub fn wasm_free_attribute(attr: *sgr.Attribute.C) callconv(.c) void { test "alloc" { var p: Parser = undefined; try testing.expectEqual(Result.success, new( - &lib_alloc.test_allocator, + &lib.alloc.test_allocator, &p, )); free(p); @@ -149,7 +149,7 @@ test "alloc" { test "simple params, no seps" { var p: Parser = undefined; try testing.expectEqual(Result.success, new( - &lib_alloc.test_allocator, + &lib.alloc.test_allocator, &p, )); defer free(p); diff --git a/src/terminal/c/size_report.zig b/src/terminal/c/size_report.zig new file mode 100644 index 000000000..1c350c8f7 --- /dev/null +++ b/src/terminal/c/size_report.zig @@ -0,0 +1,82 @@ +const std = @import("std"); +const lib = @import("../lib.zig"); +const terminal_size_report = @import("../size_report.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttySizeReportStyle +pub const Style = terminal_size_report.Style; + +/// C: GhosttySizeReportSize +pub const Size = terminal_size_report.Size; + +pub fn encode( + style: Style, + size: Size, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(lib.calling_conv) Result { + var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{}); + terminal_size_report.encode(&writer, style, size) catch |err| switch (err) { + error.WriteFailed => { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + terminal_size_report.encode(&discarding.writer, style, size) catch unreachable; + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + }; + + out_written.* = writer.end; + return .success; +} + +test "encode mode 2048" { + var buf: [64]u8 = undefined; + var written: usize = 0; + const result = encode(.mode_2048, .{ + .rows = 24, + .columns = 80, + .cell_width = 9, + .cell_height = 18, + }, &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[48;24;80;432;720t", buf[0..written]); +} + +test "encode csi 14 t" { + var buf: [64]u8 = undefined; + var written: usize = 0; + const result = encode(.csi_14_t, .{ + .rows = 24, + .columns = 80, + .cell_width = 9, + .cell_height = 18, + }, &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1b[4;432;720t", buf[0..written]); +} + +test "encode with insufficient buffer" { + var buf: [1]u8 = undefined; + var written: usize = 0; + const result = encode(.csi_18_t, .{ + .rows = 24, + .columns = 80, + .cell_width = 9, + .cell_height = 18, + }, &buf, buf.len, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expect(written > 1); +} + +test "encode with null buffer" { + var written: usize = 0; + const result = encode(.csi_18_t, .{ + .rows = 24, + .columns = 80, + .cell_width = 9, + .cell_height = 18, + }, null, 0, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expect(written > 0); +} diff --git a/src/terminal/c/style.zig b/src/terminal/c/style.zig new file mode 100644 index 000000000..82729fa94 --- /dev/null +++ b/src/terminal/c/style.zig @@ -0,0 +1,134 @@ +const std = @import("std"); +const assert = std.debug.assert; +const testing = std.testing; +const lib = @import("../lib.zig"); +const style = @import("../style.zig"); +const color = @import("../color.zig"); +const sgr = @import("../sgr.zig"); + +/// C: GhosttyStyleColorTag +pub const ColorTag = enum(c_int) { + none = 0, + palette = 1, + rgb = 2, +}; + +/// C: GhosttyStyleColorValue +pub const ColorValue = extern union { + palette: u8, + rgb: color.RGB.C, + _padding: u64, +}; + +/// C: GhosttyStyleColor +pub const Color = extern struct { + tag: ColorTag, + value: ColorValue, + + pub fn fromColor(c: style.Style.Color) Color { + return switch (c) { + .none => .{ + .tag = .none, + .value = .{ ._padding = 0 }, + }, + .palette => |idx| .{ + .tag = .palette, + .value = .{ .palette = idx }, + }, + .rgb => |rgb| .{ + .tag = .rgb, + .value = .{ .rgb = rgb.cval() }, + }, + }; + } +}; + +/// C: GhosttyStyle +pub const Style = extern struct { + size: usize = @sizeOf(Style), + fg_color: Color, + bg_color: Color, + underline_color: Color, + bold: bool, + italic: bool, + faint: bool, + blink: bool, + inverse: bool, + invisible: bool, + strikethrough: bool, + overline: bool, + underline: c_int, + + pub fn fromStyle(s: style.Style) Style { + return .{ + .fg_color = .fromColor(s.fg_color), + .bg_color = .fromColor(s.bg_color), + .underline_color = .fromColor(s.underline_color), + .bold = s.flags.bold, + .italic = s.flags.italic, + .faint = s.flags.faint, + .blink = s.flags.blink, + .inverse = s.flags.inverse, + .invisible = s.flags.invisible, + .strikethrough = s.flags.strikethrough, + .overline = s.flags.overline, + .underline = @intFromEnum(s.flags.underline), + }; + } +}; + +/// Returns the default style. +pub fn default_style(result: *Style) callconv(lib.calling_conv) void { + result.* = .fromStyle(.{}); + assert(result.size == @sizeOf(Style)); +} + +/// Returns true if the style is the default style. +pub fn style_is_default(s: *const Style) callconv(lib.calling_conv) bool { + assert(s.size == @sizeOf(Style)); + return s.fg_color.tag == .none and + s.bg_color.tag == .none and + s.underline_color.tag == .none and + s.bold == false and + s.italic == false and + s.faint == false and + s.blink == false and + s.inverse == false and + s.invisible == false and + s.strikethrough == false and + s.overline == false and + s.underline == 0; +} + +test "default style" { + var s: Style = undefined; + default_style(&s); + try testing.expect(style_is_default(&s)); + try testing.expectEqual(ColorTag.none, s.fg_color.tag); + try testing.expectEqual(ColorTag.none, s.bg_color.tag); + try testing.expectEqual(ColorTag.none, s.underline_color.tag); + try testing.expect(!s.bold); + try testing.expect(!s.italic); + try testing.expectEqual(@as(c_int, 0), s.underline); +} + +test "convert style with colors" { + const zig_style: style.Style = .{ + .fg_color = .{ .palette = 42 }, + .bg_color = .{ .rgb = .{ .r = 255, .g = 128, .b = 64 } }, + .underline_color = .none, + .flags = .{ .bold = true, .underline = .curly }, + }; + + const c_style: Style = .fromStyle(zig_style); + try testing.expectEqual(ColorTag.palette, c_style.fg_color.tag); + try testing.expectEqual(@as(u8, 42), c_style.fg_color.value.palette); + try testing.expectEqual(ColorTag.rgb, c_style.bg_color.tag); + try testing.expectEqual(@as(u8, 255), c_style.bg_color.value.rgb.r); + try testing.expectEqual(@as(u8, 128), c_style.bg_color.value.rgb.g); + try testing.expectEqual(@as(u8, 64), c_style.bg_color.value.rgb.b); + try testing.expectEqual(ColorTag.none, c_style.underline_color.tag); + try testing.expect(c_style.bold); + try testing.expectEqual(@as(c_int, 3), c_style.underline); + try testing.expect(!style_is_default(&c_style)); +} diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig new file mode 100644 index 000000000..62d3f61c4 --- /dev/null +++ b/src/terminal/c/terminal.zig @@ -0,0 +1,2077 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const ZigTerminal = @import("../Terminal.zig"); +const Stream = @import("../stream_terminal.zig").Stream; +const ScreenSet = @import("../ScreenSet.zig"); +const PageList = @import("../PageList.zig"); +const kitty = @import("../kitty/key.zig"); +const modes = @import("../modes.zig"); +const point = @import("../point.zig"); +const size = @import("../size.zig"); +const device_attributes = @import("../device_attributes.zig"); +const device_status = @import("../device_status.zig"); +const size_report = @import("../size_report.zig"); +const cell_c = @import("cell.zig"); +const row_c = @import("row.zig"); +const grid_ref_c = @import("grid_ref.zig"); +const style_c = @import("style.zig"); +const Result = @import("result.zig").Result; + +const Handler = @import("../stream_terminal.zig").Handler; + +const log = std.log.scoped(.terminal_c); + +/// 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 { + terminal: *ZigTerminal, + stream: Stream, + effects: Effects = .{}, +}; + +/// 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. +const Effects = struct { + userdata: ?*anyopaque = null, + write_pty: ?WritePtyFn = null, + bell: ?BellFn = null, + color_scheme: ?ColorSchemeFn = null, + device_attributes_cb: ?DeviceAttributesFn = null, + enquiry: ?EnquiryFn = null, + xtversion: ?XtversionFn = null, + title_changed: ?TitleChangedFn = null, + size_cb: ?SizeFn = null, + + /// Scratch buffer for DA1 feature codes. The device attributes + /// trampoline converts C feature codes into this buffer and returns + /// a slice pointing into it. Storing it here ensures the slice + /// remains valid after the trampoline returns, since the caller + /// (`reportDeviceAttributes`) reads it before any re-entrant call. + da_features_buf: [64]device_attributes.Primary.Feature = undefined, + + /// C function pointer type for the write_pty callback. + pub const WritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(lib.calling_conv) void; + + /// C function pointer type for the bell callback. + pub const BellFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void; + + /// C function pointer type for the color_scheme callback. + /// Returns true and fills out_scheme if a color scheme is available, + /// or returns false to silently ignore the query. + pub const ColorSchemeFn = *const fn (Terminal, ?*anyopaque, *device_status.ColorScheme) callconv(lib.calling_conv) bool; + + /// C function pointer type for the enquiry callback. + /// Returns the response bytes. The memory must remain valid + /// until the callback returns. + pub const EnquiryFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) lib.String; + + /// C function pointer type for the xtversion callback. + /// Returns the version string (e.g. "ghostty 1.2.3"). The memory + /// must remain valid until the callback returns. An empty string + /// (len=0) causes the default "libghostty" to be reported. + pub const XtversionFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) lib.String; + + /// C function pointer type for the title_changed callback. + pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) 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. + pub const SizeFn = *const fn (Terminal, ?*anyopaque, *size_report.Size) callconv(lib.calling_conv) bool; + + /// C function pointer type for the device_attributes callback. + /// Returns true and fills out_attrs if attributes are available, + /// or returns false to silently ignore the query. + pub const DeviceAttributesFn = *const fn (Terminal, ?*anyopaque, *CDeviceAttributes) callconv(lib.calling_conv) bool; + + /// C-compatible device attributes struct. + /// C: GhosttyDeviceAttributes + pub const CDeviceAttributes = extern struct { + primary: Primary, + secondary: Secondary, + tertiary: Tertiary, + + pub const Primary = extern struct { + conformance_level: u16, + features: [64]u16, + num_features: usize, + }; + + pub const Secondary = extern struct { + device_type: u16, + firmware_version: u16, + rom_cartridge: u16, + }; + + pub const Tertiary = extern struct { + unit_id: u32, + }; + }; + + fn writePtyTrampoline(handler: *Handler, data: [:0]const u8) void { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.write_pty orelse return; + func(@ptrCast(wrapper), wrapper.effects.userdata, data.ptr, data.len); + } + + fn bellTrampoline(handler: *Handler) void { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.bell orelse return; + func(@ptrCast(wrapper), wrapper.effects.userdata); + } + + fn colorSchemeTrampoline(handler: *Handler) ?device_status.ColorScheme { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.color_scheme orelse return null; + var scheme: device_status.ColorScheme = undefined; + if (func(@ptrCast(wrapper), wrapper.effects.userdata, &scheme)) return scheme; + return null; + } + + fn deviceAttributesTrampoline(handler: *Handler) device_attributes.Attributes { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.device_attributes_cb orelse return .{}; + + // Get our attributes from the callback. + var c_attrs: CDeviceAttributes = undefined; + if (!func(@ptrCast(wrapper), wrapper.effects.userdata, &c_attrs)) return .{}; + + // Note below we use a lot of enumFromInt but its always safe + // because all our types are non-exhaustive enums. + + const n: usize = @min(c_attrs.primary.num_features, 64); + for (0..n) |i| wrapper.effects.da_features_buf[i] = @enumFromInt(c_attrs.primary.features[i]); + + return .{ + .primary = .{ + .conformance_level = @enumFromInt(c_attrs.primary.conformance_level), + .features = wrapper.effects.da_features_buf[0..n], + }, + .secondary = .{ + .device_type = @enumFromInt(c_attrs.secondary.device_type), + .firmware_version = c_attrs.secondary.firmware_version, + .rom_cartridge = c_attrs.secondary.rom_cartridge, + }, + .tertiary = .{ + .unit_id = c_attrs.tertiary.unit_id, + }, + }; + } + + fn enquiryTrampoline(handler: *Handler) []const u8 { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.enquiry orelse return ""; + const result = func(@ptrCast(wrapper), wrapper.effects.userdata); + if (result.len == 0) return ""; + return result.ptr[0..result.len]; + } + + fn xtversionTrampoline(handler: *Handler) []const u8 { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.xtversion orelse return ""; + const result = func(@ptrCast(wrapper), wrapper.effects.userdata); + if (result.len == 0) return ""; + return result.ptr[0..result.len]; + } + + fn titleChangedTrampoline(handler: *Handler) void { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.title_changed orelse return; + func(@ptrCast(wrapper), wrapper.effects.userdata); + } + + fn sizeTrampoline(handler: *Handler) ?size_report.Size { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.size_cb orelse return null; + var s: size_report.Size = undefined; + if (func(@ptrCast(wrapper), wrapper.effects.userdata, &s)) return s; + return null; + } +}; + +/// C: GhosttyTerminal +pub const Terminal = ?*TerminalWrapper; + +/// C: GhosttyTerminalOptions +pub const Options = extern struct { + cols: size.CellCountInt, + rows: size.CellCountInt, + max_scrollback: usize, +}; + +const NewError = error{ + InvalidValue, + OutOfMemory, +}; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Terminal, + opts: Options, +) callconv(lib.calling_conv) Result { + result.* = new_(alloc_, opts) catch |err| { + result.* = null; + return switch (err) { + error.InvalidValue => .invalid_value, + error.OutOfMemory => .out_of_memory, + }; + }; + + return .success; +} + +fn new_( + alloc_: ?*const CAllocator, + opts: Options, +) NewError!*TerminalWrapper { + if (opts.cols == 0 or opts.rows == 0) return error.InvalidValue; + + const alloc = lib.alloc.default(alloc_); + const t = alloc.create(ZigTerminal) catch + return error.OutOfMemory; + errdefer alloc.destroy(t); + + const wrapper = alloc.create(TerminalWrapper) catch + return error.OutOfMemory; + errdefer alloc.destroy(wrapper); + + // Setup our terminal + t.* = try .init(alloc, .{ + .cols = opts.cols, + .rows = opts.rows, + .max_scrollback = opts.max_scrollback, + }); + errdefer t.deinit(alloc); + + // 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, + .device_attributes = &Effects.deviceAttributesTrampoline, + .enquiry = &Effects.enquiryTrampoline, + .xtversion = &Effects.xtversionTrampoline, + .title_changed = &Effects.titleChangedTrampoline, + .size = &Effects.sizeTrampoline, + }; + + wrapper.* = .{ + .terminal = t, + .stream = .initAlloc(alloc, handler), + }; + + return wrapper; +} + +pub fn vt_write( + terminal_: Terminal, + ptr: [*]const u8, + len: usize, +) callconv(lib.calling_conv) void { + const wrapper = terminal_ orelse return; + wrapper.stream.nextSlice(ptr[0..len]); +} + +/// C: GhosttyTerminalOption +pub const Option = enum(c_int) { + userdata = 0, + write_pty = 1, + bell = 2, + enquiry = 3, + xtversion = 4, + title_changed = 5, + size_cb = 6, + color_scheme = 7, + device_attributes = 8, + title = 9, + pwd = 10, + + /// Input type expected for setting the option. + pub fn InType(comptime self: Option) type { + return switch (self) { + .userdata => ?*const anyopaque, + .write_pty => ?Effects.WritePtyFn, + .bell => ?Effects.BellFn, + .color_scheme => ?Effects.ColorSchemeFn, + .device_attributes => ?Effects.DeviceAttributesFn, + .enquiry => ?Effects.EnquiryFn, + .xtversion => ?Effects.XtversionFn, + .title_changed => ?Effects.TitleChangedFn, + .size_cb => ?Effects.SizeFn, + .title, .pwd => ?*const lib.String, + }; + } +}; + +pub fn set( + terminal_: Terminal, + option: Option, + value: ?*const anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Option, @intFromEnum(option)) catch { + log.warn("terminal_set invalid option value={d}", .{@intFromEnum(option)}); + return .invalid_value; + }; + } + + const wrapper = terminal_ orelse return .invalid_value; + + return switch (option) { + inline else => |comptime_option| setTyped( + wrapper, + comptime_option, + @ptrCast(@alignCast(value)), + ), + }; +} + +fn setTyped( + wrapper: *TerminalWrapper, + comptime option: Option, + value: option.InType(), +) Result { + switch (option) { + .userdata => wrapper.effects.userdata = @constCast(value), + .write_pty => wrapper.effects.write_pty = value, + .bell => wrapper.effects.bell = value, + .color_scheme => wrapper.effects.color_scheme = value, + .device_attributes => wrapper.effects.device_attributes_cb = value, + .enquiry => wrapper.effects.enquiry = value, + .xtversion => wrapper.effects.xtversion = value, + .title_changed => wrapper.effects.title_changed = value, + .size_cb => wrapper.effects.size_cb = value, + .title => { + const str = if (value) |v| v.ptr[0..v.len] else ""; + wrapper.terminal.setTitle(str) catch return .out_of_memory; + }, + .pwd => { + const str = if (value) |v| v.ptr[0..v.len] else ""; + wrapper.terminal.setPwd(str) catch return .out_of_memory; + }, + } + return .success; +} + +/// C: GhosttyTerminalScrollViewport +pub const ScrollViewport = ZigTerminal.ScrollViewport.C; + +pub fn scroll_viewport( + terminal_: Terminal, + behavior: ScrollViewport, +) callconv(lib.calling_conv) void { + const t: *ZigTerminal = (terminal_ orelse return).terminal; + t.scrollViewport(switch (behavior.tag) { + .top => .top, + .bottom => .bottom, + .delta => .{ .delta = behavior.value.delta }, + }); +} + +pub fn resize( + terminal_: Terminal, + cols: size.CellCountInt, + rows: size.CellCountInt, + cell_width_px: u32, + cell_height_px: u32, +) callconv(lib.calling_conv) Result { + const wrapper = terminal_ orelse return .invalid_value; + const t = wrapper.terminal; + if (cols == 0 or rows == 0) return .invalid_value; + t.resize(t.gpa(), cols, rows) catch return .out_of_memory; + + // Update pixel sizes + t.width_px = std.math.mul(u32, cols, cell_width_px) catch std.math.maxInt(u32); + t.height_px = std.math.mul(u32, rows, cell_height_px) catch std.math.maxInt(u32); + + // Disable synchronized output mode so that we show changes + // immediately for a resize. This is allowed by the spec. + t.modes.set(.synchronized_output, false); + + // If we have in-band size reporting enabled, send a report. + if (t.modes.get(.in_band_size_reports)) in_band: { + const func = wrapper.effects.write_pty orelse break :in_band; + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + size_report.encode(&writer, .mode_2048, .{ + .rows = rows, + .columns = cols, + .cell_width = cell_width_px, + .cell_height = cell_height_px, + }) catch break :in_band; + + const data = writer.buffered(); + func(@ptrCast(wrapper), wrapper.effects.userdata, data.ptr, data.len); + } + + return .success; +} + +pub fn reset(terminal_: Terminal) callconv(lib.calling_conv) void { + const t: *ZigTerminal = (terminal_ orelse return).terminal; + 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: GhosttyTerminalScreen +pub const TerminalScreen = ScreenSet.Key; + +/// C: GhosttyTerminalScrollbar +pub const TerminalScrollbar = PageList.Scrollbar.C; + +/// C: GhosttyTerminalData +pub const TerminalData = enum(c_int) { + invalid = 0, + cols = 1, + rows = 2, + cursor_x = 3, + cursor_y = 4, + cursor_pending_wrap = 5, + active_screen = 6, + cursor_visible = 7, + kitty_keyboard_flags = 8, + scrollbar = 9, + cursor_style = 10, + mouse_tracking = 11, + title = 12, + pwd = 13, + total_rows = 14, + scrollback_rows = 15, + width_px = 16, + height_px = 17, + + /// Output type expected for querying the data of the given kind. + pub fn OutType(comptime self: TerminalData) type { + return switch (self) { + .invalid => void, + .cols, .rows, .cursor_x, .cursor_y => size.CellCountInt, + .cursor_pending_wrap, .cursor_visible, .mouse_tracking => bool, + .active_screen => TerminalScreen, + .kitty_keyboard_flags => u8, + .scrollbar => TerminalScrollbar, + .cursor_style => style_c.Style, + .title, .pwd => lib.String, + .total_rows, .scrollback_rows => usize, + .width_px, .height_px => u32, + }; + } +}; + +pub fn get( + terminal_: Terminal, + data: TerminalData, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(TerminalData, @intFromEnum(data)) catch { + log.warn("terminal_get invalid data value={d}", .{@intFromEnum(data)}); + return .invalid_value; + }; + } + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| getTyped( + terminal_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn getTyped( + terminal_: Terminal, + comptime data: TerminalData, + out: *data.OutType(), +) Result { + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + switch (data) { + .invalid => return .invalid_value, + .cols => out.* = t.cols, + .rows => out.* = t.rows, + .cursor_x => out.* = t.screens.active.cursor.x, + .cursor_y => out.* = t.screens.active.cursor.y, + .cursor_pending_wrap => out.* = t.screens.active.cursor.pending_wrap, + .active_screen => out.* = t.screens.active_key, + .cursor_visible => out.* = t.modes.get(.cursor_visible), + .kitty_keyboard_flags => out.* = @as(u8, t.screens.active.kitty_keyboard.current().int()), + .scrollbar => out.* = t.screens.active.pages.scrollbar().cval(), + .cursor_style => out.* = .fromStyle(t.screens.active.cursor.style), + .mouse_tracking => out.* = t.modes.get(.mouse_event_x10) or + t.modes.get(.mouse_event_normal) or + t.modes.get(.mouse_event_button) or + t.modes.get(.mouse_event_any), + .title => { + const title = t.getTitle() orelse ""; + out.* = .{ .ptr = title.ptr, .len = title.len }; + }, + .pwd => { + const pwd = t.getPwd() orelse ""; + out.* = .{ .ptr = pwd.ptr, .len = pwd.len }; + }, + .total_rows => out.* = t.screens.active.pages.total_rows, + .scrollback_rows => out.* = t.screens.active.pages.total_rows - t.rows, + .width_px => out.* = t.width_px, + .height_px => out.* = t.height_px, + } + + return .success; +} + +pub fn grid_ref( + terminal_: Terminal, + pt: point.Point.C, + out_ref: ?*grid_ref_c.CGridRef, +) callconv(lib.calling_conv) Result { + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const zig_pt: point.Point = switch (pt.tag) { + .active => .{ .active = pt.value.active }, + .viewport => .{ .viewport = pt.value.viewport }, + .screen => .{ .screen = pt.value.screen }, + .history => .{ .history = pt.value.history }, + }; + const p = t.screens.active.pages.pin(zig_pt) orelse + return .invalid_value; + if (out_ref) |out| out.* = grid_ref_c.CGridRef.fromPin(p); + return .success; +} + +pub fn free(terminal_: Terminal) callconv(lib.calling_conv) void { + const wrapper = terminal_ orelse return; + const t = wrapper.terminal; + + wrapper.stream.deinit(); + const alloc = t.gpa(); + t.deinit(alloc); + alloc.destroy(t); + alloc.destroy(wrapper); +} + +test "new/free" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + + try testing.expect(t != null); + free(t); +} + +test "new invalid value" { + var t: Terminal = null; + + try testing.expectEqual(Result.invalid_value, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 0, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + try testing.expect(t == null); + + try testing.expectEqual(Result.invalid_value, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 0, + .max_scrollback = 10_000, + }, + )); + try testing.expect(t == null); +} + +test "free null" { + free(null); +} + +test "scroll_viewport" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 5, + .rows = 2, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + const zt = t.?.terminal; + + // Write "hello" on the first line + vt_write(t, "hello", 5); + + // Push "hello" into scrollback with 3 newlines (index = ESC D) + vt_write(t, "\x1bD\x1bD\x1bD", 6); + { + // Viewport should be empty now since hello scrolled off + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Scroll to top: "hello" should be visible again + scroll_viewport(t, .{ .tag = .top, .value = undefined }); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello", str); + } + + // Scroll to bottom: viewport should be empty again + scroll_viewport(t, .{ .tag = .bottom, .value = undefined }); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Scroll up by delta to bring "hello" back into view + scroll_viewport(t, .{ .tag = .delta, .value = .{ .delta = -3 } }); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello", str); + } +} + +test "scroll_viewport null" { + scroll_viewport(null, .{ .tag = .top, .value = undefined }); +} + +test "reset" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + vt_write(t, "Hello", 5); + reset(t); + + const str = try t.?.terminal.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); +} + +test "reset null" { + reset(null); +} + +test "resize" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + try testing.expectEqual(Result.success, resize(t, 40, 12, 9, 18)); + try testing.expectEqual(40, t.?.terminal.cols); + try testing.expectEqual(12, t.?.terminal.rows); +} + +test "resize null" { + try testing.expectEqual(Result.invalid_value, resize(null, 80, 24, 9, 18)); +} + +test "resize invalid value" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + try testing.expectEqual(Result.invalid_value, resize(t, 0, 24, 9, 18)); + try testing.expectEqual(Result.invalid_value, resize(t, 80, 0, 9, 18)); +} + +test "mode_get and mode_set" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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); + + // 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); + + // 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); + + try testing.expectEqual(Result.success, mode_set(t, insert, true)); + try testing.expectEqual(Result.success, mode_get(t, insert, &value)); + try testing.expect(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" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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)); +} + +test "mode_set unknown mode" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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)); +} + +test "vt_write" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + vt_write(t, "Hello", 5); + + const str = try t.?.terminal.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Hello", str); +} + +test "vt_write split escape sequence" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + // Write "Hello" in bold by splitting the CSI bold sequence across two writes. + // ESC [ 1 m = bold on, ESC [ 0 m = reset + // Split ESC from the rest of the CSI sequence. + vt_write(t, "Hello \x1b", 7); + vt_write(t, "[1mBold\x1b[0m", 10); + + const str = try t.?.terminal.plainString(testing.allocator); + defer testing.allocator.free(str); + // If the escape sequence leaked, we'd see "[1mBold" as literal text. + try testing.expectEqualStrings("Hello Bold", str); +} + +test "get cols and rows" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var cols: size.CellCountInt = undefined; + var rows: size.CellCountInt = undefined; + try testing.expectEqual(Result.success, get(t, .cols, @ptrCast(&cols))); + try testing.expectEqual(Result.success, get(t, .rows, @ptrCast(&rows))); + try testing.expectEqual(80, cols); + try testing.expectEqual(24, rows); +} + +test "get cursor position" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + vt_write(t, "Hello", 5); + + var x: size.CellCountInt = undefined; + var y: size.CellCountInt = undefined; + try testing.expectEqual(Result.success, get(t, .cursor_x, @ptrCast(&x))); + try testing.expectEqual(Result.success, get(t, .cursor_y, @ptrCast(&y))); + try testing.expectEqual(5, x); + try testing.expectEqual(0, y); +} + +test "get null" { + var cols: size.CellCountInt = undefined; + try testing.expectEqual(Result.invalid_value, get(null, .cols, @ptrCast(&cols))); +} + +test "get cursor_visible" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var visible: bool = undefined; + try testing.expectEqual(Result.success, get(t, .cursor_visible, @ptrCast(&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)); + try testing.expectEqual(Result.success, get(t, .cursor_visible, @ptrCast(&visible))); + try testing.expect(!visible); +} + +test "get active_screen" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var screen: TerminalScreen = undefined; + try testing.expectEqual(Result.success, get(t, .active_screen, @ptrCast(&screen))); + try testing.expectEqual(.primary, screen); +} + +test "get kitty_keyboard_flags" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var flags: u8 = undefined; + try testing.expectEqual(Result.success, get(t, .kitty_keyboard_flags, @ptrCast(&flags))); + try testing.expectEqual(0, flags); + + // Push kitty flags via VT sequence: CSI > 3 u (push disambiguate | report_events) + vt_write(t, "\x1b[>3u", 5); + + try testing.expectEqual(Result.success, get(t, .kitty_keyboard_flags, @ptrCast(&flags))); + try testing.expectEqual(3, flags); +} + +test "get mouse_tracking" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var tracking: bool = undefined; + try testing.expectEqual(Result.success, get(t, .mouse_tracking, @ptrCast(&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)); + 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)); + 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)); + 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)); + 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)); + try testing.expectEqual(Result.success, get(t, .mouse_tracking, @ptrCast(&tracking))); + try testing.expect(!tracking); +} + +test "get total_rows" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + var total: usize = undefined; + try testing.expectEqual(Result.success, get(t, .total_rows, @ptrCast(&total))); + try testing.expect(total >= 24); +} + +test "get scrollback_rows" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 3, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + var scrollback: usize = undefined; + try testing.expectEqual(Result.success, get(t, .scrollback_rows, @ptrCast(&scrollback))); + try testing.expectEqual(@as(usize, 0), scrollback); + + // Write enough lines to push content into scrollback + vt_write(t, "line1\r\nline2\r\nline3\r\nline4\r\nline5\r\n", 34); + + try testing.expectEqual(Result.success, get(t, .scrollback_rows, @ptrCast(&scrollback))); + try testing.expectEqual(@as(usize, 2), scrollback); +} + +test "get invalid" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + try testing.expectEqual(Result.invalid_value, get(t, .invalid, null)); +} + +test "grid_ref" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + vt_write(t, "Hello", 5); + + var out_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 0, .y = 0 } }, + }, &out_ref)); + + // Extract cell from grid ref and verify it contains 'H' + var out_cell: cell_c.CCell = undefined; + try testing.expectEqual(Result.success, grid_ref_c.grid_ref_cell(&out_ref, &out_cell)); + + var cp: u32 = 0; + try testing.expectEqual(Result.success, cell_c.get(out_cell, .codepoint, @ptrCast(&cp))); + try testing.expectEqual(@as(u32, 'H'), cp); +} + +test "grid_ref null terminal" { + var out_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.invalid_value, grid_ref(null, .{ + .tag = .active, + .value = .{ .active = .{ .x = 0, .y = 0 } }, + }, &out_ref)); +} + +test "set write_pty callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + var last_userdata: ?*anyopaque = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + last_userdata = null; + } + + fn writePty(_: Terminal, ud: ?*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"); + last_userdata = ud; + } + }; + defer S.deinit(); + + // Set userdata and write_pty callback + var sentinel: u8 = 42; + try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel))); + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + + // DECRQM for wraparound mode (mode 7, set by default) should trigger write_pty + vt_write(t, "\x1B[?7$p", 6); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1B[?7;1$y", S.last_data.?); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); +} + +test "set write_pty without callback ignores queries" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // Without setting a callback, DECRQM should be silently ignored (no crash) + vt_write(t, "\x1B[?7$p", 6); +} + +test "set write_pty null clears callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var called: bool = false; + fn writePty(_: Terminal, _: ?*anyopaque, _: [*]const u8, _: usize) callconv(lib.calling_conv) void { + called = true; + } + }; + S.called = false; + + // Set then clear the callback + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + try testing.expectEqual(Result.success, set(t, .write_pty, null)); + + vt_write(t, "\x1B[?7$p", 6); + try testing.expect(!S.called); +} + +test "set bell callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var bell_count: usize = 0; + var last_userdata: ?*anyopaque = null; + + fn bell(_: Terminal, ud: ?*anyopaque) callconv(lib.calling_conv) void { + bell_count += 1; + last_userdata = ud; + } + }; + S.bell_count = 0; + S.last_userdata = null; + + // Set userdata and bell callback + var sentinel: u8 = 99; + try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel))); + try testing.expectEqual(Result.success, set(t, .bell, @ptrCast(&S.bell))); + + // Single BEL + vt_write(t, "\x07", 1); + try testing.expectEqual(@as(usize, 1), S.bell_count); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); + + // Multiple BELs + vt_write(t, "\x07\x07", 2); + try testing.expectEqual(@as(usize, 3), S.bell_count); +} + +test "bell without callback is silent" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // BEL without a callback should not crash + vt_write(t, "\x07", 1); +} + +test "set enquiry callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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"); + } + + const response = "OK"; + fn enquiry(_: Terminal, _: ?*anyopaque) callconv(lib.calling_conv) lib.String { + return .{ .ptr = response, .len = response.len }; + } + }; + defer S.deinit(); + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + try testing.expectEqual(Result.success, set(t, .enquiry, @ptrCast(&S.enquiry))); + + // ENQ (0x05) should trigger the enquiry callback and write response via write_pty + vt_write(t, "\x05", 1); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("OK", S.last_data.?); +} + +test "enquiry without callback is silent" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // ENQ without a callback should not crash + vt_write(t, "\x05", 1); +} + +test "set xtversion callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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"); + } + + const version = "myterm 1.0"; + fn xtversion(_: Terminal, _: ?*anyopaque) callconv(lib.calling_conv) lib.String { + return .{ .ptr = version, .len = version.len }; + } + }; + defer S.deinit(); + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + try testing.expectEqual(Result.success, set(t, .xtversion, @ptrCast(&S.xtversion))); + + // XTVERSION: CSI > q + vt_write(t, "\x1B[>q", 4); + try testing.expect(S.last_data != null); + // Response should be DCS >| version ST + try testing.expectEqualStrings("\x1BP>|myterm 1.0\x1B\\", S.last_data.?); +} + +test "xtversion without callback reports default" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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(); + + // Set write_pty but not xtversion — should get default "libghostty" + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + + vt_write(t, "\x1B[>q", 4); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1BP>|libghostty\x1B\\", S.last_data.?); +} + +test "set title_changed callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var title_count: usize = 0; + var last_userdata: ?*anyopaque = null; + + fn titleChanged(_: Terminal, ud: ?*anyopaque) callconv(lib.calling_conv) void { + title_count += 1; + last_userdata = ud; + } + }; + S.title_count = 0; + S.last_userdata = null; + + var sentinel: u8 = 77; + try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel))); + try testing.expectEqual(Result.success, set(t, .title_changed, @ptrCast(&S.titleChanged))); + + // OSC 2 ; title ST — set window title + vt_write(t, "\x1B]2;Hello\x1B\\", 10); + try testing.expectEqual(@as(usize, 1), S.title_count); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); + + // Another title change + vt_write(t, "\x1B]2;World\x1B\\", 10); + try testing.expectEqual(@as(usize, 2), S.title_count); +} + +test "title_changed without callback is silent" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // OSC 2 without a callback should not crash + vt_write(t, "\x1B]2;Hello\x1B\\", 10); +} + +test "set size callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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"); + } + + fn sizeCb(_: Terminal, _: ?*anyopaque, out_size: *size_report.Size) callconv(lib.calling_conv) bool { + out_size.* = .{ + .rows = 24, + .columns = 80, + .cell_width = 8, + .cell_height = 16, + }; + return true; + } + }; + defer S.deinit(); + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + try testing.expectEqual(Result.success, set(t, .size_cb, @ptrCast(&S.sizeCb))); + + // CSI 18 t — report text area size in characters + vt_write(t, "\x1B[18t", 5); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1b[8;24;80t", S.last_data.?); +} + +test "size without callback is silent" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // CSI 18 t without a size callback should not crash + vt_write(t, "\x1B[18t", 5); +} + +test "set device_attributes callback primary" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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"); + } + + fn da(_: Terminal, _: ?*anyopaque, out: *Effects.CDeviceAttributes) callconv(lib.calling_conv) bool { + out.* = .{ + .primary = .{ + .conformance_level = 64, + .features = .{ 22, 52 } ++ .{0} ** 62, + .num_features = 2, + }, + .secondary = .{ + .device_type = 1, + .firmware_version = 10, + .rom_cartridge = 0, + }, + .tertiary = .{ .unit_id = 0 }, + }; + return true; + } + }; + defer S.deinit(); + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + try testing.expectEqual(Result.success, set(t, .device_attributes, @ptrCast(&S.da))); + + // CSI c — primary DA + vt_write(t, "\x1B[c", 3); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1b[?64;22;52c", S.last_data.?); +} + +test "set device_attributes callback secondary" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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"); + } + + fn da(_: Terminal, _: ?*anyopaque, out: *Effects.CDeviceAttributes) callconv(lib.calling_conv) bool { + out.* = .{ + .primary = .{ + .conformance_level = 62, + .features = .{22} ++ .{0} ** 63, + .num_features = 1, + }, + .secondary = .{ + .device_type = 1, + .firmware_version = 10, + .rom_cartridge = 0, + }, + .tertiary = .{ .unit_id = 0 }, + }; + return true; + } + }; + defer S.deinit(); + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + try testing.expectEqual(Result.success, set(t, .device_attributes, @ptrCast(&S.da))); + + // CSI > c — secondary DA + vt_write(t, "\x1B[>c", 4); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1b[>1;10;0c", S.last_data.?); +} + +test "set device_attributes callback tertiary" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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"); + } + + fn da(_: Terminal, _: ?*anyopaque, out: *Effects.CDeviceAttributes) callconv(lib.calling_conv) bool { + out.* = .{ + .primary = .{ + .conformance_level = 62, + .features = .{0} ** 64, + .num_features = 0, + }, + .secondary = .{ + .device_type = 1, + .firmware_version = 0, + .rom_cartridge = 0, + }, + .tertiary = .{ .unit_id = 0xAABBCCDD }, + }; + return true; + } + }; + defer S.deinit(); + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + try testing.expectEqual(Result.success, set(t, .device_attributes, @ptrCast(&S.da))); + + // CSI = c — tertiary DA + vt_write(t, "\x1B[=c", 4); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1bP!|AABBCCDD\x1b\\", S.last_data.?); +} + +test "device_attributes without callback uses default" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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))); + + // Without setting a device_attributes callback, DA1 should return the default + vt_write(t, "\x1B[c", 3); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1b[?62;22c", S.last_data.?); +} + +test "device_attributes callback returns false uses default" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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"); + } + + fn da(_: Terminal, _: ?*anyopaque, _: *Effects.CDeviceAttributes) callconv(lib.calling_conv) bool { + return false; + } + }; + defer S.deinit(); + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + try testing.expectEqual(Result.success, set(t, .device_attributes, @ptrCast(&S.da))); + + // Callback returns false, should use default response + vt_write(t, "\x1B[c", 3); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1b[?62;22c", S.last_data.?); +} + +test "set and get title" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // No title set yet — should return empty string + var title: lib.String = undefined; + try testing.expectEqual(Result.success, get(t, .title, @ptrCast(&title))); + try testing.expectEqual(@as(usize, 0), title.len); + + // Set title via option + const hello: lib.String = .{ .ptr = "Hello", .len = 5 }; + try testing.expectEqual(Result.success, set(t, .title, @ptrCast(&hello))); + + try testing.expectEqual(Result.success, get(t, .title, @ptrCast(&title))); + try testing.expectEqualStrings("Hello", title.ptr[0..title.len]); + + // Overwrite title + const world: lib.String = .{ .ptr = "World", .len = 5 }; + try testing.expectEqual(Result.success, set(t, .title, @ptrCast(&world))); + + try testing.expectEqual(Result.success, get(t, .title, @ptrCast(&title))); + try testing.expectEqualStrings("World", title.ptr[0..title.len]); + + // Clear title with NULL + try testing.expectEqual(Result.success, set(t, .title, null)); + + try testing.expectEqual(Result.success, get(t, .title, @ptrCast(&title))); + try testing.expectEqual(@as(usize, 0), title.len); +} + +test "set and get pwd" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // No pwd set yet — should return empty string + var pwd: lib.String = undefined; + try testing.expectEqual(Result.success, get(t, .pwd, @ptrCast(&pwd))); + try testing.expectEqual(@as(usize, 0), pwd.len); + + // Set pwd via option + const home: lib.String = .{ .ptr = "/home/user", .len = 10 }; + try testing.expectEqual(Result.success, set(t, .pwd, @ptrCast(&home))); + + try testing.expectEqual(Result.success, get(t, .pwd, @ptrCast(&pwd))); + try testing.expectEqualStrings("/home/user", pwd.ptr[0..pwd.len]); + + // Clear pwd with NULL + try testing.expectEqual(Result.success, set(t, .pwd, null)); + + try testing.expectEqual(Result.success, get(t, .pwd, @ptrCast(&pwd))); + try testing.expectEqual(@as(usize, 0), pwd.len); +} + +test "get title set via vt_write" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // Set title via OSC 2 + vt_write(t, "\x1B]2;VT Title\x1B\\", 14); + + var title: lib.String = undefined; + try testing.expectEqual(Result.success, get(t, .title, @ptrCast(&title))); + try testing.expectEqualStrings("VT Title", title.ptr[0..title.len]); +} + +test "resize updates pixel dimensions" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + try testing.expectEqual(Result.success, resize(t, 100, 40, 9, 18)); + + const zt = t.?.terminal; + try testing.expectEqual(@as(u32, 100 * 9), zt.width_px); + try testing.expectEqual(@as(u32, 40 * 18), zt.height_px); +} + +test "resize pixel overflow saturates" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + try testing.expectEqual(Result.success, resize(t, 100, 40, std.math.maxInt(u32), std.math.maxInt(u32))); + + const zt = t.?.terminal; + try testing.expectEqual(std.math.maxInt(u32), zt.width_px); + try testing.expectEqual(std.math.maxInt(u32), zt.height_px); +} + +test "resize disables synchronized output" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const zt = t.?.terminal; + zt.modes.set(.synchronized_output, true); + + try testing.expectEqual(Result.success, resize(t, 100, 40, 9, 18)); + try testing.expect(!zt.modes.get(.synchronized_output)); +} + +test "resize sends in-band size report" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + 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))); + + // Enable in-band size reports (mode 2048) + t.?.terminal.modes.set(.in_band_size_reports, true); + + try testing.expectEqual(Result.success, resize(t, 100, 40, 9, 18)); + + // Expected: \x1B[48;rows;cols;height_px;width_pxt + // height_px = 40*18 = 720, width_px = 100*9 = 900 + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1B[48;40;100;720;900t", S.last_data.?); +} + +test "resize no size report without mode 2048" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var called: bool = false; + fn writePty(_: Terminal, _: ?*anyopaque, _: [*]const u8, _: usize) callconv(lib.calling_conv) void { + called = true; + } + }; + S.called = false; + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + + // in_band_size_reports is off by default + try testing.expectEqual(Result.success, resize(t, 100, 40, 9, 18)); + try testing.expect(!S.called); +} + +test "resize in-band report without write_pty callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // Enable mode 2048 but don't set a write_pty callback — should not crash + t.?.terminal.modes.set(.in_band_size_reports, true); + try testing.expectEqual(Result.success, resize(t, 100, 40, 9, 18)); +} + +test "resize null terminal" { + try testing.expectEqual(Result.invalid_value, resize(null, 100, 40, 9, 18)); +} + +test "resize zero cols" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + try testing.expectEqual(Result.invalid_value, resize(t, 0, 40, 9, 18)); +} + +test "resize zero rows" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + try testing.expectEqual(Result.invalid_value, resize(t, 100, 0, 9, 18)); +} + +test "grid_ref out of bounds" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var out_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.invalid_value, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 100, .y = 0 } }, + }, &out_ref)); +} diff --git a/src/terminal/csi.zig b/src/terminal/csi.zig index d2f4bd6f8..2491a2341 100644 --- a/src/terminal/csi.zig +++ b/src/terminal/csi.zig @@ -1,6 +1,4 @@ -const build_options = @import("terminal_options"); -const lib = @import("../lib/main.zig"); -const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; +const lib = @import("lib.zig"); /// Modes for the ED CSI command. pub const EraseDisplay = enum(u8) { @@ -38,7 +36,7 @@ pub const TabClear = enum(u8) { /// Style formats for terminal size reports. pub const SizeReportStyle = lib.Enum( - lib_target, + lib.target, &.{ // XTWINOPS "csi_14_t", diff --git a/src/terminal/device_attributes.zig b/src/terminal/device_attributes.zig new file mode 100644 index 000000000..38d081175 --- /dev/null +++ b/src/terminal/device_attributes.zig @@ -0,0 +1,227 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("lib.zig"); + +/// The device attribute request type (CSI c). +pub const Req = lib.Enum(lib.target, &.{ + "primary", // Blank + "secondary", // > + "tertiary", // = +}); + +/// Response data for all device attribute queries. +pub const Attributes = struct { + /// Reply to CSI c (DA1). + primary: Primary = .{}, + + /// Reply to CSI > c (DA2). + secondary: Secondary = .{}, + + /// Reply to CSI = c (DA3). + tertiary: Tertiary = .{}, + + /// Encode the response for the given request type into the writer. + pub fn encode( + self: Attributes, + req: Req, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + switch (req) { + .primary => try self.primary.encode(writer), + .secondary => try self.secondary.encode(writer), + .tertiary => try self.tertiary.encode(writer), + } + } +}; + +/// Primary device attributes (DA1). +/// +/// Response format: CSI ? Pp ; Ps... c +/// where Pp is the conformance level and Ps are feature flags. +pub const Primary = struct { + /// Conformance level sent as the first parameter. + conformance_level: ConformanceLevel = .vt220, + + /// Optional feature attributes. + features: []const Feature = &.{.ansi_color}, + + /// DA1 feature attribute codes. + pub const Feature = enum(u16) { + columns_132 = 1, + printer = 2, + regis = 3, + sixel = 4, + selective_erase = 6, + user_defined_keys = 8, + national_replacement = 9, + technical_characters = 15, + locator = 16, + terminal_state = 17, + windowing = 18, + horizontal_scrolling = 21, + ansi_color = 22, + rectangular_editing = 28, + ansi_text_locator = 29, + clipboard = 52, + _, + }; + + /// Encode the primary DA response into the writer. + pub fn encode(self: Primary, writer: *std.Io.Writer) std.Io.Writer.Error!void { + try writer.print("\x1b[?{}", .{@intFromEnum(self.conformance_level)}); + for (self.features) |feature| try writer.print(";{}", .{@intFromEnum(feature)}); + try writer.writeAll("c"); + } +}; + +/// Secondary device attributes (DA2). +/// +/// Response format: CSI > Pp ; Pv ; Pc c +pub const Secondary = struct { + /// Terminal type identifier (Pp parameter from secondary DA response). + device_type: DeviceType = .vt220, + + /// Firmware/patch version number. + firmware_version: u16 = 0, + + /// ROM cartridge registration number. Always 0 for emulators. + rom_cartridge: u16 = 0, + + /// Encode the secondary DA response into the writer. + pub fn encode(self: Secondary, writer: *std.Io.Writer) std.Io.Writer.Error!void { + try writer.print("\x1b[>{};{};{}c", .{ + @intFromEnum(self.device_type), + self.firmware_version, + self.rom_cartridge, + }); + } +}; + +/// Tertiary device attributes (DA3). +/// +/// Response format: DCS ! | D...D ST +/// where D...D is the unit ID as hex digits (DECRPTUI). +pub const Tertiary = struct { + /// Unit ID (DECRPTUI). Encoded as 8 uppercase hex digits. + /// Meaningless for emulators nowadays. The actual DEC manuals + /// appear to split this into two 16-bit fields but since there + /// is no practical usage I know if I'm simplifying this. + unit_id: u32 = 0, + + /// Encode the tertiary DA response into the writer. + pub fn encode( + self: Tertiary, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + try writer.print( + "\x1bP!|{X:0>8}\x1b\\", + .{self.unit_id}, + ); + } +}; + +/// Conformance level reported as the first parameter (Pp) in the +/// primary device attributes (DA1) response. +pub const ConformanceLevel = enum(u16) { + // VT100-series have per-model values. + vt100 = 1, + vt132 = 4, + vt102 = 6, + vt131 = 7, + vt125 = 12, + + // VT200+ use 60 + decTerminalID/100. + /// Level 2 conformance (VT200 series, e.g. VT220, VT240). + level_2 = 62, + /// Level 3 conformance (VT300 series, e.g. VT320, VT340). + level_3 = 63, + /// Level 4 conformance (VT400 series, e.g. VT420). + level_4 = 64, + /// Level 5 conformance (VT500 series, e.g. VT510, VT520, VT525). + level_5 = 65, + + _, + + pub const vt101 = ConformanceLevel.vt100; + pub const vt220 = ConformanceLevel.level_2; + pub const vt240 = ConformanceLevel.level_2; + pub const vt320 = ConformanceLevel.level_3; + pub const vt340 = ConformanceLevel.level_3; + pub const vt420 = ConformanceLevel.level_4; + pub const vt510 = ConformanceLevel.level_5; + pub const vt520 = ConformanceLevel.level_5; + pub const vt525 = ConformanceLevel.level_5; +}; + +/// Terminal type identifier reported as the Pp parameter in the +/// secondary device attributes (DA2) response. Values correspond +/// to the decTerminalID resource in xterm. +pub const DeviceType = enum(u16) { + vt100 = 0, + vt220 = 1, + vt240 = 2, + vt330 = 18, + vt340 = 19, + vt320 = 24, + vt382 = 32, + vt420 = 41, + vt510 = 61, + vt520 = 64, + vt525 = 65, + _, +}; + +test "primary default" { + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (Primary{}).encode(&writer); + try testing.expectEqualStrings("\x1b[?62;22c", writer.buffered()); +} + +test "primary with clipboard" { + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (Primary{ .features = &.{ .ansi_color, .clipboard } }).encode(&writer); + try testing.expectEqualStrings("\x1b[?62;22;52c", writer.buffered()); +} + +test "primary with multiple features" { + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (Primary{ + .conformance_level = .vt420, + .features = &.{ .columns_132, .selective_erase, .ansi_color }, + }).encode(&writer); + try testing.expectEqualStrings("\x1b[?64;1;6;22c", writer.buffered()); +} + +test "primary no features" { + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (Primary{ + .conformance_level = .vt100, + .features = &.{}, + }).encode(&writer); + try testing.expectEqualStrings("\x1b[?1c", writer.buffered()); +} + +test "secondary default" { + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (Secondary{}).encode(&writer); + try testing.expectEqualStrings("\x1b[>1;0;0c", writer.buffered()); +} + +test "tertiary default" { + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (Tertiary{}).encode(&writer); + try testing.expectEqualStrings("\x1bP!|00000000\x1b\\", writer.buffered()); +} + +test "tertiary custom unit id" { + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (Tertiary{ .unit_id = 0xAABBCCDD }).encode(&writer); + try testing.expectEqualStrings("\x1bP!|AABBCCDD\x1b\\", writer.buffered()); +} diff --git a/src/terminal/device_status.zig b/src/terminal/device_status.zig index 1be7bf719..42b4bf01b 100644 --- a/src/terminal/device_status.zig +++ b/src/terminal/device_status.zig @@ -1,4 +1,11 @@ const std = @import("std"); +const lib = @import("lib.zig"); + +/// The color scheme reported in response to a CSI ? 996 n query. +pub const ColorScheme = lib.Enum(lib.target, &.{ + "light", + "dark", +}); /// An enum(u16) of the available device status requests. pub const Request = dsr_enum: { diff --git a/src/terminal/focus.zig b/src/terminal/focus.zig new file mode 100644 index 000000000..91c579e12 --- /dev/null +++ b/src/terminal/focus.zig @@ -0,0 +1,39 @@ +const std = @import("std"); +const lib = @import("lib.zig"); + +/// Maximum number of bytes that `encode` will write. Any users of this +/// should be resilient to this changing, so this is always a specific +/// value (e.g. we don't add unnecessary padding). +pub const max_encode_size = 3; + +/// A focus event that can be reported to the application running in the +/// terminal when focus reporting mode (mode 1004) is enabled. +pub const Event = lib.Enum(lib.target, &.{ + "gained", + "lost", +}); + +/// Encode a focus in/out report (CSI I / CSI O). +pub fn encode( + writer: *std.Io.Writer, + event: Event, +) std.Io.Writer.Error!void { + try writer.writeAll(switch (event) { + .gained => "\x1B[I", + .lost => "\x1B[O", + }); +} + +test "encode focus gained" { + var buf: [max_encode_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, .gained); + try std.testing.expectEqualStrings("\x1B[I", writer.buffered()); +} + +test "encode focus lost" { + var buf: [max_encode_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, .lost); + try std.testing.expectEqualStrings("\x1B[O", writer.buffered()); +} diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 062e3969a..a375b4dd7 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 lib = @import("lib.zig"); const Allocator = std.mem.Allocator; const color = @import("color.zig"); const size = @import("size.zig"); @@ -19,46 +20,47 @@ const Selection = @import("Selection.zig"); const Style = @import("style.zig").Style; /// Formats available. -pub const Format = enum { - /// Plain text. - plain, +pub const Format = lib.Enum(lib.target, &.{ + // Plain text. + "plain", - /// Include VT sequences to preserve colors, styles, URLs, etc. - /// This is predominantly SGR sequences but may contain others as needed. - /// - /// Note that for reference colors, like palette indices, this will - /// vary based on the formatter and you should see the docs. For example, - /// PageFormatter with VT will emit SGR sequences with palette indices, - /// not the color itself. - /// - /// For VT, newlines will be emitted as `\r\n` so that the cursor properly - /// moves back to the beginning prior emitting follow-up lines. - vt, + // Include VT sequences to preserve colors, styles, URLs, etc. + // This is predominantly SGR sequences but may contain others as needed. + // + // Note that for reference colors, like palette indices, this will + // vary based on the formatter and you should see the docs. For example, + // PageFormatter with VT will emit SGR sequences with palette indices, + // not the color itself. + // + // For VT, newlines will be emitted as `\r\n` so that the cursor properly + // moves back to the beginning prior emitting follow-up lines. + "vt", - /// HTML output. - /// - /// This will emit inline styles for as much styling as possible, - /// in the interest of simplicity and ease of editing. This isn't meant - /// to build the most beautiful or efficient HTML, but rather to be - /// stylistically correct. - /// - /// For colors, RGB values are emitted as inline CSS (#RRGGBB) while palette - /// indices use CSS variables (var(--vt-palette-N)). The palette colors are - /// emitted by TerminalFormatter.Extra.palette as a <style> block if you - /// want to also include that. But if you only format a screen or lower, - /// the formatter doesn't have access to the current palette to render it. - /// - /// Newlines are emitted as actual '\n' characters. Consumers should use - /// CSS white-space: pre or pre-wrap to preserve spacing and alignment. - html, + // HTML output. + // + // This will emit inline styles for as much styling as possible, + // in the interest of simplicity and ease of editing. This isn't meant + // to build the most beautiful or efficient HTML, but rather to be + // stylistically correct. + // + // For colors, RGB values are emitted as inline CSS (#RRGGBB) while palette + // indices use CSS variables (var(--vt-palette-N)). The palette colors are + // emitted by TerminalFormatter.Extra.palette as a <style> block if you + // want to also include that. But if you only format a screen or lower, + // the formatter doesn't have access to the current palette to render it. + // + // Newlines are emitted as actual '\n' characters. Consumers should use + // CSS white-space: pre or pre-wrap to preserve spacing and alignment. + "html", +}); - pub fn styled(self: Format) bool { - return switch (self) { - .plain => false, - .html, .vt => true, - }; - } -}; +/// Returns true if the format emits styled output (not plaintext). +pub fn formatStyled(fmt: Format) bool { + return switch (fmt) { + .plain => false, + .html, .vt => true, + }; +} pub const CodepointMap = struct { /// Unicode codepoint range to replace. @@ -289,7 +291,7 @@ pub const TerminalFormatter = struct { m.map.appendNTimes( m.alloc, self.terminal.screens.active.pages.getTopLeft(.screen), - discarding.count, + std.math.cast(usize, discarding.count) orelse return error.WriteFailed, ) catch return error.WriteFailed; } } @@ -327,7 +329,7 @@ pub const TerminalFormatter = struct { m.map.appendNTimes( m.alloc, self.terminal.screens.active.pages.getTopLeft(.screen), - discarding.count, + std.math.cast(usize, discarding.count) orelse return error.WriteFailed, ) catch return error.WriteFailed; } } @@ -411,7 +413,7 @@ pub const TerminalFormatter = struct { .y = last.y, }; } else self.terminal.screens.active.pages.getTopLeft(.screen), - discarding.count, + std.math.cast(usize, discarding.count) orelse return error.WriteFailed, ) catch return error.WriteFailed; } } @@ -684,7 +686,7 @@ pub const ScreenFormatter = struct { .y = last.y, }; } else self.screen.pages.getTopLeft(.screen), - discarding.count, + std.math.cast(usize, discarding.count) orelse return error.WriteFailed, ) catch return error.WriteFailed; } } @@ -1130,7 +1132,7 @@ 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 (self.opts.emit.styled() and + if (formatStyled(self.opts.emit) and (!cell.isEmpty() or cell.hasStyling())) break :blank; // Cells with no text are blank @@ -1186,7 +1188,7 @@ pub const PageFormatter = struct { style: { // If we aren't emitting styled output then we don't // have to worry about styles. - if (!self.opts.emit.styled()) break :style; + if (!formatStyled(self.opts.emit)) break :style; // Get our cell style. const cell_style = self.cellStyle(cell); @@ -1230,7 +1232,10 @@ pub const PageFormatter = struct { &discarding.writer, &style, ); - for (0..discarding.count) |_| map.map.append(map.alloc, .{ + 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; @@ -1287,7 +1292,10 @@ pub const PageFormatter = struct { &discarding.writer, uri, ); - for (0..discarding.count) |_| map.map.append(map.alloc, .{ + 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; @@ -1305,7 +1313,10 @@ pub const PageFormatter = struct { if (self.point_map) |*map| { var discarding: std.Io.Writer.Discarding = .init(&.{}); try self.writeCell(tag, &discarding.writer, cell); - for (0..discarding.count) |_| map.map.append(map.alloc, .{ + 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; @@ -1593,7 +1604,7 @@ test "Page plain single line" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello, world"); + s.nextSlice("hello, world"); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -1640,7 +1651,7 @@ test "Page plain single line soft-wrapped unwrapped" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello!"); + s.nextSlice("hello!"); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -1710,7 +1721,7 @@ test "Page plain single wide char" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("1A⚡"); + s.nextSlice("1A⚡"); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -1801,7 +1812,7 @@ test "Page plain single wide char soft-wrapped unwrapped" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("1A⚡"); + s.nextSlice("1A⚡"); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -1918,7 +1929,7 @@ test "Page plain multiline" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld"); + s.nextSlice("hello\r\nworld"); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -1969,7 +1980,7 @@ test "Page plain multiline rectangle" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld"); + s.nextSlice("hello\r\nworld"); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -2023,7 +2034,7 @@ test "Page plain multi blank lines" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\n\r\n\r\nworld"); + s.nextSlice("hello\r\n\r\n\r\nworld"); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -2076,7 +2087,7 @@ test "Page plain trailing blank lines" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld\r\n\r\n"); + s.nextSlice("hello\r\nworld\r\n\r\n"); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -2129,7 +2140,7 @@ test "Page plain trailing whitespace" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello \r\nworld "); + s.nextSlice("hello \r\nworld "); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -2182,7 +2193,7 @@ test "Page plain trailing whitespace no trim" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello \r\nworld "); + s.nextSlice("hello \r\nworld "); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -2238,7 +2249,7 @@ test "Page plain with prior trailing state rows" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); @@ -2284,7 +2295,7 @@ test "Page plain with prior trailing state cells no wrapped line" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); @@ -2329,7 +2340,7 @@ test "Page plain with prior trailing state cells with wrap continuation" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("world"); + s.nextSlice("world"); const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); @@ -2383,7 +2394,7 @@ test "Page plain soft-wrapped without unwrap" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world test"); + s.nextSlice("hello world test"); const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); @@ -2432,7 +2443,7 @@ test "Page plain soft-wrapped with unwrap" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world test"); + s.nextSlice("hello world test"); const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); @@ -2480,7 +2491,7 @@ test "Page plain soft-wrapped 3 lines without unwrap" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world this is a test"); + s.nextSlice("hello world this is a test"); const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); @@ -2534,7 +2545,7 @@ test "Page plain soft-wrapped 3 lines with unwrap" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world this is a test"); + s.nextSlice("hello world this is a test"); const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); @@ -2586,7 +2597,7 @@ test "Page plain start_y subset" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld\r\ntest"); + s.nextSlice("hello\r\nworld\r\ntest"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -2633,7 +2644,7 @@ test "Page plain end_y subset" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld\r\ntest"); + s.nextSlice("hello\r\nworld\r\ntest"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -2680,7 +2691,7 @@ test "Page plain start_y and end_y range" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld\r\ntest\r\nfoo"); + s.nextSlice("hello\r\nworld\r\ntest\r\nfoo"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -2728,7 +2739,7 @@ test "Page plain start_y out of bounds" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -2766,7 +2777,7 @@ test "Page plain end_y greater than rows" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -2809,7 +2820,7 @@ test "Page plain end_y less than start_y" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -2848,7 +2859,7 @@ test "Page plain start_x on first row only" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world"); + s.nextSlice("hello world"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -2890,7 +2901,7 @@ test "Page plain end_x on last row only" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("first line\r\nsecond line\r\nthird line"); + s.nextSlice("first line\r\nsecond line\r\nthird line"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -2943,7 +2954,7 @@ test "Page plain start_x and end_x multiline" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world\r\ntest case\r\nfoo bar"); + s.nextSlice("hello world\r\ntest case\r\nfoo bar"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3000,7 +3011,7 @@ test "Page plain start_x out of bounds" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3038,7 +3049,7 @@ test "Page plain end_x greater than cols" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3080,7 +3091,7 @@ test "Page plain end_x less than start_x single row" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3120,7 +3131,7 @@ test "Page plain start_y non-zero ignores trailing state" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld"); + s.nextSlice("hello\r\nworld"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3164,7 +3175,7 @@ test "Page plain start_x non-zero ignores trailing state" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world"); + s.nextSlice("hello world"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3208,7 +3219,7 @@ test "Page plain start_y and start_x zero uses trailing state" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3255,7 +3266,7 @@ test "Page plain single line with styling" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello, \x1b[1mworld\x1b[0m"); + s.nextSlice("hello, \x1b[1mworld\x1b[0m"); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -3301,7 +3312,7 @@ test "Page VT single line plain text" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3340,7 +3351,7 @@ test "Page VT single line with bold" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("\x1b[1mhello\x1b[0m"); + s.nextSlice("\x1b[1mhello\x1b[0m"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3386,7 +3397,7 @@ test "Page VT multiple styles" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("\x1b[1mhello \x1b[3mworld\x1b[0m"); + s.nextSlice("\x1b[1mhello \x1b[3mworld\x1b[0m"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3421,7 +3432,7 @@ test "Page VT with foreground color" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("\x1b[31mred\x1b[0m"); + s.nextSlice("\x1b[31mred\x1b[0m"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3467,7 +3478,7 @@ test "Page VT with background and foreground colors" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3504,7 +3515,7 @@ test "Page VT multi-line with styles" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("\x1b[1mfirst\x1b[0m\r\n\x1b[3msecond\x1b[0m"); + s.nextSlice("\x1b[1mfirst\x1b[0m\r\n\x1b[3msecond\x1b[0m"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3541,7 +3552,7 @@ test "Page VT duplicate style not emitted twice" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("\x1b[1mhel\x1b[1mlo\x1b[0m"); + s.nextSlice("\x1b[1mhel\x1b[1mlo\x1b[0m"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3576,7 +3587,7 @@ test "PageList plain single line" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello, world"); + s.nextSlice("hello, world"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -3616,18 +3627,18 @@ test "PageList plain spanning two pages" { const first_page_rows = pages.pages.first.?.data.capacity.rows; // Fill the first page almost completely - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - try s.nextSlice("page one"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + s.nextSlice("page one"); // Verify we're still on one page try testing.expect(pages.pages.first == pages.pages.last); // Add one more newline to push content to a second page - try s.nextSlice("\r\n"); + s.nextSlice("\r\n"); try testing.expect(pages.pages.first != pages.pages.last); // Write content on the second page - try s.nextSlice("page two"); + s.nextSlice("page two"); // Format the entire PageList var pin_map: std.ArrayList(Pin) = .empty; @@ -3689,8 +3700,8 @@ test "PageList soft-wrapped line spanning two pages without unwrap" { const first_page_rows = pages.pages.first.?.data.capacity.rows; // Fill the first page with soft-wrapped content - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - try s.nextSlice("hello world test"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + s.nextSlice("hello world test"); // Verify we're on two pages due to wrapping try testing.expect(pages.pages.first != pages.pages.last); @@ -3753,8 +3764,8 @@ test "PageList soft-wrapped line spanning two pages with unwrap" { const first_page_rows = pages.pages.first.?.data.capacity.rows; // Fill the first page with soft-wrapped content - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - try s.nextSlice("hello world test"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + s.nextSlice("hello world test"); // Verify we're on two pages due to wrapping try testing.expect(pages.pages.first != pages.pages.last); @@ -3814,18 +3825,18 @@ test "PageList VT spanning two pages" { const first_page_rows = pages.pages.first.?.data.capacity.rows; // Fill the first page almost completely - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - try s.nextSlice("\x1b[1mpage one"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + s.nextSlice("\x1b[1mpage one"); // Verify we're still on one page try testing.expect(pages.pages.first == pages.pages.last); // Add one more newline to push content to a second page - try s.nextSlice("\r\n"); + s.nextSlice("\r\n"); try testing.expect(pages.pages.first != pages.pages.last); // New content is still styled - try s.nextSlice("page two"); + s.nextSlice("page two"); // Format the entire PageList with VT var pin_map: std.ArrayList(Pin) = .empty; @@ -3870,7 +3881,7 @@ test "PageList plain with x offset on single page" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world\r\ntest case\r\nfoo bar"); + s.nextSlice("hello world\r\ntest case\r\nfoo bar"); const pages = &t.screens.active.pages; const node = pages.pages.first.?; @@ -3920,17 +3931,17 @@ test "PageList plain with x offset spanning two pages" { const first_page_rows = pages.pages.first.?.data.capacity.rows; // Fill first page almost completely - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - try s.nextSlice("hello world"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + s.nextSlice("hello world"); // Verify we're still on one page try testing.expect(pages.pages.first == pages.pages.last); // Push to second page - try s.nextSlice("\r\n"); + s.nextSlice("\r\n"); try testing.expect(pages.pages.first != pages.pages.last); - try s.nextSlice("foo bar test"); + s.nextSlice("foo bar test"); const first_node = pages.pages.first.?; const last_node = pages.pages.last.?; @@ -3986,7 +3997,7 @@ test "PageList plain with start_x only" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world"); + s.nextSlice("hello world"); const pages = &t.screens.active.pages; const node = pages.pages.first.?; @@ -4027,7 +4038,7 @@ test "PageList plain with end_x only" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world\r\ntest"); + s.nextSlice("hello world\r\ntest"); const pages = &t.screens.active.pages; const node = pages.pages.first.?; @@ -4080,11 +4091,11 @@ test "PageList plain rectangle basic" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Lorem ipsum dolor\r\n"); - try s.nextSlice("sit amet, consectetur\r\n"); - try s.nextSlice("adipiscing elit, sed do\r\n"); - try s.nextSlice("eiusmod tempor incididunt\r\n"); - try s.nextSlice("ut labore et dolore"); + s.nextSlice("Lorem ipsum dolor\r\n"); + s.nextSlice("sit amet, consectetur\r\n"); + s.nextSlice("adipiscing elit, sed do\r\n"); + s.nextSlice("eiusmod tempor incididunt\r\n"); + s.nextSlice("ut labore et dolore"); const pages = &t.screens.active.pages; @@ -4120,11 +4131,11 @@ test "PageList plain rectangle with EOL" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Lorem ipsum dolor\r\n"); - try s.nextSlice("sit amet, consectetur\r\n"); - try s.nextSlice("adipiscing elit, sed do\r\n"); - try s.nextSlice("eiusmod tempor incididunt\r\n"); - try s.nextSlice("ut labore et dolore"); + s.nextSlice("Lorem ipsum dolor\r\n"); + s.nextSlice("sit amet, consectetur\r\n"); + s.nextSlice("adipiscing elit, sed do\r\n"); + s.nextSlice("eiusmod tempor incididunt\r\n"); + s.nextSlice("ut labore et dolore"); const pages = &t.screens.active.pages; @@ -4162,14 +4173,14 @@ test "PageList plain rectangle more complex with breaks" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Lorem ipsum dolor\r\n"); - try s.nextSlice("sit amet, consectetur\r\n"); - try s.nextSlice("adipiscing elit, sed do\r\n"); - try s.nextSlice("eiusmod tempor incididunt\r\n"); - try s.nextSlice("ut labore et dolore\r\n"); - try s.nextSlice("\r\n"); - try s.nextSlice("magna aliqua. Ut enim\r\n"); - try s.nextSlice("ad minim veniam, quis"); + s.nextSlice("Lorem ipsum dolor\r\n"); + s.nextSlice("sit amet, consectetur\r\n"); + s.nextSlice("adipiscing elit, sed do\r\n"); + s.nextSlice("eiusmod tempor incididunt\r\n"); + s.nextSlice("ut labore et dolore\r\n"); + s.nextSlice("\r\n"); + s.nextSlice("magna aliqua. Ut enim\r\n"); + s.nextSlice("ad minim veniam, quis"); const pages = &t.screens.active.pages; @@ -4208,7 +4219,7 @@ test "TerminalFormatter plain no selection" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld"); + s.nextSlice("hello\r\nworld"); const formatter: TerminalFormatter = .init(&t, .plain); @@ -4233,10 +4244,10 @@ test "TerminalFormatter vt with palette" { defer s.deinit(); // Modify some palette colors using VT sequences - try s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); - try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); - try s.nextSlice("\x1b]4;255;rgb:ff/00/ff\x1b\\"); - try s.nextSlice("test"); + s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); + s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + s.nextSlice("\x1b]4;255;rgb:ff/00/ff\x1b\\"); + s.nextSlice("test"); const formatter: TerminalFormatter = .init(&t, .vt); @@ -4253,7 +4264,7 @@ test "TerminalFormatter vt with palette" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify the palettes match try testing.expectEqual(t.colors.palette.current[0], t2.colors.palette.current[0]); @@ -4277,7 +4288,7 @@ test "TerminalFormatter with selection" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("line1\r\nline2\r\nline3"); + s.nextSlice("line1\r\nline2\r\nline3"); var formatter: TerminalFormatter = .init(&t, .plain); formatter.content = .{ .selection = .init( @@ -4306,7 +4317,7 @@ test "TerminalFormatter plain with pin_map" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello, world"); + s.nextSlice("hello, world"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4343,7 +4354,7 @@ test "TerminalFormatter plain multiline with pin_map" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld"); + s.nextSlice("hello\r\nworld"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4392,8 +4403,8 @@ test "TerminalFormatter vt with palette and pin_map" { defer s.deinit(); // Modify some palette colors using VT sequences - try s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); - try s.nextSlice("test"); + s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); + s.nextSlice("test"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4428,7 +4439,7 @@ test "TerminalFormatter with selection and pin_map" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("line1\r\nline2\r\nline3"); + s.nextSlice("line1\r\nline2\r\nline3"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4472,7 +4483,7 @@ test "Screen plain single line" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello, world"); + s.nextSlice("hello, world"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4509,7 +4520,7 @@ test "Screen plain multiline" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld"); + s.nextSlice("hello\r\nworld"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4557,7 +4568,7 @@ test "Screen plain with selection" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("line1\r\nline2\r\nline3"); + s.nextSlice("line1\r\nline2\r\nline3"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4602,7 +4613,7 @@ test "Screen vt with cursor position" { defer s.deinit(); // Position cursor at a specific location - try s.nextSlice("hello\r\nworld"); + s.nextSlice("hello\r\nworld"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4624,7 +4635,7 @@ test "Screen vt with cursor position" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify cursor positions match try testing.expectEqual(t.screens.active.cursor.x, t2.screens.active.cursor.x); @@ -4661,7 +4672,7 @@ test "Screen vt with style" { defer s.deinit(); // Set some style attributes - try s.nextSlice("\x1b[1;31mhello"); + s.nextSlice("\x1b[1;31mhello"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4683,7 +4694,7 @@ test "Screen vt with style" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify styles match try testing.expect(t.screens.active.cursor.style.eql(t2.screens.active.cursor.style)); @@ -4713,7 +4724,7 @@ test "Screen vt with hyperlink" { defer s.deinit(); // Set a hyperlink - try s.nextSlice("\x1b]8;;http://example.com\x1b\\hello"); + s.nextSlice("\x1b]8;;http://example.com\x1b\\hello"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4735,7 +4746,7 @@ test "Screen vt with hyperlink" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify hyperlinks match const has_link1 = t.screens.active.cursor.hyperlink != null; @@ -4773,7 +4784,7 @@ test "Screen vt with protection" { defer s.deinit(); // Enable protection mode - try s.nextSlice("\x1b[1\"qhello"); + s.nextSlice("\x1b[1\"qhello"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4795,7 +4806,7 @@ test "Screen vt with protection" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify protection state matches try testing.expectEqual(t.screens.active.cursor.protected, t2.screens.active.cursor.protected); @@ -4825,7 +4836,7 @@ test "Screen vt with kitty keyboard" { defer s.deinit(); // Set kitty keyboard flags (disambiguate + report_events = 3) - try s.nextSlice("\x1b[=3;1uhello"); + s.nextSlice("\x1b[=3;1uhello"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4847,7 +4858,7 @@ test "Screen vt with kitty keyboard" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify kitty keyboard state matches const flags1 = t.screens.active.kitty_keyboard.current().int(); @@ -4879,7 +4890,7 @@ test "Screen vt with charsets" { defer s.deinit(); // Set G0 to DEC special and shift to G1 - try s.nextSlice("\x1b(0\x0ehello"); + s.nextSlice("\x1b(0\x0ehello"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4901,7 +4912,7 @@ test "Screen vt with charsets" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify charset state matches try testing.expectEqual(t.screens.active.charset.gl, t2.screens.active.charset.gl); @@ -4936,7 +4947,7 @@ test "Terminal vt with scrolling region" { defer s.deinit(); // Set scrolling region: top=5, bottom=20 - try s.nextSlice("\x1b[6;21rhello"); + s.nextSlice("\x1b[6;21rhello"); var formatter: TerminalFormatter = .init(&t, .vt); formatter.extra.scrolling_region = true; @@ -4954,7 +4965,7 @@ test "Terminal vt with scrolling region" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify scrolling regions match try testing.expectEqual(t.scrolling_region.top, t2.scrolling_region.top); @@ -4980,10 +4991,10 @@ test "Terminal vt with modes" { defer s.deinit(); // Enable some modes that differ from defaults - try s.nextSlice("\x1b[?2004h"); // Bracketed paste - try s.nextSlice("\x1b[?1000h"); // Mouse event normal - try s.nextSlice("\x1b[?7l"); // Disable wraparound (default is true) - try s.nextSlice("hello"); + s.nextSlice("\x1b[?2004h"); // Bracketed paste + s.nextSlice("\x1b[?1000h"); // Mouse event normal + s.nextSlice("\x1b[?7l"); // Disable wraparound (default is true) + s.nextSlice("hello"); var formatter: TerminalFormatter = .init(&t, .vt); formatter.extra.modes = true; @@ -5001,7 +5012,7 @@ test "Terminal vt with modes" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify modes match try testing.expectEqual(t.modes.get(.bracketed_paste), t2.modes.get(.bracketed_paste)); @@ -5026,11 +5037,11 @@ test "Terminal vt with tabstops" { defer s.deinit(); // Clear all tabs and set custom tabstops - try s.nextSlice("\x1b[3g"); // Clear all tabs - try s.nextSlice("\x1b[5G\x1bH"); // Set tab at column 5 - try s.nextSlice("\x1b[15G\x1bH"); // Set tab at column 15 - try s.nextSlice("\x1b[30G\x1bH"); // Set tab at column 30 - try s.nextSlice("hello"); + s.nextSlice("\x1b[3g"); // Clear all tabs + 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"); var formatter: TerminalFormatter = .init(&t, .vt); formatter.extra.tabstops = true; @@ -5048,7 +5059,7 @@ test "Terminal vt with tabstops" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify tabstops match (columns are 0-indexed in the API) try testing.expectEqual(t.tabstops.get(4), t2.tabstops.get(4)); @@ -5077,8 +5088,8 @@ test "Terminal vt with keyboard modes" { defer s.deinit(); // Set modify other keys mode 2 - try s.nextSlice("\x1b[>4;2m"); - try s.nextSlice("hello"); + s.nextSlice("\x1b[>4;2m"); + s.nextSlice("hello"); var formatter: TerminalFormatter = .init(&t, .vt); formatter.extra.keyboard = true; @@ -5096,7 +5107,7 @@ test "Terminal vt with keyboard modes" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify keyboard mode matches try testing.expectEqual(t.flags.modify_other_keys_2, t2.flags.modify_other_keys_2); @@ -5120,7 +5131,7 @@ test "Terminal vt with pwd" { defer s.deinit(); // Set pwd using OSC 7 - try s.nextSlice("\x1b]7;file://host/home/user\x1b\\hello"); + s.nextSlice("\x1b]7;file://host/home/user\x1b\\hello"); var formatter: TerminalFormatter = .init(&t, .vt); formatter.extra.pwd = true; @@ -5138,7 +5149,7 @@ test "Terminal vt with pwd" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify pwd matches try testing.expectEqualStrings(t.pwd.items, t2.pwd.items); @@ -5161,7 +5172,7 @@ test "Page html with multiple styles" { defer s.deinit(); // Set bold, then italic, then reset - try s.nextSlice("\x1b[1mbold\x1b[3mitalic\x1b[0mnormal"); + s.nextSlice("\x1b[1mbold\x1b[3mitalic\x1b[0mnormal"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5196,7 +5207,7 @@ test "Page html plain text" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello, world"); + s.nextSlice("hello, world"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5229,7 +5240,7 @@ test "Page html with colors" { defer s.deinit(); // Set red foreground, blue background - try s.nextSlice("\x1b[31;44mcolored"); + s.nextSlice("\x1b[31;44mcolored"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5263,10 +5274,10 @@ test "TerminalFormatter html with palette" { defer s.deinit(); // Modify some palette colors - try s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); - try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); - try s.nextSlice("\x1b]4;255;rgb:ff/00/ff\x1b\\"); - try s.nextSlice("test"); + s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); + s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + s.nextSlice("\x1b]4;255;rgb:ff/00/ff\x1b\\"); + s.nextSlice("test"); var formatter: TerminalFormatter = .init(&t, .{ .emit = .html }); formatter.extra.palette = true; @@ -5299,7 +5310,7 @@ test "Page html with background and foreground colors" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5334,7 +5345,7 @@ test "Page html with escaping" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("<tag>&\"'text"); + s.nextSlice("<tag>&\"'text"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5405,7 +5416,7 @@ test "Page html with unicode as numeric entities" { defer s.deinit(); // Box drawing characters that caused issue #9426 - try s.nextSlice("╰─ ❯"); + s.nextSlice("╰─ ❯"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5438,7 +5449,7 @@ test "Page html ascii characters unchanged" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world"); + s.nextSlice("hello world"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5470,7 +5481,7 @@ test "Page html mixed ascii and unicode" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("test ╰─❯ ok"); + s.nextSlice("test ╰─❯ ok"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5503,8 +5514,8 @@ test "Page VT with palette option emits RGB" { defer s.deinit(); // Set a custom palette color and use it - try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); - try s.nextSlice("\x1b[31mred"); + s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + s.nextSlice("\x1b[31mred"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5547,8 +5558,8 @@ test "Page html with palette option emits RGB" { defer s.deinit(); // Set a custom palette color and use it - try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); - try s.nextSlice("\x1b[31mred"); + s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + s.nextSlice("\x1b[31mred"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5601,7 +5612,7 @@ test "Page VT style reset properly closes styles" { defer s.deinit(); // Set bold, then reset with SGR 0 - try s.nextSlice("\x1b[1mbold\x1b[0mnormal"); + s.nextSlice("\x1b[1mbold\x1b[0mnormal"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5631,7 +5642,7 @@ test "Page codepoint_map single replacement" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world"); + s.nextSlice("hello world"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5690,7 +5701,7 @@ test "Page codepoint_map conflicting replacement prefers last" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5732,7 +5743,7 @@ test "Page codepoint_map replace with string" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5788,7 +5799,7 @@ test "Page codepoint_map range replacement" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("abcdefg"); + s.nextSlice("abcdefg"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5826,7 +5837,7 @@ test "Page codepoint_map multiple ranges" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world"); + s.nextSlice("hello world"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5870,7 +5881,7 @@ test "Page codepoint_map unicode replacement" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello ⚡ world"); + s.nextSlice("hello ⚡ world"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5935,7 +5946,7 @@ test "Page codepoint_map with styled formats" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("\x1b[31mred text\x1b[0m"); + s.nextSlice("\x1b[31mred text\x1b[0m"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5976,7 +5987,7 @@ test "Page codepoint_map empty map" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world"); + s.nextSlice("hello world"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -6016,9 +6027,9 @@ test "Page VT background color on trailing blank cells" { // Simulate a TUI row: "CPU:" with text, then trailing cells with red background // to end of line (no text after the colored region). // \x1b[41m sets red background, then EL fills rest of row with that bg. - try s.nextSlice("CPU:\x1b[41m\x1b[K"); + s.nextSlice("CPU:\x1b[41m\x1b[K"); // Reset colors and move to next line with different content - try s.nextSlice("\x1b[0m\r\nline2"); + s.nextSlice("\x1b[0m\r\nline2"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -6065,7 +6076,7 @@ test "Page HTML with hyperlinks" { defer s.deinit(); // Start a hyperlink, write some text, end it - try s.nextSlice("\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\ normal"); + s.nextSlice("\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\ normal"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -6099,8 +6110,8 @@ test "Page HTML with multiple hyperlinks" { defer s.deinit(); // Two different hyperlinks - try s.nextSlice("\x1b]8;;https://first.com\x1b\\first\x1b]8;;\x1b\\ "); - try s.nextSlice("\x1b]8;;https://second.com\x1b\\second\x1b]8;;\x1b\\"); + s.nextSlice("\x1b]8;;https://first.com\x1b\\first\x1b]8;;\x1b\\ "); + s.nextSlice("\x1b]8;;https://second.com\x1b\\second\x1b]8;;\x1b\\"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -6136,7 +6147,7 @@ test "Page HTML with hyperlink escaping" { defer s.deinit(); // URL with special characters that need escaping - try s.nextSlice("\x1b]8;;https://example.com?a=1&b=2\x1b\\link\x1b]8;;\x1b\\"); + s.nextSlice("\x1b]8;;https://example.com?a=1&b=2\x1b\\link\x1b]8;;\x1b\\"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -6170,7 +6181,7 @@ test "Page HTML with styled hyperlink" { defer s.deinit(); // Bold hyperlink - try s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold link\x1b[0m\x1b]8;;\x1b\\"); + s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold link\x1b[0m\x1b]8;;\x1b\\"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -6205,7 +6216,7 @@ test "Page HTML hyperlink closes style before anchor" { defer s.deinit(); // Styled hyperlink followed by plain text - try s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold\x1b[0m plain"); + s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold\x1b[0m plain"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -6239,7 +6250,7 @@ test "Page HTML hyperlink point map maps closing to previous cell" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\ normal"); + s.nextSlice("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\ normal"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; diff --git a/src/terminal/hash_map.zig b/src/terminal/hash_map.zig index 96dfcfdf3..5a38cc179 100644 --- a/src/terminal/hash_map.zig +++ b/src/terminal/hash_map.zig @@ -104,8 +104,8 @@ pub fn OffsetHashMap( }; } -/// Fork of stdlib.HashMap as of Zig 0.12 modified to to use offsets -/// for the key/values pointer. The metadata is still a pointer to limit +/// Fork of stdlib.HashMap as of Zig 0.12 modified to use offsets for +/// the key/values pointer. The metadata is still a pointer to limit /// the amount of arithmetic required to access it. See the file comment /// for full details. fn HashMapUnmanaged( diff --git a/src/terminal/lib.zig b/src/terminal/lib.zig new file mode 100644 index 000000000..3cd657b4e --- /dev/null +++ b/src/terminal/lib.zig @@ -0,0 +1,22 @@ +const std = @import("std"); +const build_options = @import("terminal_options"); +const lib = @import("../lib/main.zig"); + +/// The target for the terminal lib in particular. +pub const target: lib.Target = if (build_options.c_abi) .c else .zig; + +/// The calling convention to use for C APIs. +/// +/// This is always .c for now. I want to make this "Zig" when we're not +/// building the C ABI but there are bigger issues we need to resolve to +/// make that possible (change it and see for yourself). +pub const calling_conv: std.builtin.CallingConvention = .c; + +/// Forwarded decls from lib that are used. +pub const alloc = lib.allocator; +pub const Enum = lib.Enum; +pub const TaggedUnion = lib.TaggedUnion; +pub const Struct = lib.Struct; +pub const String = lib.String; +pub const checkGhosttyHEnum = lib.checkGhosttyHEnum; +pub const structSizedFieldFits = lib.structSizedFieldFits; diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 06c930014..9f5b65e34 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -3,14 +3,16 @@ const stream = @import("stream.zig"); const ansi = @import("ansi.zig"); const csi = @import("csi.zig"); const render = @import("render.zig"); -const stream_readonly = @import("stream_readonly.zig"); +const stream_terminal = @import("stream_terminal.zig"); const style = @import("style.zig"); pub const apc = @import("apc.zig"); pub const dcs = @import("dcs.zig"); pub const osc = @import("osc.zig"); pub const point = @import("point.zig"); pub const color = @import("color.zig"); +pub const device_attributes = @import("device_attributes.zig"); pub const device_status = @import("device_status.zig"); +pub const focus = @import("focus.zig"); pub const formatter = @import("formatter.zig"); pub const highlight = @import("highlight.zig"); pub const kitty = @import("kitty.zig"); @@ -20,6 +22,7 @@ pub const parse_table = @import("parse_table.zig"); pub const search = @import("search.zig"); pub const sgr = @import("sgr.zig"); pub const size = @import("size.zig"); +pub const size_report = @import("size_report.zig"); pub const tmux = if (options.tmux_control_mode) @import("tmux.zig") else struct {}; pub const x11_color = @import("x11_color.zig"); @@ -31,14 +34,15 @@ pub const Cell = page.Cell; pub const Coordinate = point.Coordinate; pub const CSI = Parser.Action.CSI; pub const DCS = Parser.Action.DCS; -pub const MouseShape = @import("mouse_shape.zig").MouseShape; +pub const mouse = @import("mouse.zig"); +pub const MouseEvent = mouse.Event; +pub const MouseFormat = mouse.Format; +pub const MouseShape = mouse.Shape; pub const Page = page.Page; pub const PageList = @import("PageList.zig"); pub const Parser = @import("Parser.zig"); pub const Pin = PageList.Pin; pub const Point = point.Point; -pub const ReadonlyHandler = stream_readonly.Handler; -pub const ReadonlyStream = stream_readonly.Stream; pub const RenderState = render.RenderState; pub const Screen = @import("Screen.zig"); pub const ScreenSet = @import("ScreenSet.zig"); @@ -48,12 +52,13 @@ pub const SizeReportStyle = csi.SizeReportStyle; pub const StringMap = @import("StringMap.zig"); pub const Style = style.Style; pub const Terminal = @import("Terminal.zig"); +pub const TerminalStream = stream_terminal.Stream; pub const Stream = stream.Stream; pub const StreamAction = stream.Action; pub const Cursor = Screen.Cursor; pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = ansi.CursorStyle; -pub const DeviceAttributeReq = ansi.DeviceAttributeReq; +pub const DeviceAttributeReq = device_attributes.Req; pub const Mode = modes.Mode; pub const ModePacked = modes.ModePacked; pub const ModifyKeyFormat = ansi.ModifyKeyFormat; diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index 13b7c1eac..0d8cde5b7 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -76,6 +76,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 { + const mode = modeFromInt(tag.value, tag.ansi) orelse return .{ + .tag = tag, + .state = .not_recognized, + }; + return .{ + .tag = tag, + .state = if (self.get(mode)) .set else .reset, + }; + } + test { // We have this here so that we explicitly fail when we change the // size of modes. The size of modes is NOT particularly important, @@ -136,6 +149,10 @@ pub const ModeTag = packed struct(u16) { value: u15, ansi: bool = false, + pub fn fromMode(mode: Mode) ModeTag { + return @bitCast(@intFromEnum(mode)); + } + test "order" { const t: ModeTag = .{ .value = 1 }; const int: Backing = @bitCast(t); @@ -157,6 +174,48 @@ pub fn modeFromInt(v: u16, ansi: bool) ?Mode { return null; } +/// A DECRPM mode report response. +pub const Report = struct { + tag: ModeTag, + state: State, + + pub const max_size = max_size: { + // Construct the largest possible report in terms of values. + const report: Report = .{ + .tag = .{ + .value = std.math.maxInt(u15), + .ansi = false, + }, + .state = .permanently_reset, + }; + + var discarding: std.Io.Writer.Discarding = .init(&.{}); + report.encode(&discarding.writer) catch unreachable; + break :max_size discarding.count; + }; + + /// The state of a mode as reported in a DECRPM response. + pub const State = enum(u8) { + not_recognized = 0, + set = 1, + reset = 2, + permanently_set = 3, + permanently_reset = 4, + }; + + /// Encode the DECRPM report sequence. + pub fn encode( + self: Report, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + try writer.print("\x1B[{s}{};{}$y", .{ + if (self.tag.ansi) "" else "?", + self.tag.value, + @intFromEnum(self.state), + }); + } +}; + fn entryForMode(comptime mode: Mode) ModeEntry { @setEvalBranchQuota(10_000); const name = @tagName(mode); @@ -186,6 +245,9 @@ const ModeEntry = struct { /// 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. +/// +/// NOTE: When adding a new mode entry, also add a corresponding +/// GHOSTTY_MODE_* macro in include/ghostty/vt/modes.h. const entries: []const ModeEntry = &.{ // ANSI .{ .name = "disable_keyboard", .value = 2, .ansi = true }, // KAM @@ -259,3 +321,61 @@ test ModeState { try testing.expect(state.restore(.cursor_keys)); try testing.expect(state.get(.cursor_keys)); } + +test "getReport known DEC mode" { + var state: ModeState = .{}; + const report = state.getReport(.{ .value = 1 }); + try testing.expectEqual(Report.State.reset, report.state); + try testing.expectEqual(false, report.tag.ansi); + try testing.expectEqual(@as(u15, 1), report.tag.value); + + state.set(.cursor_keys, true); + const report2 = state.getReport(.{ .value = 1 }); + try testing.expectEqual(Report.State.set, report2.state); +} + +test "getReport known ANSI mode" { + var state: ModeState = .{}; + state.set(.insert, true); + const report = state.getReport(.{ .value = 4, .ansi = true }); + try testing.expectEqual(Report.State.set, report.state); + try testing.expectEqual(true, report.tag.ansi); +} + +test "getReport unknown mode" { + const state: ModeState = .{}; + const report = state.getReport(.{ .value = 9999 }); + try testing.expectEqual(Report.State.not_recognized, report.state); +} + +test "Report.encode DEC mode set" { + var buf: [Report.max_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + const report: Report = .{ .tag = .{ .value = 1, .ansi = false }, .state = .set }; + try report.encode(&writer); + try testing.expectEqualStrings("\x1B[?1;1$y", writer.buffered()); +} + +test "Report.encode DEC mode reset" { + var buf: [Report.max_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + const report: Report = .{ .tag = .{ .value = 1, .ansi = false }, .state = .reset }; + try report.encode(&writer); + try testing.expectEqualStrings("\x1B[?1;2$y", writer.buffered()); +} + +test "Report.encode ANSI mode" { + var buf: [Report.max_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + const report: Report = .{ .tag = .{ .value = 4, .ansi = true }, .state = .set }; + try report.encode(&writer); + try testing.expectEqualStrings("\x1B[4;1$y", writer.buffered()); +} + +test "Report.encode not recognized" { + var buf: [Report.max_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + const report: Report = .{ .tag = .{ .value = 9999, .ansi = false }, .state = .not_recognized }; + try report.encode(&writer); + try testing.expectEqualStrings("\x1B[?9999;0$y", writer.buffered()); +} diff --git a/src/terminal/mouse_shape.zig b/src/terminal/mouse.zig similarity index 75% rename from src/terminal/mouse_shape.zig rename to src/terminal/mouse.zig index b5c6ac4d1..2861ad1e0 100644 --- a/src/terminal/mouse_shape.zig +++ b/src/terminal/mouse.zig @@ -1,13 +1,38 @@ const std = @import("std"); const build_options = @import("terminal_options"); -const lib = @import("../lib/main.zig"); +const lib = @import("lib.zig"); + +/// The event types that can be reported for mouse-related activities. +/// These are all mutually exclusive (hence in a single enum). +pub const Event = lib.Enum(lib.target, &.{ + "none", + "x10", // 9 + "normal", // 1000 + "button", // 1002 + "any", // 1003 +}); + +/// Returns true if this event sends motion events. +pub fn eventSendsMotion(event: Event) bool { + return event == .button or event == .any; +} + +/// The format of mouse events when enabled. +/// These are all mutually exclusive (hence in a single enum). +pub const Format = lib.Enum(lib.target, &.{ + "x10", + "utf8", // 1005 + "sgr", // 1006 + "urxvt", // 1015 + "sgr_pixels", // 1016 +}); /// The possible cursor shapes. Not all app runtimes support these shapes. /// The shapes are always based on the W3C supported cursor styles so we /// can have a cross platform list. // // Must be kept in sync with ghostty_cursor_shape_e -pub const MouseShape = enum(c_int) { +pub const Shape = enum(c_int) { default, context_menu, help, @@ -44,7 +69,7 @@ pub const MouseShape = enum(c_int) { zoom_out, /// Build cursor shape from string or null if its unknown. - pub fn fromString(v: []const u8) ?MouseShape { + pub fn fromString(v: []const u8) ?Shape { return string_map.get(v); } @@ -57,7 +82,7 @@ pub const MouseShape = enum(c_int) { break :gtk switch (@import("../build_config.zig").app_runtime) { .gtk => @import("gobject").ext.defineEnum( - MouseShape, + Shape, .{ .name = "GhosttyMouseShape" }, ), @@ -66,11 +91,12 @@ pub const MouseShape = enum(c_int) { }; test "ghostty.h MouseShape" { - try lib.checkGhosttyHEnum(MouseShape, "GHOSTTY_MOUSE_SHAPE_"); + if (comptime build_options.artifact == .lib) return error.SkipZigTest; + try lib.checkGhosttyHEnum(Shape, "GHOSTTY_MOUSE_SHAPE_"); } }; -const string_map = std.StaticStringMap(MouseShape).initComptime(.{ +const string_map = std.StaticStringMap(Shape).initComptime(.{ // W3C .{ "default", .default }, .{ "context-menu", .context_menu }, @@ -134,7 +160,7 @@ const string_map = std.StaticStringMap(MouseShape).initComptime(.{ test "cursor shape from string" { const testing = std.testing; - try testing.expectEqual(MouseShape.default, MouseShape.fromString("default").?); + try testing.expectEqual(Shape.default, Shape.fromString("default").?); } test { diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 0086e48bc..44e97e2c7 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -11,11 +11,11 @@ const build_options = @import("terminal_options"); const mem = std.mem; const assert = @import("../quirks.zig").inlineAssert; const Allocator = mem.Allocator; -const LibEnum = @import("../lib/enum.zig").Enum; +const lib = @import("lib.zig"); +const LibEnum = lib.Enum; const kitty_color = @import("kitty/color.zig"); const parsers = @import("osc/parsers.zig"); const encoding = @import("osc/encoding.zig"); -const lib = @import("../lib/main.zig"); pub const color = parsers.color; pub const semantic_prompt = parsers.semantic_prompt; @@ -165,7 +165,7 @@ pub const Command = union(Key) { pub const KittyClipboardProtocol = parsers.kitty_clipboard_protocol.OSC; pub const Key = LibEnum( - if (build_options.c_abi) .c else .zig, + lib.target, // NOTE: Order matters, see LibEnum documentation. &.{ "invalid", @@ -205,6 +205,7 @@ pub const Command = union(Key) { pause, test "ghostty.h Command.ProgressReport.State" { + if (comptime build_options.artifact == .lib) return error.SkipZigTest; try lib.checkGhosttyHEnum(State, "GHOSTTY_PROGRESS_STATE_"); } }; diff --git a/src/terminal/osc/parsers/context_signal.zig b/src/terminal/osc/parsers/context_signal.zig index ff82af392..c36c76f21 100644 --- a/src/terminal/osc/parsers/context_signal.zig +++ b/src/terminal/osc/parsers/context_signal.zig @@ -16,6 +16,8 @@ const max_context_id_len = 64; /// A single OSC 3008 context signal command. pub const Command = struct { + pub const C = void; + action: Action, /// The context identifier. Must be 1-64 characters in the 32..126 byte range. id: []const u8, diff --git a/src/terminal/osc/parsers/kitty_text_sizing.zig b/src/terminal/osc/parsers/kitty_text_sizing.zig index f0180cc8f..370e22a22 100644 --- a/src/terminal/osc/parsers/kitty_text_sizing.zig +++ b/src/terminal/osc/parsers/kitty_text_sizing.zig @@ -2,27 +2,25 @@ //! Specification: https://sw.kovidgoyal.net/kitty/text-sizing-protocol/ const std = @import("std"); -const build_options = @import("terminal_options"); const assert = @import("../../../quirks.zig").inlineAssert; const Parser = @import("../../osc.zig").Parser; const Command = @import("../../osc.zig").Command; const encoding = @import("../encoding.zig"); -const lib = @import("../../../lib/main.zig"); -const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; +const lib = @import("../../lib.zig"); const log = std.log.scoped(.kitty_text_sizing); pub const max_payload_length = 4096; -pub const VAlign = lib.Enum(lib_target, &.{ +pub const VAlign = lib.Enum(lib.target, &.{ "top", "bottom", "center", }); -pub const HAlign = lib.Enum(lib_target, &.{ +pub const HAlign = lib.Enum(lib.target, &.{ "left", "right", "center", diff --git a/src/terminal/osc/parsers/semantic_prompt.zig b/src/terminal/osc/parsers/semantic_prompt.zig index d3a117515..c60ce4cb5 100644 --- a/src/terminal/osc/parsers/semantic_prompt.zig +++ b/src/terminal/osc/parsers/semantic_prompt.zig @@ -14,6 +14,8 @@ const log = std.log.scoped(.osc_semantic_prompt); /// all except one do and the spec does also say to ignore unknown /// options. So, I think this is a fair interpretation. pub const Command = struct { + pub const C = void; + action: Action, options_unvalidated: []const u8, diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 3e7ca9ac3..0942e260c 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -6,6 +6,7 @@ const ArenaAllocator = std.heap.ArenaAllocator; const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const posix = std.posix; +const windows = std.os.windows; const fastmem = @import("../fastmem.zig"); const color = @import("color.zig"); const hyperlink = @import("hyperlink.zig"); @@ -26,6 +27,60 @@ const alignBackward = std.mem.alignBackward; const log = std.log.scoped(.page); +/// Page-aligned allocator used for terminal page backing memory. Pages +/// require page-aligned, zeroed memory obtained directly from the OS +/// (not the Zig allocator) because the allocation fast-path is +/// performance-critical and the OS guarantees zeroed pages. +const PageAlloc = switch (builtin.os.tag) { + .windows => AllocWindows, + else => AllocPosix, +}; + +/// Allocate page-aligned, zeroed backing memory using mmap with +/// MAP_PRIVATE | MAP_ANONYMOUS which guarantees zeroed pages. +const AllocPosix = struct { + pub fn alloc(n: usize) ![]align(std.heap.page_size_min) u8 { + return try posix.mmap( + null, + n, + posix.PROT.READ | posix.PROT.WRITE, + .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, + -1, + 0, + ); + } + + pub fn free(mem: []align(std.heap.page_size_min) u8) void { + posix.munmap(mem); + } +}; + +/// Allocate page-aligned, zeroed backing memory using VirtualAlloc with +/// MEM_COMMIT | MEM_RESERVE which guarantees zeroed pages. +const AllocWindows = struct { + pub fn alloc(n: usize) error{OutOfMemory}![]align(std.heap.page_size_min) u8 { + const addr = windows.VirtualAlloc( + null, + n, + windows.MEM_COMMIT | windows.MEM_RESERVE, + windows.PAGE_READWRITE, + ) catch return error.OutOfMemory; + + return @as( + [*]align(std.heap.page_size_min) u8, + @ptrCast(@alignCast(addr)), + )[0..n]; + } + + pub fn free(mem: []align(std.heap.page_size_min) u8) void { + windows.VirtualFree( + @ptrCast(@alignCast(mem.ptr)), + 0, + windows.MEM_RELEASE, + ); + } +}; + /// The allocator to use for multi-codepoint grapheme data. We use /// a chunk size of 4 codepoints. It'd be best to set this empirically /// but it is currently set based on vibes. My thinking around 4 codepoints @@ -167,20 +222,13 @@ pub const Page = struct { pub inline fn init(cap: Capacity) !Page { const l = layout(cap); - // We use mmap directly to avoid Zig allocator overhead - // (small but meaningful for this path) and because a private - // anonymous mmap is guaranteed on Linux and macOS to be zeroed, + // We allocate page-aligned zeroed memory directly to avoid Zig + // allocator overhead (small but meaningful for this path). Both + // mmap (POSIX) and VirtualAlloc (Windows) guarantee zeroed pages, // which is a critical property for us. assert(l.total_size % std.heap.page_size_min == 0); - const backing = try posix.mmap( - null, - l.total_size, - posix.PROT.READ | posix.PROT.WRITE, - .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, - -1, - 0, - ); - errdefer posix.munmap(backing); + const backing = try PageAlloc.alloc(l.total_size); + errdefer PageAlloc.free(backing); const buf = OffsetBuf.init(backing); return initBuf(buf, l); @@ -245,7 +293,7 @@ pub const Page = struct { /// this if you allocated the backing memory yourself (i.e. you used /// initBuf). pub inline fn deinit(self: *Page) void { - posix.munmap(self.memory); + PageAlloc.free(self.memory); self.* = undefined; } @@ -578,15 +626,8 @@ pub const Page = struct { /// using the page allocator. If you want to manage memory manually, /// use cloneBuf. pub inline fn clone(self: *const Page) !Page { - const backing = try posix.mmap( - null, - self.memory.len, - posix.PROT.READ | posix.PROT.WRITE, - .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, - -1, - 0, - ); - errdefer posix.munmap(backing); + const backing = try PageAlloc.alloc(self.memory.len); + errdefer PageAlloc.free(backing); return self.cloneBuf(backing); } @@ -1947,6 +1988,14 @@ pub const Row = packed struct(u64) { prompt_continuation = 2, }; + /// C ABI type. + pub const C = u64; + + /// Returns this row as a C ABI value. + pub fn cval(self: Row) C { + return @bitCast(self); + } + /// Returns true if this row has any managed memory outside of the /// row structure (graphemes, styles, etc.) pub inline fn managedMemory(self: Row) bool { @@ -2051,6 +2100,14 @@ pub const Cell = packed struct(u64) { prompt = 2, }; + /// C ABI type. + pub const C = u64; + + /// Returns this cell as a C ABI value. + pub fn cval(self: Cell) C { + return @bitCast(self); + } + /// Helper to make a cell that just has a codepoint. pub fn init(cp: u21) Cell { // We have to use this bitCast here to ensure that our memory is diff --git a/src/terminal/point.zig b/src/terminal/point.zig index 5a3d4a6f8..4297bf5b5 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -1,5 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +const lib = @import("lib.zig"); const size = @import("size.zig"); /// The possible reference locations for a point. When someone says "(42, 80)" @@ -8,45 +9,45 @@ const size = @import("size.zig"); /// cursor is? the entire scrollback history? etc. /// /// This tag is used to differentiate those cases. -pub const Tag = enum { - /// Top-left is part of the active area where a running program can - /// jump the cursor and make changes. The active area is the "editable" - /// part of the screen. - /// - /// The bottom-right of the active tag differs from all other tags - /// because it includes the full height (rows) of the screen, including - /// rows that may not be written yet. This is required because the active - /// area is fully "addressable" by the running program (see below) whereas - /// the other tags are used primarily for reading/modifying past-written - /// data so they can't address unwritten rows. - /// - /// Note for those less familiar with terminal functionality: there - /// are escape sequences to move the cursor to any position on - /// the screen, but it is limited to the size of the viewport and - /// the bottommost part of the screen. Terminal programs can't -- - /// with sequences at the time of writing this comment -- modify - /// anything in the scrollback, visible viewport (if it differs - /// from the active area), etc. - active, +pub const Tag = lib.Enum(lib.target, &.{ + // Top-left is part of the active area where a running program can + // jump the cursor and make changes. The active area is the "editable" + // part of the screen. + // + // The bottom-right of the active tag differs from all other tags + // because it includes the full height (rows) of the screen, including + // rows that may not be written yet. This is required because the active + // area is fully "addressable" by the running program (see below) whereas + // the other tags are used primarily for reading/modifying past-written + // data so they can't address unwritten rows. + // + // Note for those less familiar with terminal functionality: there + // are escape sequences to move the cursor to any position on + // the screen, but it is limited to the size of the viewport and + // the bottommost part of the screen. Terminal programs can't -- + // with sequences at the time of writing this comment -- modify + // anything in the scrollback, visible viewport (if it differs + // from the active area), etc. + "active", - /// Top-left is the visible viewport. This means that if the user - /// has scrolled in any direction, top-left changes. The bottom-right - /// is the last written row from the top-left. - viewport, + // Top-left is the visible viewport. This means that if the user + // has scrolled in any direction, top-left changes. The bottom-right + // is the last written row from the top-left. + "viewport", - /// Top-left is the furthest back in the scrollback history - /// supported by the screen and the bottom-right is the bottom-right - /// of the last written row. Note this last point is important: the - /// bottom right is NOT necessarily the same as "active" because - /// "active" always allows referencing the full rows tall of the - /// screen whereas "screen" only contains written rows. - screen, + // Top-left is the furthest back in the scrollback history + // supported by the screen and the bottom-right is the bottom-right + // of the last written row. Note this last point is important: the + // bottom right is NOT necessarily the same as "active" because + // "active" always allows referencing the full rows tall of the + // screen whereas "screen" only contains written rows. + "screen", - /// The top-left is the same as "screen" but the bottom-right is - /// the line just before the top of "active". This contains only - /// the scrollback history. - history, -}; + // The top-left is the same as "screen" but the bottom-right is + // the line just before the top of "active". This contains only + // the scrollback history. + "history", +}); /// An x/y point in the terminal for some definition of location (tag). pub const Point = union(Tag) { @@ -64,9 +65,20 @@ pub const Point = union(Tag) { => |v| v, }; } + + const c_union = lib.TaggedUnion( + lib.target, + @This(), + // Padding: largest variant is Coordinate (u16 + u32 = 6 bytes). + // Use [2]u64 (16 bytes) for future expansion. + [2]u64, + ); + pub const C = c_union.C; + pub const CValue = c_union.CValue; + pub const cval = c_union.cval; }; -pub const Coordinate = struct { +pub const Coordinate = extern struct { /// x can use size.CellCountInt because the number of columns /// can't ever be more than a valid number of columns in a Page. x: size.CellCountInt = 0, diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 2332866ac..98e142245 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -3,6 +3,7 @@ const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const fastmem = @import("../fastmem.zig"); +const lib = @import("lib.zig"); const color = @import("color.zig"); const cursor = @import("cursor.zig"); const highlight = @import("highlight.zig"); @@ -95,8 +96,8 @@ pub const RenderState = struct { .rows = 0, .cols = 0, .colors = .{ - .background = .{}, - .foreground = .{}, + .background = .{ .r = 0, .g = 0, .b = 0 }, + .foreground = .{ .r = 0xff, .g = 0xff, .b = 0xff }, .cursor = null, .palette = color.default, }, @@ -222,20 +223,20 @@ pub const RenderState = struct { style: Style, }; - // Dirty state - pub const Dirty = enum { - /// Not dirty at all. Can skip rendering if prior state was - /// already rendered. - false, + // Dirty state. + pub const Dirty = lib.Enum(lib.target, &.{ + // Not dirty at all. Can skip rendering if prior state was + // already rendered. + "false", - /// Partially dirty. Some rows changed but not all. None of the - /// global state changed such as colors. - partial, + // Some rows changed but not all. None of the global state + // changed such as colors. + "partial", - /// Fully dirty. Global state changed or dimensions changed. All rows - /// should be redrawn. - full, - }; + // Global state changed or dimensions changed. All rows should + // be redrawn. + "full", + }); const SelectionCache = struct { selection: Selection, @@ -740,7 +741,7 @@ pub const RenderState = struct { /// we can adjust this later. /// /// NOTE: There is a limitation in that wrapped lines before/after - /// the the top/bottom line of the viewport are not included, since + /// the top/bottom line of the viewport are not included, since /// the render state cuts them off. pub fn string( self: *const RenderState, @@ -908,7 +909,7 @@ test "basic text" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("ABCD"); + s.nextSlice("ABCD"); var state: RenderState = .empty; defer state.deinit(alloc); @@ -944,9 +945,9 @@ test "styled text" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("\x1b[1mA"); // Bold - try s.nextSlice("\x1b[0;3mB"); // Italic - try s.nextSlice("\x1b[0;4mC"); // Underline + s.nextSlice("\x1b[1mA"); // Bold + s.nextSlice("\x1b[0;3mB"); // Italic + s.nextSlice("\x1b[0;4mC"); // Underline var state: RenderState = .empty; defer state.deinit(alloc); @@ -990,8 +991,8 @@ test "grapheme" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("A"); - try s.nextSlice("👨‍"); // this has a ZWJ + s.nextSlice("A"); + s.nextSlice("👨‍"); // this has a ZWJ var state: RenderState = .empty; defer state.deinit(alloc); @@ -1037,7 +1038,7 @@ test "cursor state in viewport" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("A\x1b[H"); + s.nextSlice("A\x1b[H"); var state: RenderState = .empty; defer state.deinit(alloc); @@ -1052,14 +1053,14 @@ test "cursor state in viewport" { try testing.expect(state.cursor.style.default()); // Set a style on the cursor - try s.nextSlice("\x1b[1m"); // Bold + s.nextSlice("\x1b[1m"); // Bold try state.update(alloc, &t); try testing.expect(!state.cursor.style.default()); try testing.expect(state.cursor.style.flags.bold); - try s.nextSlice("\x1b[0m"); // Reset style + s.nextSlice("\x1b[0m"); // Reset style // Move cursor to 2,1 - try s.nextSlice("\x1b[2;3H"); + s.nextSlice("\x1b[2;3H"); try state.update(alloc, &t); try testing.expectEqual(2, state.cursor.active.x); try testing.expectEqual(1, state.cursor.active.y); @@ -1079,7 +1080,7 @@ test "cursor state out of viewport" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("A\r\nB\r\nC\r\nD\r\n"); + s.nextSlice("A\r\nB\r\nC\r\nD\r\n"); var state: RenderState = .empty; defer state.deinit(alloc); @@ -1139,7 +1140,7 @@ test "dirty state" { } // Write to first line - try s.nextSlice("A"); + s.nextSlice("A"); try state.update(alloc, &t); try testing.expectEqual(.partial, state.dirty); { @@ -1170,7 +1171,7 @@ test "colors" { try state.update(alloc, &t); // Change cursor color - try s.nextSlice("\x1b]12;#FF0000\x07"); + s.nextSlice("\x1b]12;#FF0000\x07"); try state.update(alloc, &t); const c = state.colors.cursor.?; @@ -1179,7 +1180,7 @@ test "colors" { try testing.expectEqual(0, c.b); // Change palette color 0 to White - try s.nextSlice("\x1b]4;0;#FFFFFF\x07"); + s.nextSlice("\x1b]4;0;#FFFFFF\x07"); try state.update(alloc, &t); const p0 = state.colors.palette[0]; try testing.expectEqual(0xFF, p0.r); @@ -1275,7 +1276,7 @@ test "linkCells" { defer state.deinit(alloc); // Create a hyperlink - try s.nextSlice("\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\"); + s.nextSlice("\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\"); try state.update(alloc, &t); // Query link at 0,0 @@ -1306,7 +1307,7 @@ test "string" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("AB"); + s.nextSlice("AB"); var state: RenderState = .empty; defer state.deinit(alloc); @@ -1345,12 +1346,12 @@ test "linkCells with scrollback spanning pages" { const first_page_cap = pages.pages.first.?.data.capacity.rows; // Fill first page - for (0..first_page_cap - 1) |_| try s.nextSlice("\r\n"); + for (0..first_page_cap - 1) |_| s.nextSlice("\r\n"); // Create second page with hyperlink - try s.nextSlice("\r\n"); - try s.nextSlice("\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\"); - for (0..(tail_rows - 1)) |_| try s.nextSlice("\r\n"); + s.nextSlice("\r\n"); + s.nextSlice("\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\"); + for (0..(tail_rows - 1)) |_| s.nextSlice("\r\n"); var state: RenderState = .empty; defer state.deinit(alloc); @@ -1416,7 +1417,7 @@ test "dirty row resets highlights" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("ABC"); + s.nextSlice("ABC"); var state: RenderState = .empty; defer state.deinit(alloc); @@ -1451,8 +1452,8 @@ test "dirty row resets highlights" { } // Write to row 0 to make it dirty - try s.nextSlice("\x1b[H"); // Move to home - try s.nextSlice("X"); + s.nextSlice("\x1b[H"); // Move to home + s.nextSlice("X"); try state.update(alloc, &t); // Verify the highlight was reset on the dirty row diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 3f5377417..fa09af5f0 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -853,7 +853,7 @@ test { var stream = t.vtStream(); defer stream.deinit(); - try stream.nextSlice("Hello, world"); + stream.nextSlice("Hello, world"); var ud: TestUserData = .{}; defer ud.deinit(); diff --git a/src/terminal/search/active.zig b/src/terminal/search/active.zig index 236f4c7a6..692a10e12 100644 --- a/src/terminal/search/active.zig +++ b/src/terminal/search/active.zig @@ -108,7 +108,7 @@ test "simple search" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ActiveSearch = try .init(alloc, "Fizz"); defer search.deinit(); @@ -148,15 +148,15 @@ test "clear screen and search" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ActiveSearch = try .init(alloc, "Fizz"); defer search.deinit(); _ = try search.update(&t.screens.active.pages); - try s.nextSlice("\x1b[2J"); // Clear screen - try s.nextSlice("\x1b[H"); // Move cursor home - try s.nextSlice("Buzz\r\nFizz\r\nBuzz"); + s.nextSlice("\x1b[2J"); // Clear screen + s.nextSlice("\x1b[H"); // Move cursor home + s.nextSlice("Buzz\r\nFizz\r\nBuzz"); _ = try search.update(&t.screens.active.pages); { diff --git a/src/terminal/search/pagelist.zig b/src/terminal/search/pagelist.zig index 4bfd241e7..f76ad4e4b 100644 --- a/src/terminal/search/pagelist.zig +++ b/src/terminal/search/pagelist.zig @@ -141,7 +141,7 @@ test "simple search" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: PageListSearch = try .init( alloc, @@ -191,14 +191,14 @@ test "feed multiple pages with matches" { // Fill up first page const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - try s.nextSlice("Fizz"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + s.nextSlice("Fizz"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); // Create second page - try s.nextSlice("\r\n"); + s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); - try s.nextSlice("Buzz\r\nFizz"); + s.nextSlice("Buzz\r\nFizz"); var search: PageListSearch = try .init( alloc, @@ -235,13 +235,13 @@ test "feed multiple pages no matches" { // Fill up first page const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - try s.nextSlice("Hello"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + s.nextSlice("Hello"); // Create second page - try s.nextSlice("\r\n"); + s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); - try s.nextSlice("World"); + s.nextSlice("World"); var search: PageListSearch = try .init( alloc, @@ -275,14 +275,14 @@ test "feed iteratively through multiple matches" { const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; // Fill first page with a match at the end - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - try s.nextSlice("Page1Test"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + s.nextSlice("Page1Test"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); // Create second page with a match - try s.nextSlice("\r\n"); + s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); - try s.nextSlice("Page2Test"); + s.nextSlice("Page2Test"); var search: PageListSearch = try .init( alloc, @@ -316,13 +316,13 @@ test "feed with match spanning page boundary" { const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; // Fill first page ending with "Te" - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - for (0..t.screens.active.pages.cols - 2) |_| try s.nextSlice("x"); - try s.nextSlice("Te"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + for (0..t.screens.active.pages.cols - 2) |_| s.nextSlice("x"); + s.nextSlice("Te"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); // Second page starts with "st" - try s.nextSlice("st"); + s.nextSlice("st"); try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); var search: PageListSearch = try .init( @@ -370,15 +370,15 @@ test "feed with match spanning page boundary with newline" { const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; // Fill first page ending with "Te" - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - for (0..t.screens.active.pages.cols - 2) |_| try s.nextSlice("x"); - try s.nextSlice("Te"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + for (0..t.screens.active.pages.cols - 2) |_| s.nextSlice("x"); + s.nextSlice("Te"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); // Second page starts with "st" - try s.nextSlice("\r\n"); + s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); - try s.nextSlice("st"); + s.nextSlice("st"); var search: PageListSearch = try .init( alloc, diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 74828d879..e98ecd958 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -740,13 +740,9 @@ pub const ScreenSearch = struct { return true; }; - const next_idx = prev.idx + 1; const active_len = self.active_results.items.len; const history_len = self.history_results.items.len; - if (next_idx >= active_len + history_len) { - // No more matches. We don't wrap or reset the match currently. - return false; - } + const next_idx = if (prev.idx + 1 >= active_len + history_len) 0 else prev.idx + 1; const hl: FlattenedHighlight = if (next_idx < active_len) self.active_results.items[active_len - 1 - next_idx] else @@ -800,14 +796,10 @@ pub const ScreenSearch = struct { return true; }; - // Can't go below zero - if (prev.idx == 0) { - // No more matches. We don't wrap or reset the match currently. - return false; - } - - const next_idx = prev.idx - 1; const active_len = self.active_results.items.len; + const history_len = self.history_results.items.len; + const next_idx = if (prev.idx != 0) prev.idx - 1 else active_len - 1 + history_len; + const hl: FlattenedHighlight = if (next_idx < active_len) self.active_results.items[active_len - 1 - next_idx] else @@ -835,7 +827,7 @@ test "simple search" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -885,10 +877,10 @@ test "simple search with history" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\n"); - while (list.totalPages() < 3) try s.nextSlice("\r\n"); - for (0..list.rows) |_| try s.nextSlice("\r\n"); - try s.nextSlice("hello."); + s.nextSlice("Fizz\r\n"); + while (list.totalPages() < 3) s.nextSlice("\r\n"); + for (0..list.rows) |_| s.nextSlice("\r\n"); + s.nextSlice("hello."); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -925,7 +917,7 @@ test "reload active with history change" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\n"); + s.nextSlice("Fizz\r\n"); // Start up our search which will populate our initial active area. var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); @@ -938,9 +930,9 @@ test "reload active with history change" { } // Grow into two pages so our history pin will move. - while (list.totalPages() < 2) try s.nextSlice("\r\n"); - for (0..list.rows) |_| try s.nextSlice("\r\n"); - try s.nextSlice("2Fizz"); + while (list.totalPages() < 2) s.nextSlice("\r\n"); + for (0..list.rows) |_| s.nextSlice("\r\n"); + s.nextSlice("2Fizz"); // Active area changed so reload try search.reloadActive(); @@ -977,7 +969,7 @@ test "reload active with history change" { // Reset the screen which will make our pin garbage. t.fullReset(); - try s.nextSlice("WeFizzing"); + s.nextSlice("WeFizzing"); try search.reloadActive(); try search.searchAll(); @@ -1006,7 +998,7 @@ test "active change contents" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fuzz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fuzz\r\nBuzz\r\nFizz\r\nBang"); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -1014,8 +1006,8 @@ test "active change contents" { try testing.expectEqual(1, search.active_results.items.len); // Erase the screen, move our cursor to the top, and change contents. - try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home - try s.nextSlice("Bang\r\nFizz\r\nHello!"); + s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + s.nextSlice("Bang\r\nFizz\r\nHello!"); try search.reloadActive(); try search.searchAll(); @@ -1046,7 +1038,7 @@ test "select next" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -1083,17 +1075,17 @@ test "select next" { } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } - // Next match (no wrap) + // Next match (wrap) _ = try search.select(.next); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, - .y = 0, + .y = 2, } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, - .y = 0, + .y = 2, } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } @@ -1105,7 +1097,7 @@ test "select in active changes contents completely" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -1126,8 +1118,8 @@ test "select in active changes contents completely" { } // Erase the screen, move our cursor to the top, and change contents. - try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home - try s.nextSlice("Fuzz\r\nFizz\r\nHello!"); + s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + s.nextSlice("Fuzz\r\nFizz\r\nHello!"); try search.reloadActive(); { @@ -1144,8 +1136,8 @@ test "select in active changes contents completely" { } // Erase the screen, redraw with same contents. - try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home - try s.nextSlice("Fuzz\r\nFizz\r\nFizz"); + s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + s.nextSlice("Fuzz\r\nFizz\r\nFizz"); try search.reloadActive(); { @@ -1175,10 +1167,10 @@ test "select into history" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\n"); - while (list.totalPages() < 3) try s.nextSlice("\r\n"); - for (0..list.rows) |_| try s.nextSlice("\r\n"); - try s.nextSlice("hello."); + s.nextSlice("Fizz\r\n"); + while (list.totalPages() < 3) s.nextSlice("\r\n"); + for (0..list.rows) |_| s.nextSlice("\r\n"); + s.nextSlice("hello."); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -1199,8 +1191,8 @@ test "select into history" { } // Erase the screen, redraw with same contents. - try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home - try s.nextSlice("yo yo"); + s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + s.nextSlice("yo yo"); try search.reloadActive(); { @@ -1217,7 +1209,7 @@ test "select into history" { } // Create some new history by adding more lines. - try s.nextSlice("\r\nfizz\r\nfizz\r\nfizz"); // Clear screen and move home + s.nextSlice("\r\nfizz\r\nfizz\r\nfizz"); // Clear screen and move home try search.reloadActive(); { // Our selection should not move since the history is still not @@ -1241,7 +1233,7 @@ test "select prev" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -1278,17 +1270,17 @@ test "select prev" { } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } - // Prev match (no wrap, stays at newest) + // Prev match (wrap) _ = try search.select(.prev); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, - .y = 2, + .y = 0, } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, - .y = 2, + .y = 0, } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } @@ -1300,7 +1292,7 @@ test "select prev then next" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -1350,10 +1342,10 @@ test "select prev with history" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\n"); - while (list.totalPages() < 3) try s.nextSlice("\r\n"); - for (0..list.rows) |_| try s.nextSlice("\r\n"); - try s.nextSlice("Fizz."); + s.nextSlice("Fizz\r\n"); + while (list.totalPages() < 3) s.nextSlice("\r\n"); + for (0..list.rows) |_| s.nextSlice("\r\n"); + s.nextSlice("Fizz."); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -1407,9 +1399,9 @@ test "screen search no scrollback has no history" { // no way to test it using public APIs, but at the time of writing // this test, CSI 22 J (scroll complete) pushes into scrollback // with alt screen. - try s.nextSlice("Fizz\r\n"); - try s.nextSlice("\x1b[22J"); - try s.nextSlice("hello."); + s.nextSlice("Fizz\r\n"); + s.nextSlice("\x1b[22J"); + s.nextSlice("hello."); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -1439,10 +1431,10 @@ test "reloadActive partial history cleanup on appendSlice error" { // Write multiple "Fizz" matches that will end up in history. // We need enough content to push "Fizz" entries into scrollback. - try s.nextSlice("Fizz\r\nFizz\r\n"); - while (list.totalPages() < 3) try s.nextSlice("\r\n"); - for (0..list.rows) |_| try s.nextSlice("\r\n"); - try s.nextSlice("Fizz."); + s.nextSlice("Fizz\r\nFizz\r\n"); + while (list.totalPages() < 3) s.nextSlice("\r\n"); + for (0..list.rows) |_| s.nextSlice("\r\n"); + s.nextSlice("Fizz."); // Complete initial search var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); @@ -1451,9 +1443,9 @@ test "reloadActive partial history cleanup on appendSlice error" { // Now trigger reloadActive by adding more content that changes the // active/history boundary. First add more "Fizz" entries to history. - try s.nextSlice("\r\nFizz\r\nFizz\r\n"); - while (list.totalPages() < 4) try s.nextSlice("\r\n"); - for (0..list.rows) |_| try s.nextSlice("\r\n"); + s.nextSlice("\r\nFizz\r\nFizz\r\n"); + while (list.totalPages() < 4) s.nextSlice("\r\n"); + for (0..list.rows) |_| s.nextSlice("\r\n"); // Arm the tripwire to fail at appendSlice (after the loop completes). // At this point, there are FlattenedHighlight items in the results list @@ -1486,10 +1478,10 @@ test "reloadActive partial history cleanup on loop append error" { // Write multiple "Fizz" matches that will end up in history. // We need enough content to push "Fizz" entries into scrollback. - try s.nextSlice("Fizz\r\nFizz\r\n"); - while (list.totalPages() < 3) try s.nextSlice("\r\n"); - for (0..list.rows) |_| try s.nextSlice("\r\n"); - try s.nextSlice("Fizz."); + s.nextSlice("Fizz\r\nFizz\r\n"); + while (list.totalPages() < 3) s.nextSlice("\r\n"); + for (0..list.rows) |_| s.nextSlice("\r\n"); + s.nextSlice("Fizz."); // Complete initial search var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); @@ -1498,9 +1490,9 @@ test "reloadActive partial history cleanup on loop append error" { // Now trigger reloadActive by adding more content that changes the // active/history boundary. First add more "Fizz" entries to history. - try s.nextSlice("\r\nFizz\r\nFizz\r\n"); - while (list.totalPages() < 4) try s.nextSlice("\r\n"); - for (0..list.rows) |_| try s.nextSlice("\r\n"); + s.nextSlice("\r\nFizz\r\nFizz\r\n"); + while (list.totalPages() < 4) s.nextSlice("\r\n"); + for (0..list.rows) |_| s.nextSlice("\r\n"); // Arm the tripwire to fail after the first loop append succeeds. // This leaves at least one FlattenedHighlight in the results list diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index c3c29e085..93a606fda 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -1583,7 +1583,7 @@ test "SlidingWindow single append soft wrapped" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("A\r\nxxboo!\r\nC"); + s.nextSlice("A\r\nxxboo!\r\nC"); // We want to test single-page cases. const screen = t.screens.active; @@ -1620,7 +1620,7 @@ test "SlidingWindow single append reversed soft wrapped" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("A\r\nxxboo!\r\nC"); + s.nextSlice("A\r\nxxboo!\r\nC"); // We want to test single-page cases. const screen = t.screens.active; diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index f5e6c8601..35dd93315 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -223,7 +223,7 @@ test "simple search" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ViewportSearch = try .init(alloc, "Fizz"); defer search.deinit(); @@ -266,15 +266,15 @@ test "clear screen and search" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ViewportSearch = try .init(alloc, "Fizz"); defer search.deinit(); try testing.expect(try search.update(&t.screens.active.pages)); - try s.nextSlice("\x1b[2J"); // Clear screen - try s.nextSlice("\x1b[H"); // Move cursor home - try s.nextSlice("Buzz\r\nFizz\r\nBuzz"); + s.nextSlice("\x1b[2J"); // Clear screen + s.nextSlice("\x1b[H"); // Move cursor home + s.nextSlice("Buzz\r\nFizz\r\nBuzz"); try testing.expect(try search.update(&t.screens.active.pages)); { @@ -299,7 +299,7 @@ test "clear screen and search dirty tracking" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ViewportSearch = try .init(alloc, "Fizz"); defer search.deinit(); @@ -313,9 +313,9 @@ test "clear screen and search dirty tracking" { // Should not update since nothing changed try testing.expect(!try search.update(&t.screens.active.pages)); - try s.nextSlice("\x1b[2J"); // Clear screen - try s.nextSlice("\x1b[H"); // Move cursor home - try s.nextSlice("Buzz\r\nFizz\r\nBuzz"); + s.nextSlice("\x1b[2J"); // Clear screen + s.nextSlice("\x1b[H"); // Move cursor home + s.nextSlice("Buzz\r\nFizz\r\nBuzz"); // Should still not update since active area isn't dirty try testing.expect(!try search.update(&t.screens.active.pages)); @@ -349,14 +349,14 @@ test "history search, no active area" { // Fill up first page const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; - try s.nextSlice("Fizz\r\n"); - for (1..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + s.nextSlice("Fizz\r\n"); + for (1..first_page_rows - 1) |_| s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); // Create second page - try s.nextSlice("\r\n"); + s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); - try s.nextSlice("Buzz\r\nFizz"); + s.nextSlice("Buzz\r\nFizz"); t.scrollViewport(.top); diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index 314d60686..e9b9354c3 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -1,15 +1,12 @@ //! SGR (Select Graphic Rendition) attrinvbute parsing and types. const std = @import("std"); -const build_options = @import("terminal_options"); const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; -const lib = @import("../lib/main.zig"); +const lib = @import("lib.zig"); const color = @import("color.zig"); const SepList = @import("Parser.zig").Action.CSI.SepList; -const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; - /// Attribute type for SGR pub const Attribute = union(Tag) { /// Unset all attributes @@ -81,7 +78,7 @@ pub const Attribute = union(Tag) { @"256_fg": u8, pub const Tag = lib.Enum( - lib_target, + lib.target, &.{ "unset", "unknown", @@ -158,7 +155,7 @@ pub const Attribute = union(Tag) { /// C ABI functions. const c_union = lib.TaggedUnion( - lib_target, + lib.target, @This(), // Padding size for C ABI compatibility. // Largest variant is Unknown.C: 2 pointers + 2 usize = 32 bytes on 64-bit. diff --git a/src/terminal/size_report.zig b/src/terminal/size_report.zig new file mode 100644 index 000000000..2ce89a937 --- /dev/null +++ b/src/terminal/size_report.zig @@ -0,0 +1,160 @@ +const std = @import("std"); +const lib = @import("lib.zig"); +const CellCountInt = @import("size.zig").CellCountInt; + +/// Output formats for terminal size reports written to the PTY. +pub const Style = lib.Enum(lib.target, &.{ + // In-band size reports (mode 2048) + "mode_2048", + // XTWINOPS: report text area size in pixels + "csi_14_t", + // XTWINOPS: report cell size in pixels + "csi_16_t", + // XTWINOPS: report text area size in characters + "csi_18_t", +}); + +/// Runtime size values used to encode terminal size reports. +pub const Size = lib.Struct(lib.target, struct { + /// Terminal row count in cells. + rows: CellCountInt, + + /// Terminal column count in cells. + columns: CellCountInt, + + /// Width of a single terminal cell in pixels. + cell_width: u32, + + /// Height of a single terminal cell in pixels. + cell_height: u32, +}); + +fn widthPixels(s: Size) u64 { + return @as(u64, s.columns) * @as(u64, s.cell_width); +} + +fn heightPixels(s: Size) u64 { + return @as(u64, s.rows) * @as(u64, s.cell_height); +} + +/// Encode a terminal size report sequence. +pub fn encode( + writer: *std.Io.Writer, + style: Style, + size: Size, +) std.Io.Writer.Error!void { + switch (style) { + .mode_2048 => try writer.print( + "\x1B[48;{};{};{};{}t", + .{ + size.rows, + size.columns, + heightPixels(size), + widthPixels(size), + }, + ), + + .csi_14_t => try writer.print( + "\x1b[4;{};{}t", + .{ + heightPixels(size), + widthPixels(size), + }, + ), + + .csi_16_t => try writer.print( + "\x1b[6;{};{}t", + .{ + size.cell_height, + size.cell_width, + }, + ), + + .csi_18_t => try writer.print( + "\x1b[8;{};{}t", + .{ + size.rows, + size.columns, + }, + ), + } +} + +fn testSize() Size { + return .{ + .rows = 24, + .columns = 80, + .cell_width = 9, + .cell_height = 18, + }; +} + +test "encode mode 2048" { + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, .mode_2048, testSize()); + + try std.testing.expectEqualStrings("\x1B[48;24;80;432;720t", writer.buffered()); +} + +test "encode csi 14 t" { + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, .csi_14_t, testSize()); + + try std.testing.expectEqualStrings("\x1b[4;432;720t", writer.buffered()); +} + +test "encode csi 16 t" { + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, .csi_16_t, testSize()); + + try std.testing.expectEqualStrings("\x1b[6;18;9t", writer.buffered()); +} + +test "encode csi 18 t" { + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, .csi_18_t, testSize()); + + try std.testing.expectEqualStrings("\x1b[8;24;80t", writer.buffered()); +} + +test "encode max values for all fields" { + const max_size: Size = .{ + .rows = std.math.maxInt(@FieldType(Size, "rows")), + .columns = std.math.maxInt(@FieldType(Size, "columns")), + .cell_width = std.math.maxInt(@FieldType(Size, "cell_width")), + .cell_height = std.math.maxInt(@FieldType(Size, "cell_height")), + }; + + const Case = struct { + style: Style, + expected: []const u8, + }; + + inline for ([_]Case{ + .{ + .style = .mode_2048, + .expected = "\x1B[48;65535;65535;281470681677825;281470681677825t", + }, + .{ + .style = .csi_14_t, + .expected = "\x1b[4;281470681677825;281470681677825t", + }, + .{ + .style = .csi_16_t, + .expected = "\x1b[6;4294967295;4294967295t", + }, + .{ + .style = .csi_18_t, + .expected = "\x1b[8;65535;65535t", + }, + }) |case| { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, case.style, max_size); + try std.testing.expectEqualStrings(case.expected, writer.buffered()); + } +} diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index e89c73e66..9771334f9 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -5,10 +5,11 @@ const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const simd = @import("../simd/main.zig"); -const lib = @import("../lib/main.zig"); +const lib = @import("lib.zig"); const Parser = @import("Parser.zig"); const ansi = @import("ansi.zig"); const charsets = @import("charsets.zig"); +const device_attributes = @import("device_attributes.zig"); const device_status = @import("device_status.zig"); const csi = @import("csi.zig"); const kitty = @import("kitty.zig"); @@ -16,7 +17,7 @@ const modes = @import("modes.zig"); const osc = @import("osc.zig"); const sgr = @import("sgr.zig"); const UTF8Decoder = @import("UTF8Decoder.zig"); -const MouseShape = @import("mouse_shape.zig").MouseShape; +const MouseShape = @import("mouse.zig").Shape; const log = std.log.scoped(.stream); @@ -28,8 +29,6 @@ const log = std.log.scoped(.stream); /// do something else. const debug = false; -const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; - /// The possible actions that can be emitted by the Stream /// function for handling. pub const Action = union(Key) { @@ -97,7 +96,7 @@ pub const Action = union(Key) { title_push: u16, title_pop: u16, xtversion, - device_attributes: ansi.DeviceAttributeReq, + device_attributes: device_attributes.Req, device_status: DeviceStatus, kitty_keyboard_query, kitty_keyboard_push: KittyKeyboardFlags, @@ -128,7 +127,7 @@ pub const Action = union(Key) { semantic_prompt: SemanticPrompt, pub const Key = lib.Enum( - lib_target, + lib.target, &.{ "print", "print_repeat", @@ -228,7 +227,7 @@ pub const Action = union(Key) { /// C ABI functions. const c_union = lib.TaggedUnion( - lib_target, + lib.target, @This(), // TODO: Before shipping an ABI-compatible libghostty, verify this. // This was just arbitrarily chosen for now. @@ -253,7 +252,7 @@ pub const Action = union(Key) { } }; - pub const InvokeCharset = lib.Struct(lib_target, struct { + pub const InvokeCharset = lib.Struct(lib.target, struct { bank: charsets.ActiveSlot, charset: charsets.Slots, locking: bool, @@ -383,7 +382,7 @@ pub const Action = union(Key) { } }; - pub const ConfigureCharset = lib.Struct(lib_target, struct { + pub const ConfigureCharset = lib.Struct(lib.target, struct { slot: charsets.Slots, charset: charsets.Charset, }); @@ -406,7 +405,7 @@ pub const Action = union(Key) { /// Returns a type that can process a stream of tty control characters. /// This will call the `vt` function on type T with the following signature: /// -/// fn(comptime action: Action.Key, value: Action.Value(action)) !void +/// fn(comptime action: Action.Key, value: Action.Value(action)) void /// /// The handler type T can choose to react to whatever actions it cares /// about in its pursuit of implementing a terminal emulator or other @@ -419,11 +418,12 @@ pub const Action = union(Key) { /// e.g. you don't need to pay a conditional branching cost on every single /// action because the Zig compiler codegens separate code paths for every /// single action at comptime. -pub fn Stream(comptime Handler: type) type { +pub fn Stream(comptime H: type) type { return struct { const Self = @This(); pub const Action = streampkg.Action; + pub const Handler = H; const T = switch (@typeInfo(Handler)) { .pointer => |p| p.child, @@ -468,11 +468,11 @@ pub fn Stream(comptime Handler: type) type { } /// Process a string of characters. - pub inline fn nextSlice(self: *Self, input: []const u8) !void { + pub inline fn nextSlice(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| try self.next(c); + for (input) |c| self.next(c); return; } @@ -485,13 +485,17 @@ pub fn Stream(comptime Handler: type) type { var i: usize = 0; while (true) { const len = @min(cp_buf.len, input.len - i); - try self.nextSliceCapped(input[i .. i + len], &cp_buf); + self.nextSliceCapped(input[i .. i + len], &cp_buf); i += len; if (i >= input.len) break; } } - inline fn nextSliceCapped(self: *Self, input: []const u8, cp_buf: []u32) !void { + inline fn nextSliceCapped( + self: *Self, + input: []const u8, + cp_buf: []u32, + ) void { assert(input.len <= cp_buf.len); var offset: usize = 0; @@ -500,7 +504,7 @@ pub fn Stream(comptime Handler: type) type { // a code sequence, we continue until it's not. while (self.utf8decoder.state != 0) { if (offset >= input.len) return; - try self.nextUtf8(input[offset]); + self.nextUtf8(input[offset]); offset += 1; } if (offset >= input.len) return; @@ -508,9 +512,9 @@ pub fn Stream(comptime Handler: type) type { // If we're not in the ground state then we process until // we are. This can happen if the last chunk of input put us // in the middle of a control sequence. - offset += try self.consumeUntilGround(input[offset..]); + offset += self.consumeUntilGround(input[offset..]); if (offset >= input.len) return; - offset += try self.consumeAllEscapes(input[offset..]); + offset += self.consumeAllEscapes(input[offset..]); // If we're in the ground state then we can use SIMD to process // input until we see an ESC (0x1B), since all other characters @@ -519,9 +523,9 @@ pub fn Stream(comptime Handler: type) type { const res = simd.vt.utf8DecodeUntilControlSeq(input[offset..], cp_buf); for (cp_buf[0..res.decoded]) |cp| { if (cp <= 0xF) { - try self.execute(@intCast(cp)); + self.execute(@intCast(cp)); } else { - try self.print(@intCast(cp)); + self.print(@intCast(cp)); } } // Consume the bytes we just processed. @@ -534,12 +538,12 @@ pub fn Stream(comptime Handler: type) type { // to the scalar parser. if (input[offset] != 0x1B) { const rem = input[offset..]; - for (rem) |c| try self.nextUtf8(c); + for (rem) |c| self.nextUtf8(c); return; } // Process control sequences until we run out. - offset += try self.consumeAllEscapes(input[offset..]); + offset += self.consumeAllEscapes(input[offset..]); } } @@ -548,13 +552,13 @@ pub fn Stream(comptime Handler: type) type { /// /// Expects input to start with 0x1B, use consumeUntilGround first /// if the stream may be in the middle of an escape sequence. - inline fn consumeAllEscapes(self: *Self, input: []const u8) !usize { + inline fn consumeAllEscapes(self: *Self, input: []const u8) usize { var offset: usize = 0; while (input[offset] == 0x1B) { self.parser.state = .escape; self.parser.clear(); offset += 1; - offset += try self.consumeUntilGround(input[offset..]); + offset += self.consumeUntilGround(input[offset..]); if (offset >= input.len) return input.len; } return offset; @@ -562,11 +566,11 @@ pub fn Stream(comptime Handler: type) type { /// Parses escape sequences until the parser reaches the ground state. /// Returns the number of bytes consumed from the provided input. - inline fn consumeUntilGround(self: *Self, input: []const u8) !usize { + inline fn consumeUntilGround(self: *Self, input: []const u8) usize { var offset: usize = 0; while (self.parser.state != .ground) { if (offset >= input.len) return input.len; - try self.nextNonUtf8(input[offset]); + self.nextNonUtf8(input[offset]); offset += 1; } return offset; @@ -575,27 +579,27 @@ pub fn Stream(comptime Handler: type) type { /// Like nextSlice but takes one byte and is necessarily a scalar /// 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 { + pub inline fn next(self: *Self, c: u8) void { // The scalar path can be responsible for decoding UTF-8. if (self.parser.state == .ground) { - try self.nextUtf8(c); + self.nextUtf8(c); return; } - try self.nextNonUtf8(c); + self.nextNonUtf8(c); } /// Process the next byte and print as necessary. /// /// This assumes we're in the UTF-8 decoding state. If we may not /// be in the UTF-8 decoding state call nextSlice or next. - inline fn nextUtf8(self: *Self, c: u8) !void { + inline fn nextUtf8(self: *Self, c: u8) void { assert(self.parser.state == .ground); const res = self.utf8decoder.next(c); const consumed = res[1]; if (res[0]) |codepoint| { - try self.handleCodepoint(codepoint); + self.handleCodepoint(codepoint); } if (!consumed) { // We optimize for the scenario where the text being @@ -608,7 +612,7 @@ pub fn Stream(comptime Handler: type) type { // to not consume the byte twice in a row. assert(retry[1] == true); if (retry[0]) |codepoint| { - try self.handleCodepoint(codepoint); + self.handleCodepoint(codepoint); } } } @@ -617,7 +621,7 @@ pub fn Stream(comptime Handler: type) type { /// /// This function is abstracted this way to handle the case where /// the decoder emits a 0x1B after rejecting an ill-formed sequence. - inline fn handleCodepoint(self: *Self, c: u21) !void { + inline fn handleCodepoint(self: *Self, c: u21) void { // 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. @@ -626,7 +630,7 @@ pub fn Stream(comptime Handler: type) type { // C0 control if (c <= 0xF) { @branchHint(.unlikely); - try self.execute(@intCast(c)); + self.execute(@intCast(c)); return; } // ESC @@ -635,14 +639,14 @@ pub fn Stream(comptime Handler: type) type { self.parser.clear(); return; } - try self.print(@intCast(c)); + self.print(@intCast(c)); } /// Process the next character and call any callbacks if necessary. /// /// This assumes that we're not in the UTF-8 decoding state. If /// we may be in the UTF-8 decoding state call nextSlice or next. - fn nextNonUtf8(self: *Self, c: u8) !void { + fn nextNonUtf8(self: *Self, c: u8) void { assert(self.parser.state != .ground); // Fast path for CSI entry. @@ -659,7 +663,7 @@ pub fn Stream(comptime Handler: type) type { @branchHint(.likely); switch (c) { // A C0 escape (yes, this is valid): - 0x00...0x0F => try self.execute(c), + 0x00...0x0F => self.execute(c), // We ignore C0 escapes > 0xF since execute // doesn't have processing for them anyway: 0x10...0x17, 0x19, 0x1C...0x1F => {}, @@ -725,32 +729,32 @@ pub fn Stream(comptime Handler: type) type { } switch (action) { - .print => |p| try self.print(p), - .execute => |code| try self.execute(code), - .csi_dispatch => |csi_action| try self.csiDispatch(csi_action), - .esc_dispatch => |esc| try self.escDispatch(esc), - .osc_dispatch => |cmd| try self.oscDispatch(cmd), - .dcs_hook => |dcs| try self.handler.vt(.dcs_hook, dcs), - .dcs_put => |code| try self.handler.vt(.dcs_put, code), - .dcs_unhook => try self.handler.vt(.dcs_unhook, {}), - .apc_start => try self.handler.vt(.apc_start, {}), - .apc_put => |code| try self.handler.vt(.apc_put, code), - .apc_end => try self.handler.vt(.apc_end, {}), + .print => |p| self.print(p), + .execute => |code| self.execute(code), + .csi_dispatch => |csi_action| self.csiDispatch(csi_action), + .esc_dispatch => |esc| self.escDispatch(esc), + .osc_dispatch => |cmd| self.oscDispatch(cmd), + .dcs_hook => |dcs| self.handler.vt(.dcs_hook, dcs), + .dcs_put => |code| self.handler.vt(.dcs_put, code), + .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, {}), } } } - pub inline fn print(self: *Self, c: u21) !void { - try self.handler.vt(.print, .{ .cp = c }); + inline fn print(self: *Self, c: u21) void { + self.handler.vt(.print, .{ .cp = c }); } - pub inline fn execute(self: *Self, c: u8) !void { + inline fn execute(self: *Self, c: u8) void { // If the character is > 0x7F, it's a C1 (8-bit) control, // which is strictly equivalent to `ESC` plus `c - 0x40`. if (c > 0x7F) { @branchHint(.unlikely); log.info("executing C1 0x{x} as ESC {c}", .{ c, c - 0x40 }); - try self.escDispatch(.{ + self.escDispatch(.{ .intermediates = &.{}, .final = c - 0x40, }); @@ -763,20 +767,20 @@ pub fn Stream(comptime Handler: type) type { // We ignore SOH/STX: https://github.com/microsoft/terminal/issues/10786 .NUL, .SOH, .STX => {}, - .ENQ => try self.handler.vt(.enquiry, {}), - .BEL => try self.handler.vt(.bell, {}), - .BS => try self.handler.vt(.backspace, {}), - .HT => try self.handler.vt(.horizontal_tab, 1), - .LF, .VT, .FF => try self.handler.vt(.linefeed, {}), - .CR => try self.handler.vt(.carriage_return, {}), - .SO => try self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G1, .locking = false }), - .SI => try self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G0, .locking = false }), + .ENQ => self.handler.vt(.enquiry, {}), + .BEL => self.handler.vt(.bell, {}), + .BS => self.handler.vt(.backspace, {}), + .HT => self.handler.vt(.horizontal_tab, 1), + .LF, .VT, .FF => self.handler.vt(.linefeed, {}), + .CR => self.handler.vt(.carriage_return, {}), + .SO => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G1, .locking = false }), + .SI => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G0, .locking = false }), else => log.warn("invalid C0 character, ignoring: 0x{x}", .{c}), } } - inline fn csiDispatch(self: *Self, input: Parser.Action.CSI) !void { + inline fn csiDispatch(self: *Self, input: Parser.Action.CSI) void { // The branch hints here are based on real world data // which indicates that the most common CSI finals are: // @@ -806,7 +810,7 @@ pub fn Stream(comptime Handler: type) type { 'A', 'k' => { @branchHint(.likely); switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_up, .{ + 0 => self.handler.vt(.cursor_up, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -827,7 +831,7 @@ pub fn Stream(comptime Handler: type) type { // CUD - Cursor Down 'B' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_down, .{ + 0 => self.handler.vt(.cursor_down, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -849,7 +853,7 @@ pub fn Stream(comptime Handler: type) type { 'C' => { @branchHint(.likely); switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_right, .{ + 0 => self.handler.vt(.cursor_right, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -870,7 +874,7 @@ pub fn Stream(comptime Handler: type) type { // CUB - Cursor Left 'D', 'j' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_left, .{ + 0 => self.handler.vt(.cursor_left, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -891,7 +895,7 @@ pub fn Stream(comptime Handler: type) type { // CNL - Cursor Next Line 'E' => switch (input.intermediates.len) { 0 => { - try self.handler.vt(.cursor_down, .{ + self.handler.vt(.cursor_down, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -902,7 +906,7 @@ pub fn Stream(comptime Handler: type) type { }, }, }); - try self.handler.vt(.carriage_return, {}); + self.handler.vt(.carriage_return, {}); }, else => log.warn( @@ -914,7 +918,7 @@ pub fn Stream(comptime Handler: type) type { // CPL - Cursor Previous Line 'F' => switch (input.intermediates.len) { 0 => { - try self.handler.vt(.cursor_up, .{ + self.handler.vt(.cursor_up, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -925,7 +929,7 @@ pub fn Stream(comptime Handler: type) type { }, }, }); - try self.handler.vt(.carriage_return, {}); + self.handler.vt(.carriage_return, {}); }, else => log.warn( @@ -937,7 +941,7 @@ pub fn Stream(comptime Handler: type) type { // HPA - Cursor Horizontal Position Absolute // TODO: test 'G', '`' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_col, .{ + 0 => self.handler.vt(.cursor_col, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -971,7 +975,7 @@ pub fn Stream(comptime Handler: type) type { return; }, }; - try self.handler.vt(.cursor_pos, pos); + self.handler.vt(.cursor_pos, pos); }, else => log.warn( @@ -983,7 +987,7 @@ pub fn Stream(comptime Handler: type) type { // CHT - Cursor Horizontal Tabulation 'I' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.horizontal_tab, switch (input.params.len) { + 0 => self.handler.vt(.horizontal_tab, switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -1023,11 +1027,11 @@ pub fn Stream(comptime Handler: type) type { }; switch (mode) { - .below => try self.handler.vt(.erase_display_below, protected), - .above => try self.handler.vt(.erase_display_above, protected), - .complete => try self.handler.vt(.erase_display_complete, protected), - .scrollback => try self.handler.vt(.erase_display_scrollback, protected), - .scroll_complete => try self.handler.vt(.erase_display_scroll_complete, protected), + .below => self.handler.vt(.erase_display_below, protected), + .above => self.handler.vt(.erase_display_above, protected), + .complete => self.handler.vt(.erase_display_complete, protected), + .scrollback => self.handler.vt(.erase_display_scrollback, protected), + .scroll_complete => self.handler.vt(.erase_display_scroll_complete, protected), } }, @@ -1059,10 +1063,10 @@ pub fn Stream(comptime Handler: type) type { }; switch (mode) { - .right => try self.handler.vt(.erase_line_right, protected), - .left => try self.handler.vt(.erase_line_left, protected), - .complete => try self.handler.vt(.erase_line_complete, protected), - .right_unless_pending_wrap => try self.handler.vt(.erase_line_right_unless_pending_wrap, protected), + .right => self.handler.vt(.erase_line_right, protected), + .left => self.handler.vt(.erase_line_left, protected), + .complete => self.handler.vt(.erase_line_complete, protected), + .right_unless_pending_wrap => self.handler.vt(.erase_line_right_unless_pending_wrap, protected), _ => { @branchHint(.unlikely); log.warn("invalid erase line mode: {}", .{mode}); @@ -1073,7 +1077,7 @@ pub fn Stream(comptime Handler: type) type { // IL - Insert Lines // TODO: test 'L' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.insert_lines, switch (input.params.len) { + 0 => self.handler.vt(.insert_lines, switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -1091,7 +1095,7 @@ pub fn Stream(comptime Handler: type) type { // DL - Delete Lines // TODO: test 'M' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.delete_lines, switch (input.params.len) { + 0 => self.handler.vt(.delete_lines, switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -1108,7 +1112,7 @@ pub fn Stream(comptime Handler: type) type { // Delete Character (DCH) 'P' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.delete_chars, switch (input.params.len) { + 0 => self.handler.vt(.delete_chars, switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -1126,7 +1130,7 @@ pub fn Stream(comptime Handler: type) type { // Scroll Up (SD) 'S' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.scroll_up, switch (input.params.len) { + 0 => self.handler.vt(.scroll_up, switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -1143,7 +1147,7 @@ pub fn Stream(comptime Handler: type) type { // Scroll Down (SD) 'T' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.scroll_down, switch (input.params.len) { + 0 => self.handler.vt(.scroll_down, switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -1164,7 +1168,7 @@ pub fn Stream(comptime Handler: type) type { if (input.params.len == 0 or (input.params.len == 1 and input.params[0] == 0)) { - try self.handler.vt(.tab_set, {}); + self.handler.vt(.tab_set, {}); return; } @@ -1174,9 +1178,9 @@ pub fn Stream(comptime Handler: type) type { 1 => switch (input.params[0]) { 0 => unreachable, - 2 => try self.handler.vt(.tab_clear_current, {}), + 2 => self.handler.vt(.tab_clear_current, {}), - 5 => try self.handler.vt(.tab_clear_all, {}), + 5 => self.handler.vt(.tab_clear_all, {}), else => {}, }, @@ -1192,7 +1196,7 @@ pub fn Stream(comptime Handler: type) type { input.params.len == 1 and input.params[0] == 5) { - try self.handler.vt(.tab_reset, {}); + self.handler.vt(.tab_reset, {}); } else log.warn("invalid cursor tabulation control: {f}", .{input}), else => log.warn( @@ -1205,7 +1209,7 @@ pub fn Stream(comptime Handler: type) type { 'X' => { @branchHint(.likely); switch (input.intermediates.len) { - 0 => try self.handler.vt(.erase_chars, switch (input.params.len) { + 0 => self.handler.vt(.erase_chars, switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -1224,7 +1228,7 @@ pub fn Stream(comptime Handler: type) type { // CHT - Cursor Horizontal Tabulation Back 'Z' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.horizontal_tab_back, switch (input.params.len) { + 0 => self.handler.vt(.horizontal_tab_back, switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -1241,7 +1245,7 @@ pub fn Stream(comptime Handler: type) type { // HPR - Cursor Horizontal Position Relative 'a' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_col_relative, .{ + 0 => self.handler.vt(.cursor_col_relative, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -1260,7 +1264,7 @@ pub fn Stream(comptime Handler: type) type { // Repeat Previous Char (REP) 'b' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.print_repeat, switch (input.params.len) { + 0 => self.handler.vt(.print_repeat, switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -1277,7 +1281,7 @@ pub fn Stream(comptime Handler: type) type { // c - Device Attributes (DA1) 'c' => { - const req: ?ansi.DeviceAttributeReq = switch (input.intermediates.len) { + const req: ?device_attributes.Req = switch (input.intermediates.len) { 0 => .primary, 1 => switch (input.intermediates[0]) { '>' => .secondary, @@ -1288,7 +1292,7 @@ pub fn Stream(comptime Handler: type) type { }; if (req) |r| { - try self.handler.vt(.device_attributes, r); + self.handler.vt(.device_attributes, r); } else { log.warn("invalid device attributes command: {f}", .{input}); return; @@ -1297,7 +1301,7 @@ pub fn Stream(comptime Handler: type) type { // VPA - Cursor Vertical Position Absolute 'd' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_row, .{ + 0 => self.handler.vt(.cursor_row, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -1316,7 +1320,7 @@ pub fn Stream(comptime Handler: type) type { // VPR - Cursor Vertical Position Relative 'e' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_row_relative, .{ + 0 => self.handler.vt(.cursor_row_relative, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -1348,8 +1352,8 @@ pub fn Stream(comptime Handler: type) type { }, }; switch (mode) { - .current => try self.handler.vt(.tab_clear_current, {}), - .all => try self.handler.vt(.tab_clear_all, {}), + .current => self.handler.vt(.tab_clear_current, {}), + .all => self.handler.vt(.tab_clear_all, {}), _ => log.warn("unknown tab clear mode: {}", .{mode}), } }, @@ -1374,7 +1378,7 @@ pub fn Stream(comptime Handler: type) type { for (input.params) |mode_int| { if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { - try self.handler.vt(.set_mode, .{ .mode = mode }); + self.handler.vt(.set_mode, .{ .mode = mode }); } else { log.warn("unimplemented mode: {}", .{mode_int}); } @@ -1395,7 +1399,7 @@ pub fn Stream(comptime Handler: type) type { for (input.params) |mode_int| { if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { - try self.handler.vt(.reset_mode, .{ .mode = mode }); + self.handler.vt(.reset_mode, .{ .mode = mode }); } else { log.warn("unimplemented mode: {}", .{mode_int}); } @@ -1416,7 +1420,7 @@ pub fn Stream(comptime Handler: type) type { }; while (p.next()) |attr| { // log.info("SGR attribute: {}", .{attr}); - try self.handler.vt(.set_attribute, attr); + self.handler.vt(.set_attribute, attr); } }, @@ -1424,7 +1428,7 @@ pub fn Stream(comptime Handler: type) type { '>' => blk: { if (input.params.len == 0) { // Reset - try self.handler.vt(.modify_key_format, .legacy); + self.handler.vt(.modify_key_format, .legacy); break :blk; } @@ -1463,7 +1467,7 @@ pub fn Stream(comptime Handler: type) type { } } - try self.handler.vt(.modify_key_format, format); + self.handler.vt(.modify_key_format, format); }, else => log.warn( @@ -1510,7 +1514,7 @@ pub fn Stream(comptime Handler: type) type { return; }; - try self.handler.vt(.device_status, .{ .request = req }); + self.handler.vt(.device_status, .{ .request = req }); return; } @@ -1524,7 +1528,7 @@ pub fn Stream(comptime Handler: type) type { // control what exactly is being disabled. However, we // only support reverting back to modify other keys in // numeric except format. - try self.handler.vt(.modify_key_format, .other_keys_numeric_except); + self.handler.vt(.modify_key_format, .other_keys_numeric_except); }, else => log.warn( @@ -1566,9 +1570,9 @@ pub fn Stream(comptime Handler: type) type { const mode_raw = input.params[0]; const mode = modes.modeFromInt(mode_raw, ansi_mode); if (mode) |m| { - try self.handler.vt(.request_mode, .{ .mode = m }); + self.handler.vt(.request_mode, .{ .mode = m }); } else { - try self.handler.vt(.request_mode_unknown, .{ + self.handler.vt(.request_mode_unknown, .{ .mode = mode_raw, .ansi = ansi_mode, }); @@ -1606,7 +1610,7 @@ pub fn Stream(comptime Handler: type) type { return; }, }; - try self.handler.vt(.cursor_style, style); + self.handler.vt(.cursor_style, style); }, // DECSCA @@ -1627,14 +1631,14 @@ pub fn Stream(comptime Handler: type) type { }; switch (mode) { - .off => try self.handler.vt(.protected_mode_off, {}), - .iso => try self.handler.vt(.protected_mode_iso, {}), - .dec => try self.handler.vt(.protected_mode_dec, {}), + .off => self.handler.vt(.protected_mode_off, {}), + .iso => self.handler.vt(.protected_mode_iso, {}), + .dec => self.handler.vt(.protected_mode_dec, {}), } }, // XTVERSION - '>' => try self.handler.vt(.xtversion, {}), + '>' => self.handler.vt(.xtversion, {}), else => { log.warn( "ignoring unimplemented CSI q with intermediates: {s}", @@ -1654,9 +1658,9 @@ pub fn Stream(comptime Handler: type) type { switch (input.intermediates.len) { // DECSTBM - Set Top and Bottom Margins 0 => switch (input.params.len) { - 0 => try self.handler.vt(.top_and_bottom_margin, .{ .top_left = 0, .bottom_right = 0 }), - 1 => try self.handler.vt(.top_and_bottom_margin, .{ .top_left = input.params[0], .bottom_right = 0 }), - 2 => try self.handler.vt(.top_and_bottom_margin, .{ .top_left = input.params[0], .bottom_right = input.params[1] }), + 0 => self.handler.vt(.top_and_bottom_margin, .{ .top_left = 0, .bottom_right = 0 }), + 1 => self.handler.vt(.top_and_bottom_margin, .{ .top_left = input.params[0], .bottom_right = 0 }), + 2 => self.handler.vt(.top_and_bottom_margin, .{ .top_left = input.params[0], .bottom_right = input.params[1] }), else => { @branchHint(.unlikely); log.warn("invalid DECSTBM command: {f}", .{input}); @@ -1668,7 +1672,7 @@ pub fn Stream(comptime Handler: type) type { '?' => { for (input.params) |mode_int| { if (modes.modeFromInt(mode_int, false)) |mode| { - try self.handler.vt(.restore_mode, .{ .mode = mode }); + self.handler.vt(.restore_mode, .{ .mode = mode }); } else { log.warn( "unimplemented restore mode: {}", @@ -1698,9 +1702,9 @@ pub fn Stream(comptime Handler: type) type { // to our handler to do the proper logic. If mode 69 // is set, then we should invoke DECSLRM, otherwise // we should invoke SC. - 0 => try self.handler.vt(.left_and_right_margin_ambiguous, {}), - 1 => try self.handler.vt(.left_and_right_margin, .{ .top_left = input.params[0], .bottom_right = 0 }), - 2 => try self.handler.vt(.left_and_right_margin, .{ .top_left = input.params[0], .bottom_right = input.params[1] }), + 0 => self.handler.vt(.left_and_right_margin_ambiguous, {}), + 1 => self.handler.vt(.left_and_right_margin, .{ .top_left = input.params[0], .bottom_right = 0 }), + 2 => self.handler.vt(.left_and_right_margin, .{ .top_left = input.params[0], .bottom_right = input.params[1] }), else => log.warn("invalid DECSLRM command: {f}", .{input}), }, @@ -1708,7 +1712,7 @@ pub fn Stream(comptime Handler: type) type { '?' => { for (input.params) |mode_int| { if (modes.modeFromInt(mode_int, false)) |mode| { - try self.handler.vt(.save_mode, .{ .mode = mode }); + self.handler.vt(.save_mode, .{ .mode = mode }); } else { log.warn( "unimplemented save mode: {}", @@ -1736,7 +1740,7 @@ pub fn Stream(comptime Handler: type) type { }, }; - try self.handler.vt(.mouse_shift_capture, capture); + self.handler.vt(.mouse_shift_capture, capture); }, else => log.warn( @@ -1758,28 +1762,28 @@ pub fn Stream(comptime Handler: type) type { switch (input.params[0]) { 14 => if (input.params.len == 1) { // report the text area size in pixels - try self.handler.vt(.size_report, .csi_14_t); + self.handler.vt(.size_report, .csi_14_t); } else log.warn( "ignoring CSI 14 t with extra parameters: {f}", .{input}, ), 16 => if (input.params.len == 1) { // report cell size in pixels - try self.handler.vt(.size_report, .csi_16_t); + self.handler.vt(.size_report, .csi_16_t); } else log.warn( "ignoring CSI 16 t with extra parameters: {f}", .{input}, ), 18 => if (input.params.len == 1) { // report screen size in characters - try self.handler.vt(.size_report, .csi_18_t); + self.handler.vt(.size_report, .csi_18_t); } else log.warn( "ignoring CSI 18 t with extra parameters: {f}", .{input}, ), 21 => if (input.params.len == 1) { // report window title - try self.handler.vt(.size_report, .csi_21_t); + self.handler.vt(.size_report, .csi_21_t); } else log.warn( "ignoring CSI 21 t with extra parameters: {f}", .{input}, @@ -1796,8 +1800,8 @@ pub fn Stream(comptime Handler: type) type { else 0; switch (number) { - 22 => try self.handler.vt(.title_push, index), - 23 => try self.handler.vt(.title_pop, index), + 22 => self.handler.vt(.title_push, index), + 23 => self.handler.vt(.title_pop, index), else => @compileError("unreachable"), } } else log.warn( @@ -1821,11 +1825,11 @@ pub fn Stream(comptime Handler: type) type { }, 'u' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.restore_cursor, {}), + 0 => self.handler.vt(.restore_cursor, {}), // Kitty keyboard protocol 1 => switch (input.intermediates[0]) { - '?' => try self.handler.vt(.kitty_keyboard_query, {}), + '?' => self.handler.vt(.kitty_keyboard_query, {}), '>' => push: { const flags: u5 = if (input.params.len == 1) @@ -1836,7 +1840,7 @@ pub fn Stream(comptime Handler: type) type { else 0; - try self.handler.vt(.kitty_keyboard_push, .{ .flags = @as(kitty.KeyFlags, @bitCast(flags)) }); + self.handler.vt(.kitty_keyboard_push, .{ .flags = @as(kitty.KeyFlags, @bitCast(flags)) }); }, '<' => { @@ -1845,7 +1849,7 @@ pub fn Stream(comptime Handler: type) type { else 1; - try self.handler.vt(.kitty_keyboard_pop, number); + self.handler.vt(.kitty_keyboard_pop, number); }, '=' => set: { @@ -1874,9 +1878,9 @@ pub fn Stream(comptime Handler: type) type { const kitty_flags: streampkg.Action.KittyKeyboardFlags = .{ .flags = @as(kitty.KeyFlags, @bitCast(flags)) }; switch (action_tag) { - .kitty_keyboard_set => try self.handler.vt(.kitty_keyboard_set, kitty_flags), - .kitty_keyboard_set_or => try self.handler.vt(.kitty_keyboard_set_or, kitty_flags), - .kitty_keyboard_set_not => try self.handler.vt(.kitty_keyboard_set_not, kitty_flags), + .kitty_keyboard_set => self.handler.vt(.kitty_keyboard_set, kitty_flags), + .kitty_keyboard_set_or => self.handler.vt(.kitty_keyboard_set_or, kitty_flags), + .kitty_keyboard_set_not => self.handler.vt(.kitty_keyboard_set_not, kitty_flags), else => unreachable, } }, @@ -1895,7 +1899,7 @@ pub fn Stream(comptime Handler: type) type { // ICH - Insert Blanks '@' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.insert_blanks, switch (input.params.len) { + 0 => self.handler.vt(.insert_blanks, switch (input.params.len) { 0 => 1, 1 => @max(1, input.params[0]), else => { @@ -1932,14 +1936,14 @@ pub fn Stream(comptime Handler: type) type { }, }; - try self.handler.vt(.active_status_display, display); + self.handler.vt(.active_status_display, display); }, else => log.warn("unimplemented CSI action: {f}", .{input}), } } - inline fn oscDispatch(self: *Self, cmd: osc.Command) !void { + inline fn oscDispatch(self: *Self, cmd: osc.Command) void { // The branch hints here are based on real world data // which indicates that the most common OSC commands are: // @@ -1965,7 +1969,7 @@ pub fn Stream(comptime Handler: type) type { switch (cmd) { .semantic_prompt => |sp| { @branchHint(.likely); - try self.handler.vt(.semantic_prompt, sp); + self.handler.vt(.semantic_prompt, sp); }, .change_window_title => |title| { @@ -1976,7 +1980,7 @@ pub fn Stream(comptime Handler: type) type { return; } - try self.handler.vt(.window_title, .{ .title = title }); + self.handler.vt(.window_title, .{ .title = title }); }, .change_window_icon => |icon| { @@ -1985,7 +1989,7 @@ pub fn Stream(comptime Handler: type) type { }, .clipboard_contents => |clip| { - try self.handler.vt(.clipboard_contents, .{ + self.handler.vt(.clipboard_contents, .{ .kind = clip.kind, .data = clip.data, }); @@ -1993,7 +1997,7 @@ pub fn Stream(comptime Handler: type) type { .report_pwd => |v| { @branchHint(.likely); - try self.handler.vt(.report_pwd, .{ .url = v.value }); + self.handler.vt(.report_pwd, .{ .url = v.value }); }, .mouse_shape => |v| { @@ -2003,12 +2007,12 @@ pub fn Stream(comptime Handler: type) type { return; }; - try self.handler.vt(.mouse_shape, shape); + self.handler.vt(.mouse_shape, shape); }, .color_operation => |v| { @branchHint(.likely); - try self.handler.vt(.color_operation, .{ + self.handler.vt(.color_operation, .{ .op = v.op, .requests = v.requests, .terminator = v.terminator, @@ -2016,11 +2020,11 @@ pub fn Stream(comptime Handler: type) type { }, .kitty_color_protocol => |v| { - try self.handler.vt(.kitty_color_report, v); + self.handler.vt(.kitty_color_report, v); }, .show_desktop_notification => |v| { - try self.handler.vt(.show_desktop_notification, .{ + self.handler.vt(.show_desktop_notification, .{ .title = v.title, .body = v.body, }); @@ -2028,7 +2032,7 @@ pub fn Stream(comptime Handler: type) type { .hyperlink_start => |v| { @branchHint(.likely); - try self.handler.vt(.start_hyperlink, .{ + self.handler.vt(.start_hyperlink, .{ .uri = v.uri, .id = v.id, }); @@ -2036,11 +2040,11 @@ pub fn Stream(comptime Handler: type) type { .hyperlink_end => { @branchHint(.likely); - try self.handler.vt(.end_hyperlink, {}); + self.handler.vt(.end_hyperlink, {}); }, .conemu_progress_report => |v| { - try self.handler.vt(.progress_report, v); + self.handler.vt(.progress_report, v); }, .conemu_sleep, @@ -2072,7 +2076,7 @@ pub fn Stream(comptime Handler: type) type { self: *Self, intermediates: []const u8, set: charsets.Charset, - ) !void { + ) void { if (intermediates.len != 1) { log.warn("invalid charset intermediate: {any}", .{intermediates}); return; @@ -2092,7 +2096,7 @@ pub fn Stream(comptime Handler: type) type { }, }; - try self.handler.vt(.configure_charset, .{ + self.handler.vt(.configure_charset, .{ .slot = slot, .charset = set, }); @@ -2101,7 +2105,7 @@ pub fn Stream(comptime Handler: type) type { inline fn escDispatch( self: *Self, action: Parser.Action.ESC, - ) !void { + ) void { // The branch hints here are based on real world data // which indicates that the most common ESC finals are: // @@ -2129,19 +2133,19 @@ pub fn Stream(comptime Handler: type) type { // Charsets 'B' => { @branchHint(.likely); - try self.configureCharset(action.intermediates, .ascii); + self.configureCharset(action.intermediates, .ascii); }, - 'A' => try self.configureCharset(action.intermediates, .british), + 'A' => self.configureCharset(action.intermediates, .british), '0' => { @branchHint(.likely); - try self.configureCharset(action.intermediates, .dec_special); + self.configureCharset(action.intermediates, .dec_special); }, // DECSC - Save Cursor '7' => { @branchHint(.likely); switch (action.intermediates.len) { - 0 => try self.handler.vt(.save_cursor, {}), + 0 => self.handler.vt(.save_cursor, {}), else => { @branchHint(.unlikely); log.warn("invalid command: {f}", .{action}); @@ -2155,14 +2159,14 @@ pub fn Stream(comptime Handler: type) type { switch (action.intermediates.len) { // DECRC - Restore Cursor 0 => { - try self.handler.vt(.restore_cursor, {}); + self.handler.vt(.restore_cursor, {}); break :blk {}; }, 1 => switch (action.intermediates[0]) { // DECALN - Fill Screen with E '#' => { - try self.handler.vt(.decaln, {}); + self.handler.vt(.decaln, {}); break :blk {}; }, @@ -2177,7 +2181,7 @@ pub fn Stream(comptime Handler: type) type { // IND - Index 'D' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.index, {}), + 0 => self.handler.vt(.index, {}), else => { @branchHint(.unlikely); log.warn("invalid index command: {f}", .{action}); @@ -2187,7 +2191,7 @@ pub fn Stream(comptime Handler: type) type { // NEL - Next Line 'E' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.next_line, {}), + 0 => self.handler.vt(.next_line, {}), else => { @branchHint(.unlikely); log.warn("invalid next line command: {f}", .{action}); @@ -2197,7 +2201,7 @@ pub fn Stream(comptime Handler: type) type { // HTS - Horizontal Tab Set 'H' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.tab_set, {}), + 0 => self.handler.vt(.tab_set, {}), else => { @branchHint(.unlikely); log.warn("invalid tab set command: {f}", .{action}); @@ -2209,7 +2213,7 @@ pub fn Stream(comptime Handler: type) type { 'M' => { @branchHint(.likely); switch (action.intermediates.len) { - 0 => try self.handler.vt(.reverse_index, {}), + 0 => self.handler.vt(.reverse_index, {}), else => { @branchHint(.unlikely); log.warn("invalid reverse index command: {f}", .{action}); @@ -2220,7 +2224,7 @@ pub fn Stream(comptime Handler: type) type { // SS2 - Single Shift 2 'N' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.invoke_charset, .{ + 0 => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G2, .locking = true, @@ -2234,7 +2238,7 @@ pub fn Stream(comptime Handler: type) type { // SS3 - Single Shift 3 'O' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.invoke_charset, .{ + 0 => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G3, .locking = true, @@ -2248,24 +2252,24 @@ pub fn Stream(comptime Handler: type) type { // SPA - Start of Guarded Area 'V' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.protected_mode_iso, {}), + 0 => self.handler.vt(.protected_mode_iso, {}), else => log.warn("unimplemented ESC callback: {f}", .{action}), }, // EPA - End of Guarded Area 'W' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.protected_mode_off, {}), + 0 => self.handler.vt(.protected_mode_off, {}), else => log.warn("unimplemented ESC callback: {f}", .{action}), }, // DECID 'Z' => if (action.intermediates.len == 0) { - try self.handler.vt(.device_attributes, .primary); + self.handler.vt(.device_attributes, .primary); } else log.warn("unimplemented ESC callback: {f}", .{action}), // RIS - Full Reset 'c' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.full_reset, {}), + 0 => self.handler.vt(.full_reset, {}), else => { log.warn("invalid full reset command: {f}", .{action}); return; @@ -2274,7 +2278,7 @@ pub fn Stream(comptime Handler: type) type { // LS2 - Locking Shift 2 'n' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.invoke_charset, .{ + 0 => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G2, .locking = false, @@ -2288,7 +2292,7 @@ pub fn Stream(comptime Handler: type) type { // LS3 - Locking Shift 3 'o' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.invoke_charset, .{ + 0 => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G3, .locking = false, @@ -2302,7 +2306,7 @@ pub fn Stream(comptime Handler: type) type { // LS1R - Locking Shift 1 Right '~' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.invoke_charset, .{ + 0 => self.handler.vt(.invoke_charset, .{ .bank = .GR, .charset = .G1, .locking = false, @@ -2316,7 +2320,7 @@ pub fn Stream(comptime Handler: type) type { // LS2R - Locking Shift 2 Right '}' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.invoke_charset, .{ + 0 => self.handler.vt(.invoke_charset, .{ .bank = .GR, .charset = .G2, .locking = false, @@ -2330,7 +2334,7 @@ pub fn Stream(comptime Handler: type) type { // LS3R - Locking Shift 3 Right '|' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.invoke_charset, .{ + 0 => self.handler.vt(.invoke_charset, .{ .bank = .GR, .charset = .G3, .locking = false, @@ -2346,7 +2350,7 @@ pub fn Stream(comptime Handler: type) type { '=' => { @branchHint(.likely); switch (action.intermediates.len) { - 0 => try self.handler.vt(.set_mode, .{ .mode = .keypad_keys }), + 0 => self.handler.vt(.set_mode, .{ .mode = .keypad_keys }), else => log.warn("unimplemented setMode: {f}", .{action}), } }, @@ -2355,7 +2359,7 @@ pub fn Stream(comptime Handler: type) type { '>' => { @branchHint(.likely); switch (action.intermediates.len) { - 0 => try self.handler.vt(.reset_mode, .{ .mode = .keypad_keys }), + 0 => self.handler.vt(.reset_mode, .{ .mode = .keypad_keys }), else => log.warn("unimplemented setMode: {f}", .{action}), } }, @@ -2386,7 +2390,7 @@ test "stream: print" { self: *@This(), comptime action: Action.Tag, value: Action.Value(action), - ) !void { + ) void { switch (action) { .print => self.c = value.cp, else => {}, @@ -2395,7 +2399,7 @@ test "stream: print" { }; var s: Stream(H) = .init(.{}); - try s.next('x'); + s.next('x'); try testing.expectEqual(@as(u21, 'x'), s.handler.c.?); } @@ -2407,7 +2411,7 @@ test "simd: print invalid utf-8" { self: *@This(), comptime action: Action.Tag, value: Action.Value(action), - ) !void { + ) void { switch (action) { .print => self.c = value.cp, else => {}, @@ -2416,7 +2420,7 @@ test "simd: print invalid utf-8" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice(&.{0xFF}); + s.nextSlice(&.{0xFF}); try testing.expectEqual(@as(u21, 0xFFFD), s.handler.c.?); } @@ -2428,7 +2432,7 @@ test "simd: complete incomplete utf-8" { self: *@This(), comptime action: Action.Tag, value: Action.Value(action), - ) !void { + ) void { switch (action) { .print => self.c = value.cp, else => {}, @@ -2437,11 +2441,11 @@ test "simd: complete incomplete utf-8" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice(&.{0xE0}); // 3 byte + s.nextSlice(&.{0xE0}); // 3 byte try testing.expect(s.handler.c == null); - try s.nextSlice(&.{0xA0}); // still incomplete + s.nextSlice(&.{0xA0}); // still incomplete try testing.expect(s.handler.c == null); - try s.nextSlice(&.{0x80}); + s.nextSlice(&.{0x80}); try testing.expectEqual(@as(u21, 0x800), s.handler.c.?); } @@ -2453,7 +2457,7 @@ test "stream: cursor right (CUF)" { self: *@This(), comptime action: Action.Tag, value: Action.Value(action), - ) !void { + ) void { switch (action) { .cursor_right => self.amount = value.value, else => {}, @@ -2462,18 +2466,18 @@ test "stream: cursor right (CUF)" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1B[C"); + s.nextSlice("\x1B[C"); try testing.expectEqual(@as(u16, 1), s.handler.amount); - try s.nextSlice("\x1B[5C"); + s.nextSlice("\x1B[5C"); try testing.expectEqual(@as(u16, 5), s.handler.amount); s.handler.amount = 0; - try s.nextSlice("\x1B[5;4C"); + s.nextSlice("\x1B[5;4C"); try testing.expectEqual(@as(u16, 0), s.handler.amount); s.handler.amount = 0; - try s.nextSlice("\x1b[?3C"); + s.nextSlice("\x1b[?3C"); try testing.expectEqual(@as(u16, 0), s.handler.amount); } @@ -2485,7 +2489,7 @@ test "stream: dec set mode (SM) and reset mode (RM)" { self: *@This(), comptime action: Action.Tag, value: Action.Value(action), - ) !void { + ) void { switch (action) { .set_mode => self.mode = value.mode, .reset_mode => self.mode = @as(modes.Mode, @enumFromInt(1)), @@ -2495,14 +2499,14 @@ test "stream: dec set mode (SM) and reset mode (RM)" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1B[?6h"); + s.nextSlice("\x1B[?6h"); try testing.expectEqual(@as(modes.Mode, .origin), s.handler.mode); - try s.nextSlice("\x1B[?6l"); + s.nextSlice("\x1B[?6l"); try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode); s.handler.mode = @as(modes.Mode, @enumFromInt(1)); - try s.nextSlice("\x1B[6 h"); + s.nextSlice("\x1B[6 h"); try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode); } @@ -2514,7 +2518,7 @@ test "stream: ansi set mode (SM) and reset mode (RM)" { self: *@This(), comptime action: Action.Tag, value: Action.Value(action), - ) !void { + ) void { switch (action) { .set_mode => self.mode = value.mode, .reset_mode => self.mode = null, @@ -2524,14 +2528,14 @@ test "stream: ansi set mode (SM) and reset mode (RM)" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1B[4h"); + s.nextSlice("\x1B[4h"); try testing.expectEqual(@as(modes.Mode, .insert), s.handler.mode.?); - try s.nextSlice("\x1B[4l"); + s.nextSlice("\x1B[4l"); try testing.expect(s.handler.mode == null); s.handler.mode = null; - try s.nextSlice("\x1B[>5h"); + s.nextSlice("\x1B[>5h"); try testing.expect(s.handler.mode == null); } @@ -2548,17 +2552,17 @@ test "stream: ansi set mode (SM) and reset mode (RM) with unknown value" { self: *@This(), comptime action: Action.Tag, value: Action.Value(action), - ) !void { + ) void { _ = self; _ = value; } }; var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1B[6h"); + s.nextSlice("\x1B[6h"); try testing.expect(s.handler.mode == null); - try s.nextSlice("\x1B[6l"); + s.nextSlice("\x1B[6l"); try testing.expect(s.handler.mode == null); } @@ -2571,7 +2575,7 @@ test "stream: restore mode" { self: *Self, comptime action: Stream(Self).Action.Tag, value: Stream(Self).Action.Value(action), - ) !void { + ) void { _ = value; switch (action) { .top_and_bottom_margin => self.called = true, @@ -2581,7 +2585,7 @@ test "stream: restore mode" { }; var s: Stream(H) = .init(.{}); - for ("\x1B[?42r") |c| try s.next(c); + for ("\x1B[?42r") |c| s.next(c); try testing.expect(!s.handler.called); } @@ -2594,7 +2598,7 @@ test "stream: pop kitty keyboard with no params defaults to 1" { self: *Self, comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .kitty_keyboard_pop => self.n = value, else => {}, @@ -2603,7 +2607,7 @@ test "stream: pop kitty keyboard with no params defaults to 1" { }; var s: Stream(H) = .init(.{}); - for ("\x1B[<u") |c| try s.next(c); + for ("\x1B[<u") |c| s.next(c); try testing.expectEqual(@as(u16, 1), s.handler.n); } @@ -2616,7 +2620,7 @@ test "stream: DECSCA" { self: *Self, comptime action: Stream(Self).Action.Tag, value: Stream(Self).Action.Value(action), - ) !void { + ) void { _ = value; switch (action) { .protected_mode_off => self.v = .off, @@ -2629,19 +2633,19 @@ test "stream: DECSCA" { var s: Stream(H) = .init(.{}); { - for ("\x1B[\"q") |c| try s.next(c); + for ("\x1B[\"q") |c| s.next(c); try testing.expectEqual(ansi.ProtectedMode.off, s.handler.v.?); } { - for ("\x1B[0\"q") |c| try s.next(c); + for ("\x1B[0\"q") |c| s.next(c); try testing.expectEqual(ansi.ProtectedMode.off, s.handler.v.?); } { - for ("\x1B[2\"q") |c| try s.next(c); + for ("\x1B[2\"q") |c| s.next(c); try testing.expectEqual(ansi.ProtectedMode.off, s.handler.v.?); } { - for ("\x1B[1\"q") |c| try s.next(c); + for ("\x1B[1\"q") |c| s.next(c); try testing.expectEqual(ansi.ProtectedMode.dec, s.handler.v.?); } } @@ -2656,7 +2660,7 @@ test "stream: DECED, DECSED" { self: *Self, comptime action: anytype, value: anytype, - ) !void { + ) void { switch (action) { .erase_display_below => { self.mode = .below; @@ -2685,59 +2689,59 @@ test "stream: DECED, DECSED" { var s: Stream(H) = .init(.{}); { - for ("\x1B[?J") |c| try s.next(c); + for ("\x1B[?J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.below, s.handler.mode.?); try testing.expect(s.handler.protected.?); } { - for ("\x1B[?0J") |c| try s.next(c); + for ("\x1B[?0J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.below, s.handler.mode.?); try testing.expect(s.handler.protected.?); } { - for ("\x1B[?1J") |c| try s.next(c); + for ("\x1B[?1J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.above, s.handler.mode.?); try testing.expect(s.handler.protected.?); } { - for ("\x1B[?2J") |c| try s.next(c); + for ("\x1B[?2J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.complete, s.handler.mode.?); try testing.expect(s.handler.protected.?); } { - for ("\x1B[?3J") |c| try s.next(c); + for ("\x1B[?3J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?); try testing.expect(s.handler.protected.?); } { - for ("\x1B[J") |c| try s.next(c); + for ("\x1B[J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.below, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } { - for ("\x1B[0J") |c| try s.next(c); + for ("\x1B[0J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.below, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } { - for ("\x1B[1J") |c| try s.next(c); + for ("\x1B[1J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.above, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } { - for ("\x1B[2J") |c| try s.next(c); + for ("\x1B[2J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.complete, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } { - for ("\x1B[3J") |c| try s.next(c); + for ("\x1B[3J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } { // Invalid and ignored by the handler - for ("\x1B[>0J") |c| try s.next(c); + for ("\x1B[>0J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } @@ -2753,7 +2757,7 @@ test "stream: DECEL, DECSEL" { self: *Self, comptime action: anytype, value: anytype, - ) !void { + ) void { switch (action) { .erase_line_right => { self.mode = .right; @@ -2778,49 +2782,49 @@ test "stream: DECEL, DECSEL" { var s: Stream(H) = .init(.{}); { - for ("\x1B[?K") |c| try s.next(c); + for ("\x1B[?K") |c| s.next(c); try testing.expectEqual(csi.EraseLine.right, s.handler.mode.?); try testing.expect(s.handler.protected.?); } { - for ("\x1B[?0K") |c| try s.next(c); + for ("\x1B[?0K") |c| s.next(c); try testing.expectEqual(csi.EraseLine.right, s.handler.mode.?); try testing.expect(s.handler.protected.?); } { - for ("\x1B[?1K") |c| try s.next(c); + for ("\x1B[?1K") |c| s.next(c); try testing.expectEqual(csi.EraseLine.left, s.handler.mode.?); try testing.expect(s.handler.protected.?); } { - for ("\x1B[?2K") |c| try s.next(c); + for ("\x1B[?2K") |c| s.next(c); try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?); try testing.expect(s.handler.protected.?); } { - for ("\x1B[K") |c| try s.next(c); + for ("\x1B[K") |c| s.next(c); try testing.expectEqual(csi.EraseLine.right, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } { - for ("\x1B[0K") |c| try s.next(c); + for ("\x1B[0K") |c| s.next(c); try testing.expectEqual(csi.EraseLine.right, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } { - for ("\x1B[1K") |c| try s.next(c); + for ("\x1B[1K") |c| s.next(c); try testing.expectEqual(csi.EraseLine.left, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } { - for ("\x1B[2K") |c| try s.next(c); + for ("\x1B[2K") |c| s.next(c); try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } { // Invalid and ignored by the handler - for ("\x1B[<1K") |c| try s.next(c); + for ("\x1B[<1K") |c| s.next(c); try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } @@ -2834,7 +2838,7 @@ test "stream: DECSCUSR" { self: *@This(), comptime action: Stream(@This()).Action.Tag, value: Stream(@This()).Action.Value(action), - ) !void { + ) void { switch (action) { .cursor_style => self.style = value, else => {}, @@ -2843,14 +2847,14 @@ test "stream: DECSCUSR" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1B[ q"); + s.nextSlice("\x1B[ q"); try testing.expect(s.handler.style.? == .default); - try s.nextSlice("\x1B[1 q"); + s.nextSlice("\x1B[1 q"); try testing.expect(s.handler.style.? == .blinking_block); // Invalid and ignored by the handler - try s.nextSlice("\x1B[?0 q"); + s.nextSlice("\x1B[?0 q"); try testing.expect(s.handler.style.? == .blinking_block); } @@ -2862,7 +2866,7 @@ test "stream: DECSCUSR without space" { self: *@This(), comptime action: Stream(@This()).Action.Tag, value: Stream(@This()).Action.Value(action), - ) !void { + ) void { switch (action) { .cursor_style => self.style = value, else => {}, @@ -2871,10 +2875,10 @@ test "stream: DECSCUSR without space" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1B[q"); + s.nextSlice("\x1B[q"); try testing.expect(s.handler.style == null); - try s.nextSlice("\x1B[1q"); + s.nextSlice("\x1B[1q"); try testing.expect(s.handler.style == null); } @@ -2886,7 +2890,7 @@ test "stream: XTSHIFTESCAPE" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .mouse_shift_capture => self.escape = value, else => {}, @@ -2895,20 +2899,20 @@ test "stream: XTSHIFTESCAPE" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1B[>2s"); + s.nextSlice("\x1B[>2s"); try testing.expect(s.handler.escape == null); - try s.nextSlice("\x1B[>s"); + s.nextSlice("\x1B[>s"); try testing.expect(s.handler.escape.? == false); - try s.nextSlice("\x1B[>0s"); + s.nextSlice("\x1B[>0s"); try testing.expect(s.handler.escape.? == false); - try s.nextSlice("\x1B[>1s"); + s.nextSlice("\x1B[>1s"); try testing.expect(s.handler.escape.? == true); // Invalid and ignored by the handler - try s.nextSlice("\x1B[1 s"); + s.nextSlice("\x1B[1 s"); try testing.expect(s.handler.escape.? == true); } @@ -2920,7 +2924,7 @@ test "stream: change window title with invalid utf-8" { self: *@This(), comptime action: anytype, value: anytype, - ) !void { + ) void { _ = value; switch (action) { .window_title => self.seen = true, @@ -2931,13 +2935,13 @@ test "stream: change window title with invalid utf-8" { { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b]2;abc\x1b\\"); + s.nextSlice("\x1b]2;abc\x1b\\"); try testing.expect(s.handler.seen); } { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b]2;abc\xc0\x1b\\"); + s.nextSlice("\x1b]2;abc\xc0\x1b\\"); try testing.expect(!s.handler.seen); } } @@ -2951,7 +2955,7 @@ test "stream: insert characters" { self: *Self, comptime action: anytype, value: anytype, - ) !void { + ) void { _ = value; switch (action) { .insert_blanks => self.called = true, @@ -2961,11 +2965,11 @@ test "stream: insert characters" { }; var s: Stream(H) = .init(.{}); - for ("\x1B[42@") |c| try s.next(c); + for ("\x1B[42@") |c| s.next(c); try testing.expect(s.handler.called); s.handler.called = false; - for ("\x1B[?42@") |c| try s.next(c); + for ("\x1B[?42@") |c| s.next(c); try testing.expect(!s.handler.called); } @@ -2978,7 +2982,7 @@ test "stream: insert characters explicit zero clamps to 1" { self: *Self, comptime action: anytype, value: anytype, - ) !void { + ) void { switch (action) { .insert_blanks => self.value = value, else => {}, @@ -2987,7 +2991,7 @@ test "stream: insert characters explicit zero clamps to 1" { }; var s: Stream(H) = .init(.{}); - for ("\x1B[0@") |c| try s.next(c); + for ("\x1B[0@") |c| s.next(c); try testing.expectEqual(@as(usize, 1), s.handler.value.?); } @@ -3000,7 +3004,7 @@ test "stream: SCOSC" { self: *Self, comptime action: Stream(Self).Action.Tag, value: Stream(Self).Action.Value(action), - ) !void { + ) void { _ = value; switch (action) { .left_and_right_margin => @panic("bad"), @@ -3011,7 +3015,7 @@ test "stream: SCOSC" { }; var s: Stream(H) = .init(.{}); - for ("\x1B[s") |c| try s.next(c); + for ("\x1B[s") |c| s.next(c); try testing.expect(s.handler.called); } @@ -3024,7 +3028,7 @@ test "stream: SCORC" { self: *Self, comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { _ = value; switch (action) { .restore_cursor => self.called = true, @@ -3034,7 +3038,7 @@ test "stream: SCORC" { }; var s: Stream(H) = .init(.{}); - for ("\x1B[u") |c| try s.next(c); + for ("\x1B[u") |c| s.next(c); try testing.expect(s.handler.called); } @@ -3044,7 +3048,7 @@ test "stream: too many csi params" { self: *@This(), comptime action: anytype, value: anytype, - ) !void { + ) void { _ = self; _ = value; switch (action) { @@ -3055,7 +3059,7 @@ test "stream: too many csi params" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1B[1;1;1;1;1;1;1;1;1;1;1;1;1;1;1;1;1C"); + s.nextSlice("\x1B[1;1;1;1;1;1;1;1;1;1;1;1;1;1;1;1;1C"); } test "stream: csi param too long" { @@ -3064,7 +3068,7 @@ test "stream: csi param too long" { self: *@This(), comptime action: anytype, value: anytype, - ) !void { + ) void { _ = self; _ = action; _ = value; @@ -3072,7 +3076,7 @@ test "stream: csi param too long" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1B[1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111C"); + s.nextSlice("\x1B[1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111C"); } test "stream: send report with CSI t" { @@ -3083,7 +3087,7 @@ test "stream: send report with CSI t" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .size_report => self.style = value, else => {}, @@ -3093,16 +3097,16 @@ test "stream: send report with CSI t" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[14t"); + s.nextSlice("\x1b[14t"); try testing.expectEqual(csi.SizeReportStyle.csi_14_t, s.handler.style); - try s.nextSlice("\x1b[16t"); + s.nextSlice("\x1b[16t"); try testing.expectEqual(csi.SizeReportStyle.csi_16_t, s.handler.style); - try s.nextSlice("\x1b[18t"); + s.nextSlice("\x1b[18t"); try testing.expectEqual(csi.SizeReportStyle.csi_18_t, s.handler.style); - try s.nextSlice("\x1b[21t"); + s.nextSlice("\x1b[21t"); try testing.expectEqual(csi.SizeReportStyle.csi_21_t, s.handler.style); } @@ -3118,7 +3122,7 @@ test "stream: invalid CSI t" { self: *@This(), comptime action: anytype, value: anytype, - ) !void { + ) void { _ = self; _ = action; _ = value; @@ -3127,7 +3131,7 @@ test "stream: invalid CSI t" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[19t"); + s.nextSlice("\x1b[19t"); try testing.expectEqual(null, s.handler.style); } @@ -3139,7 +3143,7 @@ test "stream: CSI t push title" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .title_push => self.index = value, else => {}, @@ -3149,7 +3153,7 @@ test "stream: CSI t push title" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[22;0t"); + s.nextSlice("\x1b[22;0t"); try testing.expectEqual(@as(u16, 0), s.handler.index.?); } @@ -3161,7 +3165,7 @@ test "stream: CSI t push title with explicit window" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .title_push => self.index = value, else => {}, @@ -3171,7 +3175,7 @@ test "stream: CSI t push title with explicit window" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[22;2t"); + s.nextSlice("\x1b[22;2t"); try testing.expectEqual(@as(u16, 0), s.handler.index.?); } @@ -3183,7 +3187,7 @@ test "stream: CSI t push title with explicit icon" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .title_push => self.index = value, else => {}, @@ -3193,7 +3197,7 @@ test "stream: CSI t push title with explicit icon" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[22;1t"); + s.nextSlice("\x1b[22;1t"); try testing.expectEqual(null, s.handler.index); } @@ -3205,7 +3209,7 @@ test "stream: CSI t push title with index" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .title_push => self.index = value, else => {}, @@ -3215,7 +3219,7 @@ test "stream: CSI t push title with index" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[22;0;5t"); + s.nextSlice("\x1b[22;0;5t"); try testing.expectEqual(@as(u16, 5), s.handler.index.?); } @@ -3227,7 +3231,7 @@ test "stream: CSI t pop title" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .title_pop => self.index = value, else => {}, @@ -3237,7 +3241,7 @@ test "stream: CSI t pop title" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[23;0t"); + s.nextSlice("\x1b[23;0t"); try testing.expectEqual(@as(u16, 0), s.handler.index.?); } @@ -3249,7 +3253,7 @@ test "stream: CSI t pop title with explicit window" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .title_pop => self.index = value, else => {}, @@ -3259,7 +3263,7 @@ test "stream: CSI t pop title with explicit window" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[23;2t"); + s.nextSlice("\x1b[23;2t"); try testing.expectEqual(@as(u16, 0), s.handler.index.?); } @@ -3271,7 +3275,7 @@ test "stream: CSI t pop title with explicit icon" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .title_pop => self.index = value, else => {}, @@ -3281,7 +3285,7 @@ test "stream: CSI t pop title with explicit icon" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[23;1t"); + s.nextSlice("\x1b[23;1t"); try testing.expectEqual(null, s.handler.index); } @@ -3293,7 +3297,7 @@ test "stream: CSI t pop title with index" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .title_pop => self.index = value, else => {}, @@ -3303,7 +3307,7 @@ test "stream: CSI t pop title with index" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[23;0;5t"); + s.nextSlice("\x1b[23;0;5t"); try testing.expectEqual(@as(u16, 5), s.handler.index.?); } @@ -3315,7 +3319,7 @@ test "stream CSI W clear tab stops" { self: *@This(), comptime action: anytype, value: anytype, - ) !void { + ) void { _ = value; self.action = action; } @@ -3323,10 +3327,10 @@ test "stream CSI W clear tab stops" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[2W"); + s.nextSlice("\x1b[2W"); try testing.expectEqual(Action.Key.tab_clear_current, s.handler.action.?); - try s.nextSlice("\x1b[5W"); + s.nextSlice("\x1b[5W"); try testing.expectEqual(Action.Key.tab_clear_all, s.handler.action.?); } @@ -3338,7 +3342,7 @@ test "stream CSI W tab set" { self: *@This(), comptime action: anytype, value: anytype, - ) !void { + ) void { _ = value; self.action = action; } @@ -3346,19 +3350,19 @@ test "stream CSI W tab set" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[W"); + s.nextSlice("\x1b[W"); try testing.expectEqual(Action.Key.tab_set, s.handler.action.?); s.handler.action = null; - try s.nextSlice("\x1b[0W"); + s.nextSlice("\x1b[0W"); try testing.expectEqual(Action.Key.tab_set, s.handler.action.?); s.handler.action = null; - try s.nextSlice("\x1b[>W"); + s.nextSlice("\x1b[>W"); try testing.expect(s.handler.action == null); s.handler.action = null; - try s.nextSlice("\x1b[99W"); + s.nextSlice("\x1b[99W"); try testing.expect(s.handler.action == null); } @@ -3370,7 +3374,7 @@ test "stream CSI ? W reset tab stops" { self: *@This(), comptime action: anytype, value: anytype, - ) !void { + ) void { _ = value; self.action = action; } @@ -3378,15 +3382,15 @@ test "stream CSI ? W reset tab stops" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[?2W"); + s.nextSlice("\x1b[?2W"); try testing.expect(s.handler.action == null); - try s.nextSlice("\x1b[?5W"); + s.nextSlice("\x1b[?5W"); try testing.expectEqual(Action.Key.tab_reset, s.handler.action.?); // Invalid and ignored by the handler s.handler.action = null; - try s.nextSlice("\x1b[?1;2;3W"); + s.nextSlice("\x1b[?1;2;3W"); try testing.expect(s.handler.action == null); } @@ -3399,7 +3403,7 @@ test "stream: SGR with 17+ parameters for underline color" { self: *@This(), comptime action: anytype, value: anytype, - ) !void { + ) void { switch (action) { .set_attribute => { self.attrs = value; @@ -3414,7 +3418,7 @@ test "stream: SGR with 17+ parameters for underline color" { // Kakoune-style SGR with underline color as 17th parameter // This tests the fix where param 17 was being dropped - try s.nextSlice("\x1b[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136;0m"); + s.nextSlice("\x1b[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136;0m"); try testing.expect(s.handler.called); } @@ -3429,7 +3433,7 @@ test "stream: tab clear with overflowing param" { self: *@This(), comptime action: Action.Tag, value: Action.Value(action), - ) !void { + ) void { _ = value; switch (action) { .tab_clear_current, .tab_clear_all => self.called = true, @@ -3441,5 +3445,5 @@ test "stream: tab clear with overflowing param" { var s: Stream(H) = .init(.{}); // 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'. - try s.nextSlice("\x1b[388888888888888888888888888888888888g\x1b[0m"); + s.nextSlice("\x1b[388888888888888888888888888888888888g\x1b[0m"); } diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig deleted file mode 100644 index 5b97bebfa..000000000 --- a/src/terminal/stream_readonly.zig +++ /dev/null @@ -1,1006 +0,0 @@ -const std = @import("std"); -const testing = std.testing; -const stream = @import("stream.zig"); -const Action = stream.Action; -const Screen = @import("Screen.zig"); -const modes = @import("modes.zig"); -const osc_color = @import("osc/parsers/color.zig"); -const kitty_color = @import("kitty/color.zig"); -const Terminal = @import("Terminal.zig"); - -/// This is a Stream implementation that processes actions against -/// a Terminal and updates the Terminal state. It is called "readonly" because -/// it only processes actions that modify terminal state, while ignoring -/// any actions that require a response (like queries). -/// -/// If you're implementing a terminal emulator that only needs to render -/// output and doesn't need to respond (since it maybe isn't running the -/// actual program), this is the stream type to use. For example, this is -/// ideal for replay tooling, CI logs, PaaS builder output, etc. -pub const Stream = stream.Stream(Handler); - -/// See Stream, which is just the stream wrapper around this. -/// -/// This isn't attached directly to Terminal because there is additional -/// state and options we plan to add in the future, such as APC/DCS which -/// don't make sense to me to add to the Terminal directly. Instead, you -/// can call `vtHandler` on Terminal to initialize this handler. -pub const Handler = struct { - /// The terminal state to modify. - terminal: *Terminal, - - pub fn init(terminal: *Terminal) Handler { - return .{ - .terminal = terminal, - }; - } - - pub fn deinit(self: *Handler) void { - // Currently does nothing but may in the future so callers should - // call this. - _ = self; - } - - pub fn vt( - self: *Handler, - comptime action: Action.Tag, - value: Action.Value(action), - ) !void { - switch (action) { - .print => try self.terminal.print(value.cp), - .print_repeat => try self.terminal.printRepeat(value), - .backspace => self.terminal.backspace(), - .carriage_return => self.terminal.carriageReturn(), - .linefeed => try self.terminal.linefeed(), - .index => try self.terminal.index(), - .next_line => { - try self.terminal.index(); - self.terminal.carriageReturn(); - }, - .reverse_index => self.terminal.reverseIndex(), - .cursor_up => self.terminal.cursorUp(value.value), - .cursor_down => self.terminal.cursorDown(value.value), - .cursor_left => self.terminal.cursorLeft(value.value), - .cursor_right => self.terminal.cursorRight(value.value), - .cursor_pos => self.terminal.setCursorPos(value.row, value.col), - .cursor_col => self.terminal.setCursorPos(self.terminal.screens.active.cursor.y + 1, value.value), - .cursor_row => self.terminal.setCursorPos(value.value, self.terminal.screens.active.cursor.x + 1), - .cursor_col_relative => self.terminal.setCursorPos( - self.terminal.screens.active.cursor.y + 1, - self.terminal.screens.active.cursor.x + 1 +| value.value, - ), - .cursor_row_relative => self.terminal.setCursorPos( - self.terminal.screens.active.cursor.y + 1 +| value.value, - self.terminal.screens.active.cursor.x + 1, - ), - .cursor_style => { - const blink = switch (value) { - .default, .steady_block, .steady_bar, .steady_underline => false, - .blinking_block, .blinking_bar, .blinking_underline => true, - }; - const style: Screen.CursorStyle = switch (value) { - .default, .blinking_block, .steady_block => .block, - .blinking_bar, .steady_bar => .bar, - .blinking_underline, .steady_underline => .underline, - }; - self.terminal.modes.set(.cursor_blinking, blink); - self.terminal.screens.active.cursor.cursor_style = style; - }, - .erase_display_below => self.terminal.eraseDisplay(.below, value), - .erase_display_above => self.terminal.eraseDisplay(.above, value), - .erase_display_complete => self.terminal.eraseDisplay(.complete, value), - .erase_display_scrollback => self.terminal.eraseDisplay(.scrollback, value), - .erase_display_scroll_complete => self.terminal.eraseDisplay(.scroll_complete, value), - .erase_line_right => self.terminal.eraseLine(.right, value), - .erase_line_left => self.terminal.eraseLine(.left, value), - .erase_line_complete => self.terminal.eraseLine(.complete, value), - .erase_line_right_unless_pending_wrap => self.terminal.eraseLine(.right_unless_pending_wrap, value), - .delete_chars => self.terminal.deleteChars(value), - .erase_chars => self.terminal.eraseChars(value), - .insert_lines => self.terminal.insertLines(value), - .insert_blanks => self.terminal.insertBlanks(value), - .delete_lines => self.terminal.deleteLines(value), - .scroll_up => try self.terminal.scrollUp(value), - .scroll_down => self.terminal.scrollDown(value), - .horizontal_tab => self.horizontalTab(value), - .horizontal_tab_back => self.horizontalTabBack(value), - .tab_clear_current => self.terminal.tabClear(.current), - .tab_clear_all => self.terminal.tabClear(.all), - .tab_set => self.terminal.tabSet(), - .tab_reset => self.terminal.tabReset(), - .set_mode => try self.setMode(value.mode, true), - .reset_mode => try self.setMode(value.mode, false), - .save_mode => self.terminal.modes.save(value.mode), - .restore_mode => { - const v = self.terminal.modes.restore(value.mode); - try self.setMode(value.mode, v); - }, - .top_and_bottom_margin => self.terminal.setTopAndBottomMargin(value.top_left, value.bottom_right), - .left_and_right_margin => self.terminal.setLeftAndRightMargin(value.top_left, value.bottom_right), - .left_and_right_margin_ambiguous => { - if (self.terminal.modes.get(.enable_left_and_right_margin)) { - self.terminal.setLeftAndRightMargin(0, 0); - } else { - self.terminal.saveCursor(); - } - }, - .save_cursor => self.terminal.saveCursor(), - .restore_cursor => self.terminal.restoreCursor(), - .invoke_charset => self.terminal.invokeCharset(value.bank, value.charset, value.locking), - .configure_charset => self.terminal.configureCharset(value.slot, value.charset), - .set_attribute => switch (value) { - .unknown => {}, - else => self.terminal.setAttribute(value) catch {}, - }, - .protected_mode_off => self.terminal.setProtectedMode(.off), - .protected_mode_iso => self.terminal.setProtectedMode(.iso), - .protected_mode_dec => self.terminal.setProtectedMode(.dec), - .mouse_shift_capture => self.terminal.flags.mouse_shift_capture = if (value) .true else .false, - .kitty_keyboard_push => self.terminal.screens.active.kitty_keyboard.push(value.flags), - .kitty_keyboard_pop => self.terminal.screens.active.kitty_keyboard.pop(@intCast(value)), - .kitty_keyboard_set => self.terminal.screens.active.kitty_keyboard.set(.set, value.flags), - .kitty_keyboard_set_or => self.terminal.screens.active.kitty_keyboard.set(.@"or", value.flags), - .kitty_keyboard_set_not => self.terminal.screens.active.kitty_keyboard.set(.not, value.flags), - .modify_key_format => { - self.terminal.flags.modify_other_keys_2 = false; - switch (value) { - .other_keys_numeric => self.terminal.flags.modify_other_keys_2 = true, - else => {}, - } - }, - .active_status_display => self.terminal.status_display = value, - .decaln => try self.terminal.decaln(), - .full_reset => self.terminal.fullReset(), - .start_hyperlink => try self.terminal.screens.active.startHyperlink(value.uri, value.id), - .end_hyperlink => self.terminal.screens.active.endHyperlink(), - .semantic_prompt => try self.terminal.semanticPrompt(value), - .mouse_shape => self.terminal.mouse_shape = value, - .color_operation => try self.colorOperation(value.op, &value.requests), - .kitty_color_report => try self.kittyColorOperation(value), - - // No supported DCS commands have any terminal-modifying effects, - // but they may in the future. For now we just ignore it. - .dcs_hook, - .dcs_put, - .dcs_unhook, - => {}, - - // APC can modify terminal state (Kitty graphics) but we don't - // currently support it in the readonly stream. - .apc_start, - .apc_end, - .apc_put, - => {}, - - // Have no terminal-modifying effect - .bell, - .enquiry, - .request_mode, - .request_mode_unknown, - .size_report, - .xtversion, - .device_attributes, - .device_status, - .kitty_keyboard_query, - .window_title, - .report_pwd, - .show_desktop_notification, - .progress_report, - .clipboard_contents, - .title_push, - .title_pop, - => {}, - } - } - - inline fn horizontalTab(self: *Handler, count: u16) void { - for (0..count) |_| { - const x = self.terminal.screens.active.cursor.x; - self.terminal.horizontalTab(); - if (x == self.terminal.screens.active.cursor.x) break; - } - } - - inline fn horizontalTabBack(self: *Handler, count: u16) void { - for (0..count) |_| { - const x = self.terminal.screens.active.cursor.x; - self.terminal.horizontalTabBack(); - if (x == self.terminal.screens.active.cursor.x) break; - } - } - - fn setMode(self: *Handler, mode: modes.Mode, enabled: bool) !void { - // Set the mode on the terminal - self.terminal.modes.set(mode, enabled); - - // Some modes require additional processing - switch (mode) { - .autorepeat, - .reverse_colors, - => {}, - - .origin => self.terminal.setCursorPos(1, 1), - - .enable_left_and_right_margin => if (!enabled) { - self.terminal.scrolling_region.left = 0; - self.terminal.scrolling_region.right = self.terminal.cols - 1; - }, - - .alt_screen_legacy => try self.terminal.switchScreenMode(.@"47", enabled), - .alt_screen => try self.terminal.switchScreenMode(.@"1047", enabled), - .alt_screen_save_cursor_clear_enter => try self.terminal.switchScreenMode(.@"1049", enabled), - - .save_cursor => if (enabled) { - self.terminal.saveCursor(); - } else { - self.terminal.restoreCursor(); - }, - - .enable_mode_3 => {}, - - .@"132_column" => try self.terminal.deccolm( - self.terminal.screens.active.alloc, - if (enabled) .@"132_cols" else .@"80_cols", - ), - - .synchronized_output, - .linefeed, - .in_band_size_reports, - .focus_event, - => {}, - - .mouse_event_x10 => { - if (enabled) { - self.terminal.flags.mouse_event = .x10; - } else { - self.terminal.flags.mouse_event = .none; - } - }, - .mouse_event_normal => { - if (enabled) { - self.terminal.flags.mouse_event = .normal; - } else { - self.terminal.flags.mouse_event = .none; - } - }, - .mouse_event_button => { - if (enabled) { - self.terminal.flags.mouse_event = .button; - } else { - self.terminal.flags.mouse_event = .none; - } - }, - .mouse_event_any => { - if (enabled) { - self.terminal.flags.mouse_event = .any; - } else { - self.terminal.flags.mouse_event = .none; - } - }, - - .mouse_format_utf8 => self.terminal.flags.mouse_format = if (enabled) .utf8 else .x10, - .mouse_format_sgr => self.terminal.flags.mouse_format = if (enabled) .sgr else .x10, - .mouse_format_urxvt => self.terminal.flags.mouse_format = if (enabled) .urxvt else .x10, - .mouse_format_sgr_pixels => self.terminal.flags.mouse_format = if (enabled) .sgr_pixels else .x10, - - else => {}, - } - } - - fn colorOperation( - self: *Handler, - op: osc_color.Operation, - requests: *const osc_color.List, - ) !void { - _ = op; - if (requests.count() == 0) return; - - var it = requests.constIterator(0); - while (it.next()) |req| { - switch (req.*) { - .set => |set| { - switch (set.target) { - .palette => |i| { - self.terminal.flags.dirty.palette = true; - self.terminal.colors.palette.set(i, set.color); - }, - .dynamic => |dynamic| switch (dynamic) { - .foreground => self.terminal.colors.foreground.set(set.color), - .background => self.terminal.colors.background.set(set.color), - .cursor => self.terminal.colors.cursor.set(set.color), - .pointer_foreground, - .pointer_background, - .tektronix_foreground, - .tektronix_background, - .highlight_background, - .tektronix_cursor, - .highlight_foreground, - => {}, - }, - .special => {}, - } - }, - - .reset => |target| switch (target) { - .palette => |i| { - self.terminal.flags.dirty.palette = true; - self.terminal.colors.palette.reset(i); - }, - .dynamic => |dynamic| switch (dynamic) { - .foreground => self.terminal.colors.foreground.reset(), - .background => self.terminal.colors.background.reset(), - .cursor => self.terminal.colors.cursor.reset(), - .pointer_foreground, - .pointer_background, - .tektronix_foreground, - .tektronix_background, - .highlight_background, - .tektronix_cursor, - .highlight_foreground, - => {}, - }, - .special => {}, - }, - - .reset_palette => { - const mask = &self.terminal.colors.palette.mask; - var mask_it = mask.iterator(.{}); - while (mask_it.next()) |i| { - self.terminal.flags.dirty.palette = true; - self.terminal.colors.palette.reset(@intCast(i)); - } - mask.* = .initEmpty(); - }, - - .query, - .reset_special, - => {}, - } - } - } - - fn kittyColorOperation( - self: *Handler, - request: kitty_color.OSC, - ) !void { - for (request.list.items) |item| { - switch (item) { - .set => |v| switch (v.key) { - .palette => |palette| { - self.terminal.flags.dirty.palette = true; - self.terminal.colors.palette.set(palette, v.color); - }, - .special => |special| switch (special) { - .foreground => self.terminal.colors.foreground.set(v.color), - .background => self.terminal.colors.background.set(v.color), - .cursor => self.terminal.colors.cursor.set(v.color), - else => {}, - }, - }, - .reset => |key| switch (key) { - .palette => |palette| { - self.terminal.flags.dirty.palette = true; - self.terminal.colors.palette.reset(palette); - }, - .special => |special| switch (special) { - .foreground => self.terminal.colors.foreground.reset(), - .background => self.terminal.colors.background.reset(), - .cursor => self.terminal.colors.cursor.reset(), - else => {}, - }, - }, - .query => {}, - } - } - } -}; - -test "basic print" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - try s.nextSlice("Hello"); - try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); - - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("Hello", str); -} - -test "cursor movement" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Move cursor using escape sequences - try s.nextSlice("Hello\x1B[1;1H"); - try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); - - // Move to position 2,3 - try s.nextSlice("\x1B[2;3H"); - try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); - try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); -} - -test "erase operations" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 20, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Print some text - try s.nextSlice("Hello World"); - try testing.expectEqual(@as(usize, 11), t.screens.active.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); - - // Move cursor to position 1,6 and erase from cursor to end of line - try s.nextSlice("\x1B[1;6H"); - try s.nextSlice("\x1B[K"); - - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("Hello", str); -} - -test "tabs" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - try s.nextSlice("A\tB"); - try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.x); - - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A B", str); -} - -test "modes" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Test wraparound mode - try testing.expect(t.modes.get(.wraparound)); - try s.nextSlice("\x1B[?7l"); // Disable wraparound - try testing.expect(!t.modes.get(.wraparound)); - try s.nextSlice("\x1B[?7h"); // Enable wraparound - try testing.expect(t.modes.get(.wraparound)); -} - -test "scrolling regions" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Set scrolling region from line 5 to 20 - try s.nextSlice("\x1B[5;20r"); - try testing.expectEqual(@as(usize, 4), t.scrolling_region.top); - try testing.expectEqual(@as(usize, 19), t.scrolling_region.bottom); - try testing.expectEqual(@as(usize, 0), t.scrolling_region.left); - try testing.expectEqual(@as(usize, 79), t.scrolling_region.right); -} - -test "charsets" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Configure G0 as DEC special graphics - try s.nextSlice("\x1B(0"); - try s.nextSlice("`"); // Should print diamond character - - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("◆", str); -} - -test "alt screen" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 5 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Write to primary screen - try s.nextSlice("Primary"); - try testing.expectEqual(.primary, t.screens.active_key); - - // Switch to alt screen - try s.nextSlice("\x1B[?1049h"); - try testing.expectEqual(.alternate, t.screens.active_key); - - // Write to alt screen - try s.nextSlice("Alt"); - - // Switch back to primary - try s.nextSlice("\x1B[?1049l"); - try testing.expectEqual(.primary, t.screens.active_key); - - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("Primary", str); -} - -test "cursor save and restore" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Move cursor to 10,15 - try s.nextSlice("\x1B[10;15H"); - try testing.expectEqual(@as(usize, 14), t.screens.active.cursor.x); - try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.y); - - // Save cursor - try s.nextSlice("\x1B7"); - - // Move cursor elsewhere - try s.nextSlice("\x1B[1;1H"); - try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); - - // Restore cursor - try s.nextSlice("\x1B8"); - try testing.expectEqual(@as(usize, 14), t.screens.active.cursor.x); - try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.y); -} - -test "attributes" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Set bold and write text - try s.nextSlice("\x1B[1mBold\x1B[0m"); - - // Verify we can write attributes - just check the string was written - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("Bold", str); -} - -test "DECALN screen alignment" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 3 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Run DECALN - try s.nextSlice("\x1B#8"); - - // Verify entire screen is filled with 'E' - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("EEEEEEEEEE\nEEEEEEEEEE\nEEEEEEEEEE", str); - - // Cursor should be at 1,1 - try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); -} - -test "full reset" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Make some changes - try s.nextSlice("Hello"); - try s.nextSlice("\x1B[10;20H"); - try s.nextSlice("\x1B[5;20r"); // Set scroll region - try s.nextSlice("\x1B[?7l"); // Disable wraparound - - // Full reset - try s.nextSlice("\x1Bc"); - - // Verify reset state - try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); - try testing.expectEqual(@as(usize, 0), t.scrolling_region.top); - try testing.expectEqual(@as(usize, 23), t.scrolling_region.bottom); - try testing.expect(t.modes.get(.wraparound)); -} - -test "ignores query actions" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // These should be ignored without error - try s.nextSlice("\x1B[c"); // Device attributes - try s.nextSlice("\x1B[5n"); // Device status report - try s.nextSlice("\x1B[6n"); // Cursor position report - - // Terminal should still be functional - try s.nextSlice("Test"); - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("Test", str); -} - -test "OSC 4 set and reset palette" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Save default color - const default_color_0 = t.colors.palette.original[0]; - - // Set color 0 to red - try s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); - try testing.expectEqual(@as(u8, 0xff), t.colors.palette.current[0].r); - try testing.expectEqual(@as(u8, 0x00), t.colors.palette.current[0].g); - try testing.expectEqual(@as(u8, 0x00), t.colors.palette.current[0].b); - try testing.expect(t.colors.palette.mask.isSet(0)); - - // Reset color 0 - try s.nextSlice("\x1b]104;0\x1b\\"); - try testing.expectEqual(default_color_0, t.colors.palette.current[0]); - try testing.expect(!t.colors.palette.mask.isSet(0)); -} - -test "OSC 104 reset all palette colors" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Set multiple colors - try s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); - try s.nextSlice("\x1b]4;1;rgb:00/ff/00\x1b\\"); - try s.nextSlice("\x1b]4;2;rgb:00/00/ff\x1b\\"); - try testing.expect(t.colors.palette.mask.isSet(0)); - try testing.expect(t.colors.palette.mask.isSet(1)); - try testing.expect(t.colors.palette.mask.isSet(2)); - - // Reset all palette colors - try s.nextSlice("\x1b]104\x1b\\"); - try testing.expectEqual(t.colors.palette.original[0], t.colors.palette.current[0]); - try testing.expectEqual(t.colors.palette.original[1], t.colors.palette.current[1]); - try testing.expectEqual(t.colors.palette.original[2], t.colors.palette.current[2]); - try testing.expect(!t.colors.palette.mask.isSet(0)); - try testing.expect(!t.colors.palette.mask.isSet(1)); - try testing.expect(!t.colors.palette.mask.isSet(2)); -} - -test "OSC 10 set and reset foreground color" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Initially unset - try testing.expect(t.colors.foreground.get() == null); - - // Set foreground to red - try s.nextSlice("\x1b]10;rgb:ff/00/00\x1b\\"); - const fg = t.colors.foreground.get().?; - try testing.expectEqual(@as(u8, 0xff), fg.r); - try testing.expectEqual(@as(u8, 0x00), fg.g); - try testing.expectEqual(@as(u8, 0x00), fg.b); - - // Reset foreground - try s.nextSlice("\x1b]110\x1b\\"); - try testing.expect(t.colors.foreground.get() == null); -} - -test "OSC 11 set and reset background color" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Set background to green - try s.nextSlice("\x1b]11;rgb:00/ff/00\x1b\\"); - const bg = t.colors.background.get().?; - try testing.expectEqual(@as(u8, 0x00), bg.r); - try testing.expectEqual(@as(u8, 0xff), bg.g); - try testing.expectEqual(@as(u8, 0x00), bg.b); - - // Reset background - try s.nextSlice("\x1b]111\x1b\\"); - try testing.expect(t.colors.background.get() == null); -} - -test "OSC 12 set and reset cursor color" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Set cursor to blue - try s.nextSlice("\x1b]12;rgb:00/00/ff\x1b\\"); - const cursor = t.colors.cursor.get().?; - try testing.expectEqual(@as(u8, 0x00), cursor.r); - try testing.expectEqual(@as(u8, 0x00), cursor.g); - try testing.expectEqual(@as(u8, 0xff), cursor.b); - - // Reset cursor - try s.nextSlice("\x1b]112\x1b\\"); - // After reset, cursor might be null (using default) -} - -test "kitty color protocol set palette" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Set palette color 5 to magenta using kitty protocol - try s.nextSlice("\x1b]21;5=rgb:ff/00/ff\x1b\\"); - try testing.expectEqual(@as(u8, 0xff), t.colors.palette.current[5].r); - try testing.expectEqual(@as(u8, 0x00), t.colors.palette.current[5].g); - try testing.expectEqual(@as(u8, 0xff), t.colors.palette.current[5].b); - try testing.expect(t.colors.palette.mask.isSet(5)); - try testing.expect(t.flags.dirty.palette); -} - -test "kitty color protocol reset palette" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Set and then reset palette color - const original = t.colors.palette.original[7]; - try s.nextSlice("\x1b]21;7=rgb:aa/bb/cc\x1b\\"); - try testing.expect(t.colors.palette.mask.isSet(7)); - - try s.nextSlice("\x1b]21;7=\x1b\\"); - try testing.expectEqual(original, t.colors.palette.current[7]); - try testing.expect(!t.colors.palette.mask.isSet(7)); -} - -test "kitty color protocol set foreground" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Set foreground using kitty protocol - try s.nextSlice("\x1b]21;foreground=rgb:12/34/56\x1b\\"); - const fg = t.colors.foreground.get().?; - try testing.expectEqual(@as(u8, 0x12), fg.r); - try testing.expectEqual(@as(u8, 0x34), fg.g); - try testing.expectEqual(@as(u8, 0x56), fg.b); -} - -test "kitty color protocol set background" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Set background using kitty protocol - try s.nextSlice("\x1b]21;background=rgb:78/9a/bc\x1b\\"); - const bg = t.colors.background.get().?; - try testing.expectEqual(@as(u8, 0x78), bg.r); - try testing.expectEqual(@as(u8, 0x9a), bg.g); - try testing.expectEqual(@as(u8, 0xbc), bg.b); -} - -test "kitty color protocol set cursor" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Set cursor using kitty protocol - try s.nextSlice("\x1b]21;cursor=rgb:de/f0/12\x1b\\"); - const cursor = t.colors.cursor.get().?; - try testing.expectEqual(@as(u8, 0xde), cursor.r); - try testing.expectEqual(@as(u8, 0xf0), cursor.g); - try testing.expectEqual(@as(u8, 0x12), cursor.b); -} - -test "kitty color protocol reset foreground" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Set and reset foreground - try s.nextSlice("\x1b]21;foreground=rgb:11/22/33\x1b\\"); - try testing.expect(t.colors.foreground.get() != null); - - try s.nextSlice("\x1b]21;foreground=\x1b\\"); - // After reset, should be unset - try testing.expect(t.colors.foreground.get() == null); -} - -test "palette dirty flag set on color change" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Clear dirty flag - t.flags.dirty.palette = false; - - // Setting palette color should set dirty flag - try s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); - try testing.expect(t.flags.dirty.palette); - - // Clear and test reset - t.flags.dirty.palette = false; - try s.nextSlice("\x1b]104;0\x1b\\"); - try testing.expect(t.flags.dirty.palette); - - // Clear and test kitty protocol - t.flags.dirty.palette = false; - try s.nextSlice("\x1b]21;1=rgb:00/ff/00\x1b\\"); - try testing.expect(t.flags.dirty.palette); -} - -test "semantic prompt fresh line" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - try s.nextSlice("Hello"); - try s.nextSlice("\x1b]133;L\x07"); - try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); - try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); -} - -test "semantic prompt fresh line new prompt" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Write some text and then send OSC 133;A (fresh_line_new_prompt) - try s.nextSlice("Hello"); - try s.nextSlice("\x1b]133;A\x07"); - - // Should do a fresh line (carriage return + index) - try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); - try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); - - // Should set cursor semantic_content to prompt - try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); - - // Test with redraw option - try s.nextSlice("prompt$ "); - try s.nextSlice("\x1b]133;A;redraw=1\x07"); - try testing.expect(t.flags.shell_redraws_prompt == .true); -} - -test "semantic prompt end of input, then start output" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Write some text and then send OSC 133;A (fresh_line_new_prompt) - try s.nextSlice("Hello"); - try s.nextSlice("\x1b]133;A\x07"); - try s.nextSlice("prompt$ "); - try s.nextSlice("\x1b]133;B\x07"); - try testing.expectEqual(.input, t.screens.active.cursor.semantic_content); - try s.nextSlice("\x1b]133;C\x07"); - try testing.expectEqual(.output, t.screens.active.cursor.semantic_content); -} - -test "semantic prompt prompt_start" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Write some text - try s.nextSlice("Hello"); - - // OSC 133;P marks the start of a prompt (without fresh line behavior) - try s.nextSlice("\x1b]133;P\x07"); - try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); - try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); -} - -test "semantic prompt new_command" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Write some text - try s.nextSlice("Hello"); - try s.nextSlice("\x1b]133;N\x07"); - - // Should behave like fresh_line_new_prompt - cursor moves to column 0 - // on next line since we had content - try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); - try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); - try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); -} - -test "semantic prompt new_command at column zero" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // OSC 133;N when already at column 0 should stay on same line - try s.nextSlice("\x1b]133;N\x07"); - try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); - try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); -} - -test "semantic prompt end_prompt_start_input_terminate_eol clears on linefeed" { - var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - // Set input terminated by EOL - try s.nextSlice("\x1b]133;I\x07"); - try testing.expectEqual(.input, t.screens.active.cursor.semantic_content); - - // Linefeed should reset semantic content to output - try s.nextSlice("\n"); - try testing.expectEqual(.output, t.screens.active.cursor.semantic_content); -} - -test "stream: CSI W with intermediate but no params" { - // Regression test from AFL++ crash. CSI ? W without - // parameters caused an out-of-bounds access on input.params[0]. - var t: Terminal = try .init(testing.allocator, .{ - .cols = 80, - .rows = 24, - .max_scrollback = 100, - }); - defer t.deinit(testing.allocator); - - var s: Stream = .initAlloc(testing.allocator, .init(&t)); - defer s.deinit(); - - try s.nextSlice("\x1b[?W"); -} diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig new file mode 100644 index 000000000..8e0f91110 --- /dev/null +++ b/src/terminal/stream_terminal.zig @@ -0,0 +1,2071 @@ +const std = @import("std"); +const testing = std.testing; +const csi = @import("csi.zig"); +const device_attributes = @import("device_attributes.zig"); +const device_status = @import("device_status.zig"); +const stream = @import("stream.zig"); +const Action = stream.Action; +const Screen = @import("Screen.zig"); +const modes = @import("modes.zig"); +const osc_color = @import("osc/parsers/color.zig"); +const kitty_color = @import("kitty/color.zig"); +const size_report = @import("size_report.zig"); +const Terminal = @import("Terminal.zig"); + +const log = std.log.scoped(.stream_terminal); + +/// This is a Stream implementation that processes actions against +/// a Terminal and updates the Terminal state. +pub const Stream = stream.Stream(Handler); + +/// A stream handler that updates terminal state. By default, it is +/// readonly in the sense that it only updates terminal state and ignores +/// all other sequences that require a response or otherwise have side +/// effects (e.g. clipboards). +/// +/// You can manually set various effects callbacks in the `effects` field +/// to implement certain effects such as bells, titles, clipboard, etc. +pub const Handler = struct { + /// The terminal state to modify. + terminal: *Terminal, + + /// Callbacks for certain effects that handlers may have. These + /// may or may not fully replace internal handling of certain effects, + /// but they allow for the handler to trigger or query external + /// effects. + effects: Effects = .readonly, + + 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 + /// during the lifetime of the call so callers must copy it + /// if it needs to be stored or used after the call returns. + write_pty: ?*const fn (*Handler, [:0]const u8) void, + + /// Called when the bell is rung (BEL). + bell: ?*const fn (*Handler) void, + + /// Called in response to a color scheme DSR query (CSI ? 996 n). + /// Returns the current color scheme. Return null to silently + /// ignore the query. + color_scheme: ?*const fn (*Handler) ?device_status.ColorScheme, + + /// Called in response to a device attributes query (CSI c, + /// CSI > c, CSI = c). Returns the response to encode and + /// write back to the pty. + device_attributes: ?*const fn (*Handler) device_attributes.Attributes, + + /// Called in response to ENQ (0x05). Returns the raw response + /// bytes to write back to the pty. The returned memory must be + /// valid for the lifetime of the call. + enquiry: ?*const fn (*Handler) []const u8, + + /// Called in response to XTWINOPS size queries (CSI 14/16/18 t). + /// Returns the current terminal geometry used for encoding. + /// Return null to silently ignore the query. + size: ?*const fn (*Handler) ?size_report.Size, + + /// Called when the terminal title changes via escape sequences + /// (e.g. OSC 0/2). The new title can be queried via + /// handler.terminal.getTitle(). + title_changed: ?*const fn (*Handler) void, + + /// Called in response to an XTVERSION query. Returns the version + /// string to report (e.g. "ghostty 1.2.3"). The returned memory + /// must be valid for the lifetime of the call. The maximum length + /// is 256 bytes; longer strings will be silently ignored. + xtversion: ?*const fn (*Handler) []const u8, + + /// No effects means that the stream effectively becomes readonly + /// that only affects pure terminal state and ignores all side + /// effects beyond that. + pub const readonly: Effects = .{ + .bell = null, + .color_scheme = null, + .device_attributes = null, + .enquiry = null, + .size = null, + .title_changed = null, + .write_pty = null, + .xtversion = null, + }; + }; + + pub fn init(terminal: *Terminal) Handler { + return .{ + .terminal = terminal, + }; + } + + pub fn deinit(self: *Handler) void { + // Currently does nothing but may in the future so callers should + // call this. + _ = self; + } + + pub fn vt( + self: *Handler, + comptime action: Action.Tag, + value: Action.Value(action), + ) void { + self.vtFallible(action, value) catch |err| { + log.warn("error handling VT action action={} err={}", .{ action, err }); + }; + } + + inline fn vtFallible( + self: *Handler, + comptime action: Action.Tag, + value: Action.Value(action), + ) !void { + switch (action) { + .print => try self.terminal.print(value.cp), + .print_repeat => try self.terminal.printRepeat(value), + .backspace => self.terminal.backspace(), + .carriage_return => self.terminal.carriageReturn(), + .linefeed => try self.terminal.linefeed(), + .index => try self.terminal.index(), + .next_line => { + try self.terminal.index(); + self.terminal.carriageReturn(); + }, + .reverse_index => self.terminal.reverseIndex(), + .cursor_up => self.terminal.cursorUp(value.value), + .cursor_down => self.terminal.cursorDown(value.value), + .cursor_left => self.terminal.cursorLeft(value.value), + .cursor_right => self.terminal.cursorRight(value.value), + .cursor_pos => self.terminal.setCursorPos(value.row, value.col), + .cursor_col => self.terminal.setCursorPos(self.terminal.screens.active.cursor.y + 1, value.value), + .cursor_row => self.terminal.setCursorPos(value.value, self.terminal.screens.active.cursor.x + 1), + .cursor_col_relative => self.terminal.setCursorPos( + self.terminal.screens.active.cursor.y + 1, + self.terminal.screens.active.cursor.x + 1 +| value.value, + ), + .cursor_row_relative => self.terminal.setCursorPos( + self.terminal.screens.active.cursor.y + 1 +| value.value, + self.terminal.screens.active.cursor.x + 1, + ), + .cursor_style => { + const blink = switch (value) { + .default, .steady_block, .steady_bar, .steady_underline => false, + .blinking_block, .blinking_bar, .blinking_underline => true, + }; + const style: Screen.CursorStyle = switch (value) { + .default, .blinking_block, .steady_block => .block, + .blinking_bar, .steady_bar => .bar, + .blinking_underline, .steady_underline => .underline, + }; + self.terminal.modes.set(.cursor_blinking, blink); + self.terminal.screens.active.cursor.cursor_style = style; + }, + .erase_display_below => self.terminal.eraseDisplay(.below, value), + .erase_display_above => self.terminal.eraseDisplay(.above, value), + .erase_display_complete => self.terminal.eraseDisplay(.complete, value), + .erase_display_scrollback => self.terminal.eraseDisplay(.scrollback, value), + .erase_display_scroll_complete => self.terminal.eraseDisplay(.scroll_complete, value), + .erase_line_right => self.terminal.eraseLine(.right, value), + .erase_line_left => self.terminal.eraseLine(.left, value), + .erase_line_complete => self.terminal.eraseLine(.complete, value), + .erase_line_right_unless_pending_wrap => self.terminal.eraseLine(.right_unless_pending_wrap, value), + .delete_chars => self.terminal.deleteChars(value), + .erase_chars => self.terminal.eraseChars(value), + .insert_lines => self.terminal.insertLines(value), + .insert_blanks => self.terminal.insertBlanks(value), + .delete_lines => self.terminal.deleteLines(value), + .scroll_up => try self.terminal.scrollUp(value), + .scroll_down => self.terminal.scrollDown(value), + .horizontal_tab => self.horizontalTab(value), + .horizontal_tab_back => self.horizontalTabBack(value), + .tab_clear_current => self.terminal.tabClear(.current), + .tab_clear_all => self.terminal.tabClear(.all), + .tab_set => self.terminal.tabSet(), + .tab_reset => self.terminal.tabReset(), + .set_mode => try self.setMode(value.mode, true), + .reset_mode => try self.setMode(value.mode, false), + .save_mode => self.terminal.modes.save(value.mode), + .restore_mode => { + const v = self.terminal.modes.restore(value.mode); + try self.setMode(value.mode, v); + }, + .top_and_bottom_margin => self.terminal.setTopAndBottomMargin(value.top_left, value.bottom_right), + .left_and_right_margin => self.terminal.setLeftAndRightMargin(value.top_left, value.bottom_right), + .left_and_right_margin_ambiguous => { + if (self.terminal.modes.get(.enable_left_and_right_margin)) { + self.terminal.setLeftAndRightMargin(0, 0); + } else { + self.terminal.saveCursor(); + } + }, + .save_cursor => self.terminal.saveCursor(), + .restore_cursor => self.terminal.restoreCursor(), + .invoke_charset => self.terminal.invokeCharset(value.bank, value.charset, value.locking), + .configure_charset => self.terminal.configureCharset(value.slot, value.charset), + .set_attribute => switch (value) { + .unknown => {}, + else => self.terminal.setAttribute(value) catch {}, + }, + .protected_mode_off => self.terminal.setProtectedMode(.off), + .protected_mode_iso => self.terminal.setProtectedMode(.iso), + .protected_mode_dec => self.terminal.setProtectedMode(.dec), + .mouse_shift_capture => self.terminal.flags.mouse_shift_capture = if (value) .true else .false, + .kitty_keyboard_push => self.terminal.screens.active.kitty_keyboard.push(value.flags), + .kitty_keyboard_pop => self.terminal.screens.active.kitty_keyboard.pop(@intCast(value)), + .kitty_keyboard_set => self.terminal.screens.active.kitty_keyboard.set(.set, value.flags), + .kitty_keyboard_set_or => self.terminal.screens.active.kitty_keyboard.set(.@"or", value.flags), + .kitty_keyboard_set_not => self.terminal.screens.active.kitty_keyboard.set(.not, value.flags), + .modify_key_format => { + self.terminal.flags.modify_other_keys_2 = false; + switch (value) { + .other_keys_numeric => self.terminal.flags.modify_other_keys_2 = true, + else => {}, + } + }, + .active_status_display => self.terminal.status_display = value, + .decaln => try self.terminal.decaln(), + .full_reset => self.terminal.fullReset(), + .start_hyperlink => try self.terminal.screens.active.startHyperlink(value.uri, value.id), + .end_hyperlink => self.terminal.screens.active.endHyperlink(), + .semantic_prompt => try self.terminal.semanticPrompt(value), + .mouse_shape => self.terminal.mouse_shape = value, + .color_operation => try self.colorOperation(value.op, &value.requests), + .kitty_color_report => try self.kittyColorOperation(value), + + // Effect-based handlers + .bell => self.bell(), + .device_attributes => self.reportDeviceAttributes(value), + .device_status => self.deviceStatus(value.request), + .enquiry => self.reportEnquiry(), + .kitty_keyboard_query => self.queryKittyKeyboard(), + .request_mode => self.requestMode(value.mode), + .request_mode_unknown => self.requestModeUnknown(value.mode, value.ansi), + .size_report => self.reportSize(value), + .window_title => self.windowTitle(value.title), + .xtversion => self.reportXtversion(), + + // No supported DCS commands have any terminal-modifying effects, + // but they may in the future. For now we just ignore it. + .dcs_hook, + .dcs_put, + .dcs_unhook, + => {}, + + // APC can modify terminal state (Kitty graphics) but we don't + // currently support it in the readonly stream. + .apc_start, + .apc_end, + .apc_put, + => {}, + + // Have no terminal-modifying effect + .report_pwd, + .show_desktop_notification, + .progress_report, + .clipboard_contents, + .title_push, + .title_pop, + => {}, + } + } + + inline fn writePty(self: *Handler, data: [:0]const u8) void { + const func = self.effects.write_pty orelse return; + func(self, data); + } + + fn bell(self: *Handler) void { + const func = self.effects.bell orelse return; + func(self); + } + + fn reportDeviceAttributes(self: *Handler, req: device_attributes.Req) void { + const func = self.effects.device_attributes orelse return; + const attrs = func(self); + + var stack = std.heap.stackFallback(128, self.terminal.gpa()); + const alloc = stack.get(); + + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + + attrs.encode(req, &aw.writer) catch return; + + const written = aw.toOwnedSliceSentinel(0) catch return; + defer alloc.free(written); + self.writePty(written); + } + + fn deviceStatus(self: *Handler, req: device_status.Request) void { + switch (req) { + .operating_status => self.writePty("\x1B[0n"), + + .cursor_position => { + const pos: struct { + x: usize, + y: usize, + } = if (self.terminal.modes.get(.origin)) .{ + .x = self.terminal.screens.active.cursor.x -| self.terminal.scrolling_region.left, + .y = self.terminal.screens.active.cursor.y -| self.terminal.scrolling_region.top, + } else .{ + .x = self.terminal.screens.active.cursor.x, + .y = self.terminal.screens.active.cursor.y, + }; + + var buf: [64]u8 = undefined; + const resp = std.fmt.bufPrintZ(&buf, "\x1B[{};{}R", .{ + pos.y + 1, + pos.x + 1, + }) catch return; + self.writePty(resp); + }, + + .color_scheme => { + const func = self.effects.color_scheme orelse return; + const scheme = func(self) orelse return; + self.writePty(switch (scheme) { + .dark => "\x1B[?997;1n", + .light => "\x1B[?997;2n", + }); + }, + } + } + + fn reportEnquiry(self: *Handler) void { + const func = self.effects.enquiry orelse return; + const response = func(self); + if (response.len == 0) return; + var buf: [256]u8 = undefined; + if (response.len >= buf.len) return; + @memcpy(buf[0..response.len], response); + buf[response.len] = 0; + self.writePty(buf[0..response.len :0]); + } + + fn reportXtversion(self: *Handler) void { + const version = if (self.effects.xtversion) |func| func(self) else ""; + var buf: [288]u8 = undefined; + const resp = std.fmt.bufPrintZ( + &buf, + "\x1BP>|{s}\x1B\\", + .{if (version.len > 0) version else "libghostty"}, + ) catch return; + self.writePty(resp); + } + + fn reportSize(self: *Handler, style: csi.SizeReportStyle) void { + // Almost all size reports will fit in 256 bytes so try that + // on the stack before falling back to a heap allocation. + var stack = std.heap.stackFallback( + 256, + self.terminal.gpa(), + ); + const alloc = stack.get(); + + // Allocating writing to accumulate the response. + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + + // Build the response. + switch (style) { + .csi_21_t => { + const title = self.terminal.getTitle() orelse ""; + aw.writer.print("\x1b]l{s}\x1b\\", .{title}) catch return; + }, + + .csi_14_t, .csi_16_t, .csi_18_t => { + const get_size = self.effects.size orelse return; + const s = get_size(self) orelse return; + const report_style: size_report.Style = switch (style) { + .csi_14_t => .csi_14_t, + .csi_16_t => .csi_16_t, + .csi_18_t => .csi_18_t, + .csi_21_t => unreachable, + }; + size_report.encode( + &aw.writer, + report_style, + s, + ) catch |err| { + log.warn("error encoding size report err={}", .{err}); + return; + }; + }, + } + + const resp = aw.toOwnedSliceSentinel(0) catch return; + defer alloc.free(resp); + self.writePty(resp); + } + + fn windowTitle(self: *Handler, title_raw: []const u8) void { + // Prevent DoS attacks by limiting title length. + const max_title_len = 1024; + const title = if (title_raw.len > max_title_len) title: { + log.warn("title length {d} exceeds max length {d}, truncating", .{ + title_raw.len, + max_title_len, + }); + break :title title_raw[0..max_title_len]; + } else title_raw; + + self.terminal.setTitle(title) catch |err| { + log.warn("error setting title err={}", .{err}); + return; + }; + + const func = self.effects.title_changed orelse return; + func(self); + } + + fn requestMode(self: *Handler, mode: modes.Mode) void { + const report = self.terminal.modes.getReport(.fromMode(mode)); + self.sendModeReport(report); + } + + fn requestModeUnknown(self: *Handler, mode_raw: u16, ansi: bool) void { + const report = self.terminal.modes.getReport(.{ + .value = @truncate(mode_raw), + .ansi = ansi, + }); + self.sendModeReport(report); + } + + fn sendModeReport(self: *Handler, report: modes.Report) void { + var buf: [modes.Report.max_size + 1]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + report.encode(&writer) catch |err| { + log.warn("error encoding mode report err={}", .{err}); + return; + }; + const len = writer.buffered().len; + buf[len] = 0; + self.writePty(buf[0..len :0]); + } + + fn queryKittyKeyboard(self: *Handler) void { + // Max response is "\x1b[?31u\x00" (7 bytes): the flags are a u5 (max 31). + var buf: [32]u8 = undefined; + const resp = std.fmt.bufPrintZ(&buf, "\x1b[?{}u", .{ + self.terminal.screens.active.kitty_keyboard.current().int(), + }) catch return; + self.writePty(resp); + } + + inline fn horizontalTab(self: *Handler, count: u16) void { + for (0..count) |_| { + const x = self.terminal.screens.active.cursor.x; + self.terminal.horizontalTab(); + if (x == self.terminal.screens.active.cursor.x) break; + } + } + + inline fn horizontalTabBack(self: *Handler, count: u16) void { + for (0..count) |_| { + const x = self.terminal.screens.active.cursor.x; + self.terminal.horizontalTabBack(); + if (x == self.terminal.screens.active.cursor.x) break; + } + } + + fn setMode(self: *Handler, mode: modes.Mode, enabled: bool) !void { + // Set the mode on the terminal + self.terminal.modes.set(mode, enabled); + + // Some modes require additional processing + switch (mode) { + .autorepeat, + .reverse_colors, + => {}, + + .origin => self.terminal.setCursorPos(1, 1), + + .enable_left_and_right_margin => if (!enabled) { + self.terminal.scrolling_region.left = 0; + self.terminal.scrolling_region.right = self.terminal.cols - 1; + }, + + .alt_screen_legacy => try self.terminal.switchScreenMode(.@"47", enabled), + .alt_screen => try self.terminal.switchScreenMode(.@"1047", enabled), + .alt_screen_save_cursor_clear_enter => try self.terminal.switchScreenMode(.@"1049", enabled), + + .save_cursor => if (enabled) { + self.terminal.saveCursor(); + } else { + self.terminal.restoreCursor(); + }, + + .enable_mode_3 => {}, + + .@"132_column" => try self.terminal.deccolm( + self.terminal.screens.active.alloc, + if (enabled) .@"132_cols" else .@"80_cols", + ), + + .synchronized_output, + .linefeed, + .in_band_size_reports, + .focus_event, + => {}, + + .mouse_event_x10 => { + if (enabled) { + self.terminal.flags.mouse_event = .x10; + } else { + self.terminal.flags.mouse_event = .none; + } + }, + .mouse_event_normal => { + if (enabled) { + self.terminal.flags.mouse_event = .normal; + } else { + self.terminal.flags.mouse_event = .none; + } + }, + .mouse_event_button => { + if (enabled) { + self.terminal.flags.mouse_event = .button; + } else { + self.terminal.flags.mouse_event = .none; + } + }, + .mouse_event_any => { + if (enabled) { + self.terminal.flags.mouse_event = .any; + } else { + self.terminal.flags.mouse_event = .none; + } + }, + + .mouse_format_utf8 => self.terminal.flags.mouse_format = if (enabled) .utf8 else .x10, + .mouse_format_sgr => self.terminal.flags.mouse_format = if (enabled) .sgr else .x10, + .mouse_format_urxvt => self.terminal.flags.mouse_format = if (enabled) .urxvt else .x10, + .mouse_format_sgr_pixels => self.terminal.flags.mouse_format = if (enabled) .sgr_pixels else .x10, + + else => {}, + } + } + + fn colorOperation( + self: *Handler, + op: osc_color.Operation, + requests: *const osc_color.List, + ) !void { + _ = op; + if (requests.count() == 0) return; + + var it = requests.constIterator(0); + while (it.next()) |req| { + switch (req.*) { + .set => |set| { + switch (set.target) { + .palette => |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.set(i, set.color); + }, + .dynamic => |dynamic| switch (dynamic) { + .foreground => self.terminal.colors.foreground.set(set.color), + .background => self.terminal.colors.background.set(set.color), + .cursor => self.terminal.colors.cursor.set(set.color), + .pointer_foreground, + .pointer_background, + .tektronix_foreground, + .tektronix_background, + .highlight_background, + .tektronix_cursor, + .highlight_foreground, + => {}, + }, + .special => {}, + } + }, + + .reset => |target| switch (target) { + .palette => |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.reset(i); + }, + .dynamic => |dynamic| switch (dynamic) { + .foreground => self.terminal.colors.foreground.reset(), + .background => self.terminal.colors.background.reset(), + .cursor => self.terminal.colors.cursor.reset(), + .pointer_foreground, + .pointer_background, + .tektronix_foreground, + .tektronix_background, + .highlight_background, + .tektronix_cursor, + .highlight_foreground, + => {}, + }, + .special => {}, + }, + + .reset_palette => { + const mask = &self.terminal.colors.palette.mask; + var mask_it = mask.iterator(.{}); + while (mask_it.next()) |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.reset(@intCast(i)); + } + mask.* = .initEmpty(); + }, + + .query, + .reset_special, + => {}, + } + } + } + + fn kittyColorOperation( + self: *Handler, + request: kitty_color.OSC, + ) !void { + for (request.list.items) |item| { + switch (item) { + .set => |v| switch (v.key) { + .palette => |palette| { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.set(palette, v.color); + }, + .special => |special| switch (special) { + .foreground => self.terminal.colors.foreground.set(v.color), + .background => self.terminal.colors.background.set(v.color), + .cursor => self.terminal.colors.cursor.set(v.color), + else => {}, + }, + }, + .reset => |key| switch (key) { + .palette => |palette| { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.reset(palette); + }, + .special => |special| switch (special) { + .foreground => self.terminal.colors.foreground.reset(), + .background => self.terminal.colors.background.reset(), + .cursor => self.terminal.colors.cursor.reset(), + else => {}, + }, + }, + .query => {}, + } + } + } +}; + +test "basic print" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + s.nextSlice("Hello"); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Hello", str); +} + +test "cursor movement" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Move cursor using escape sequences + s.nextSlice("Hello\x1B[1;1H"); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + + // Move to position 2,3 + s.nextSlice("\x1B[2;3H"); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); +} + +test "erase operations" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 20, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Print some text + s.nextSlice("Hello World"); + try testing.expectEqual(@as(usize, 11), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + + // Move cursor to position 1,6 and erase from cursor to end of line + s.nextSlice("\x1B[1;6H"); + s.nextSlice("\x1B[K"); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Hello", str); +} + +test "tabs" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + s.nextSlice("A\tB"); + try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.x); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A B", str); +} + +test "modes" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Test wraparound mode + try testing.expect(t.modes.get(.wraparound)); + s.nextSlice("\x1B[?7l"); // Disable wraparound + try testing.expect(!t.modes.get(.wraparound)); + s.nextSlice("\x1B[?7h"); // Enable wraparound + try testing.expect(t.modes.get(.wraparound)); +} + +test "scrolling regions" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set scrolling region from line 5 to 20 + s.nextSlice("\x1B[5;20r"); + try testing.expectEqual(@as(usize, 4), t.scrolling_region.top); + try testing.expectEqual(@as(usize, 19), t.scrolling_region.bottom); + try testing.expectEqual(@as(usize, 0), t.scrolling_region.left); + try testing.expectEqual(@as(usize, 79), t.scrolling_region.right); +} + +test "charsets" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Configure G0 as DEC special graphics + s.nextSlice("\x1B(0"); + s.nextSlice("`"); // Should print diamond character + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("◆", str); +} + +test "alt screen" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 5 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Write to primary screen + s.nextSlice("Primary"); + try testing.expectEqual(.primary, t.screens.active_key); + + // Switch to alt screen + s.nextSlice("\x1B[?1049h"); + try testing.expectEqual(.alternate, t.screens.active_key); + + // Write to alt screen + s.nextSlice("Alt"); + + // Switch back to primary + s.nextSlice("\x1B[?1049l"); + try testing.expectEqual(.primary, t.screens.active_key); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Primary", str); +} + +test "cursor save and restore" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Move cursor to 10,15 + s.nextSlice("\x1B[10;15H"); + try testing.expectEqual(@as(usize, 14), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.y); + + // Save cursor + s.nextSlice("\x1B7"); + + // Move cursor elsewhere + s.nextSlice("\x1B[1;1H"); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + + // Restore cursor + s.nextSlice("\x1B8"); + try testing.expectEqual(@as(usize, 14), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.y); +} + +test "attributes" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set bold and write text + s.nextSlice("\x1B[1mBold\x1B[0m"); + + // Verify we can write attributes - just check the string was written + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Bold", str); +} + +test "DECALN screen alignment" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 3 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Run DECALN + s.nextSlice("\x1B#8"); + + // Verify entire screen is filled with 'E' + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("EEEEEEEEEE\nEEEEEEEEEE\nEEEEEEEEEE", str); + + // Cursor should be at 1,1 + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); +} + +test "full reset" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Make some changes + s.nextSlice("Hello"); + s.nextSlice("\x1B[10;20H"); + s.nextSlice("\x1B[5;20r"); // Set scroll region + s.nextSlice("\x1B[?7l"); // Disable wraparound + + // Full reset + s.nextSlice("\x1Bc"); + + // Verify reset state + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.scrolling_region.top); + try testing.expectEqual(@as(usize, 23), t.scrolling_region.bottom); + try testing.expect(t.modes.get(.wraparound)); +} + +test "ignores query actions" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // These should be ignored without error + s.nextSlice("\x1B[c"); // Device attributes + s.nextSlice("\x1B[5n"); // Device status report + s.nextSlice("\x1B[6n"); // Cursor position report + + // Terminal should still be functional + s.nextSlice("Test"); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Test", str); +} + +test "OSC 4 set and reset palette" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Save default color + const default_color_0 = t.colors.palette.original[0]; + + // Set color 0 to red + s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); + try testing.expectEqual(@as(u8, 0xff), t.colors.palette.current[0].r); + try testing.expectEqual(@as(u8, 0x00), t.colors.palette.current[0].g); + try testing.expectEqual(@as(u8, 0x00), t.colors.palette.current[0].b); + try testing.expect(t.colors.palette.mask.isSet(0)); + + // Reset color 0 + s.nextSlice("\x1b]104;0\x1b\\"); + try testing.expectEqual(default_color_0, t.colors.palette.current[0]); + try testing.expect(!t.colors.palette.mask.isSet(0)); +} + +test "OSC 104 reset all palette colors" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set multiple colors + s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); + s.nextSlice("\x1b]4;1;rgb:00/ff/00\x1b\\"); + s.nextSlice("\x1b]4;2;rgb:00/00/ff\x1b\\"); + try testing.expect(t.colors.palette.mask.isSet(0)); + try testing.expect(t.colors.palette.mask.isSet(1)); + try testing.expect(t.colors.palette.mask.isSet(2)); + + // Reset all palette colors + s.nextSlice("\x1b]104\x1b\\"); + try testing.expectEqual(t.colors.palette.original[0], t.colors.palette.current[0]); + try testing.expectEqual(t.colors.palette.original[1], t.colors.palette.current[1]); + try testing.expectEqual(t.colors.palette.original[2], t.colors.palette.current[2]); + try testing.expect(!t.colors.palette.mask.isSet(0)); + try testing.expect(!t.colors.palette.mask.isSet(1)); + try testing.expect(!t.colors.palette.mask.isSet(2)); +} + +test "OSC 10 set and reset foreground color" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Initially unset + try testing.expect(t.colors.foreground.get() == null); + + // Set foreground to red + s.nextSlice("\x1b]10;rgb:ff/00/00\x1b\\"); + const fg = t.colors.foreground.get().?; + try testing.expectEqual(@as(u8, 0xff), fg.r); + try testing.expectEqual(@as(u8, 0x00), fg.g); + try testing.expectEqual(@as(u8, 0x00), fg.b); + + // Reset foreground + s.nextSlice("\x1b]110\x1b\\"); + try testing.expect(t.colors.foreground.get() == null); +} + +test "OSC 11 set and reset background color" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set background to green + s.nextSlice("\x1b]11;rgb:00/ff/00\x1b\\"); + const bg = t.colors.background.get().?; + try testing.expectEqual(@as(u8, 0x00), bg.r); + try testing.expectEqual(@as(u8, 0xff), bg.g); + try testing.expectEqual(@as(u8, 0x00), bg.b); + + // Reset background + s.nextSlice("\x1b]111\x1b\\"); + try testing.expect(t.colors.background.get() == null); +} + +test "OSC 12 set and reset cursor color" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set cursor to blue + s.nextSlice("\x1b]12;rgb:00/00/ff\x1b\\"); + const cursor = t.colors.cursor.get().?; + try testing.expectEqual(@as(u8, 0x00), cursor.r); + try testing.expectEqual(@as(u8, 0x00), cursor.g); + try testing.expectEqual(@as(u8, 0xff), cursor.b); + + // Reset cursor + s.nextSlice("\x1b]112\x1b\\"); + // After reset, cursor might be null (using default) +} + +test "kitty color protocol set palette" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set palette color 5 to magenta using kitty protocol + s.nextSlice("\x1b]21;5=rgb:ff/00/ff\x1b\\"); + try testing.expectEqual(@as(u8, 0xff), t.colors.palette.current[5].r); + try testing.expectEqual(@as(u8, 0x00), t.colors.palette.current[5].g); + try testing.expectEqual(@as(u8, 0xff), t.colors.palette.current[5].b); + try testing.expect(t.colors.palette.mask.isSet(5)); + try testing.expect(t.flags.dirty.palette); +} + +test "kitty color protocol reset palette" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set and then reset palette color + const original = t.colors.palette.original[7]; + s.nextSlice("\x1b]21;7=rgb:aa/bb/cc\x1b\\"); + try testing.expect(t.colors.palette.mask.isSet(7)); + + s.nextSlice("\x1b]21;7=\x1b\\"); + try testing.expectEqual(original, t.colors.palette.current[7]); + try testing.expect(!t.colors.palette.mask.isSet(7)); +} + +test "kitty color protocol set foreground" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set foreground using kitty protocol + s.nextSlice("\x1b]21;foreground=rgb:12/34/56\x1b\\"); + const fg = t.colors.foreground.get().?; + try testing.expectEqual(@as(u8, 0x12), fg.r); + try testing.expectEqual(@as(u8, 0x34), fg.g); + try testing.expectEqual(@as(u8, 0x56), fg.b); +} + +test "kitty color protocol set background" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set background using kitty protocol + s.nextSlice("\x1b]21;background=rgb:78/9a/bc\x1b\\"); + const bg = t.colors.background.get().?; + try testing.expectEqual(@as(u8, 0x78), bg.r); + try testing.expectEqual(@as(u8, 0x9a), bg.g); + try testing.expectEqual(@as(u8, 0xbc), bg.b); +} + +test "kitty color protocol set cursor" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set cursor using kitty protocol + s.nextSlice("\x1b]21;cursor=rgb:de/f0/12\x1b\\"); + const cursor = t.colors.cursor.get().?; + try testing.expectEqual(@as(u8, 0xde), cursor.r); + try testing.expectEqual(@as(u8, 0xf0), cursor.g); + try testing.expectEqual(@as(u8, 0x12), cursor.b); +} + +test "kitty color protocol reset foreground" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set and reset foreground + s.nextSlice("\x1b]21;foreground=rgb:11/22/33\x1b\\"); + try testing.expect(t.colors.foreground.get() != null); + + s.nextSlice("\x1b]21;foreground=\x1b\\"); + // After reset, should be unset + try testing.expect(t.colors.foreground.get() == null); +} + +test "palette dirty flag set on color change" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Clear dirty flag + t.flags.dirty.palette = false; + + // Setting palette color should set dirty flag + s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); + try testing.expect(t.flags.dirty.palette); + + // Clear and test reset + t.flags.dirty.palette = false; + s.nextSlice("\x1b]104;0\x1b\\"); + try testing.expect(t.flags.dirty.palette); + + // Clear and test kitty protocol + t.flags.dirty.palette = false; + s.nextSlice("\x1b]21;1=rgb:00/ff/00\x1b\\"); + try testing.expect(t.flags.dirty.palette); +} + +test "semantic prompt fresh line" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + s.nextSlice("Hello"); + s.nextSlice("\x1b]133;L\x07"); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); +} + +test "semantic prompt fresh line new prompt" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Write some text and then send OSC 133;A (fresh_line_new_prompt) + s.nextSlice("Hello"); + s.nextSlice("\x1b]133;A\x07"); + + // Should do a fresh line (carriage return + index) + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + + // Should set cursor semantic_content to prompt + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); + + // Test with redraw option + s.nextSlice("prompt$ "); + s.nextSlice("\x1b]133;A;redraw=1\x07"); + try testing.expect(t.flags.shell_redraws_prompt == .true); +} + +test "semantic prompt end of input, then start output" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Write some text and then send OSC 133;A (fresh_line_new_prompt) + s.nextSlice("Hello"); + s.nextSlice("\x1b]133;A\x07"); + s.nextSlice("prompt$ "); + s.nextSlice("\x1b]133;B\x07"); + try testing.expectEqual(.input, t.screens.active.cursor.semantic_content); + s.nextSlice("\x1b]133;C\x07"); + try testing.expectEqual(.output, t.screens.active.cursor.semantic_content); +} + +test "semantic prompt prompt_start" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Write some text + s.nextSlice("Hello"); + + // OSC 133;P marks the start of a prompt (without fresh line behavior) + s.nextSlice("\x1b]133;P\x07"); + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); +} + +test "semantic prompt new_command" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Write some text + s.nextSlice("Hello"); + s.nextSlice("\x1b]133;N\x07"); + + // Should behave like fresh_line_new_prompt - cursor moves to column 0 + // on next line since we had content + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); +} + +test "semantic prompt new_command at column zero" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // OSC 133;N when already at column 0 should stay on same line + s.nextSlice("\x1b]133;N\x07"); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); +} + +test "semantic prompt end_prompt_start_input_terminate_eol clears on linefeed" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set input terminated by EOL + s.nextSlice("\x1b]133;I\x07"); + try testing.expectEqual(.input, t.screens.active.cursor.semantic_content); + + // Linefeed should reset semantic content to output + s.nextSlice("\n"); + try testing.expectEqual(.output, t.screens.active.cursor.semantic_content); +} + +test "bell effect callback" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + // Test bell with null callback (default readonly effects) doesn't crash + { + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + s.nextSlice("\x07"); + + // Terminal should still be functional after bell + s.nextSlice("AfterBell"); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AfterBell", str); + } + + t.fullReset(); + + // Test bell with a callback + { + const S = struct { + var bell_count: usize = 0; + fn bell(_: *Handler) void { + bell_count += 1; + } + }; + S.bell_count = 0; + + var handler: Handler = .init(&t); + handler.effects.bell = &S.bell; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + s.nextSlice("\x07"); + try testing.expectEqual(@as(usize, 1), S.bell_count); + + s.nextSlice("\x07\x07"); + try testing.expectEqual(@as(usize, 3), S.bell_count); + } +} + +test "request mode DECRQM with write_pty callback" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + // Without callback, DECRQM should not crash + { + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // DECRQM for mode 7 (wraparound) — should be silently ignored + s.nextSlice("\x1B[?7$p"); + } + + t.fullReset(); + + // With callback, DECRQM should produce a response + { + const S = struct { + var last_response: ?[:0]const u8 = null; + fn writePty(_: *Handler, data: [:0]const u8) void { + if (last_response) |old| testing.allocator.free(old); + last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM"); + } + }; + S.last_response = null; + defer if (S.last_response) |old| testing.allocator.free(old); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Wraparound mode (7) is set by default + s.nextSlice("\x1B[?7$p"); + try testing.expectEqualStrings("\x1B[?7;1$y", S.last_response.?); + + // Disable wraparound and query again + s.nextSlice("\x1B[?7l"); + s.nextSlice("\x1B[?7$p"); + try testing.expectEqualStrings("\x1B[?7;2$y", S.last_response.?); + + // Query an unknown mode + s.nextSlice("\x1B[?9999$p"); + try testing.expectEqualStrings("\x1B[?9999;0$y", S.last_response.?); + } +} + +test "stream: CSI W with intermediate but no params" { + // Regression test from AFL++ crash. CSI ? W without + // parameters caused an out-of-bounds access on input.params[0]. + var t: Terminal = try .init(testing.allocator, .{ + .cols = 80, + .rows = 24, + .max_scrollback = 100, + }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + s.nextSlice("\x1b[?W"); +} + +test "window_title effect is called" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var title_changed_count: usize = 0; + fn titleChanged(_: *Handler) void { + title_changed_count += 1; + } + }; + S.title_changed_count = 0; + + var handler: Handler = .init(&t); + handler.effects.title_changed = &S.titleChanged; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Set window title via OSC 2 + s.nextSlice("\x1b]2;Hello World\x1b\\"); + try testing.expectEqualStrings("Hello World", t.getTitle().?); + try testing.expectEqual(@as(usize, 1), S.title_changed_count); +} + +test "window_title effect not called without callback" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Should not crash when no callback is set + s.nextSlice("\x1b]2;Hello World\x1b\\"); + + // Title should still be set on terminal state + try testing.expectEqualStrings("Hello World", t.getTitle().?); + + // Terminal should still be functional + s.nextSlice("Test"); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Test", str); +} + +test "window_title effect with empty title" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var title_changed_count: usize = 0; + fn titleChanged(_: *Handler) void { + title_changed_count += 1; + } + }; + S.title_changed_count = 0; + + var handler: Handler = .init(&t); + handler.effects.title_changed = &S.titleChanged; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Set empty window title + s.nextSlice("\x1b]2;\x1b\\"); + try testing.expect(t.getTitle() == null); + try testing.expectEqual(@as(usize, 1), S.title_changed_count); +} + +test "kitty_keyboard_query" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var written: ?[:0]const u8 = null; + fn writePty(_: *Handler, data: [:0]const u8) void { + written = data; + } + }; + S.written = null; + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Default kitty keyboard flags should be 0 + s.nextSlice("\x1b[?u"); + try testing.expectEqualStrings("\x1b[?0u", S.written.?); + + // Push kitty keyboard mode with flags and query again + S.written = null; + s.nextSlice("\x1b[>1u"); // push with disambiguate flag + s.nextSlice("\x1b[?u"); + try testing.expectEqualStrings("\x1b[?1u", S.written.?); +} + +test "xtversion default" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var written: ?[:0]const u8 = null; + fn writePty(_: *Handler, data: [:0]const u8) void { + written = data; + } + }; + S.written = null; + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Without xtversion effect set, should report "libghostty" + s.nextSlice("\x1b[>0q"); + try testing.expectEqualStrings("\x1bP>|libghostty\x1b\\", S.written.?); +} + +test "xtversion with effect" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var written: ?[:0]const u8 = null; + fn writePty(_: *Handler, data: [:0]const u8) void { + written = data; + } + fn xtversion(_: *Handler) []const u8 { + return "ghostty 1.2.3"; + } + }; + S.written = null; + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.xtversion = &S.xtversion; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + s.nextSlice("\x1b[>0q"); + try testing.expectEqualStrings("\x1bP>|ghostty 1.2.3\x1b\\", S.written.?); +} + +test "xtversion with empty string effect" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var written: ?[:0]const u8 = null; + fn writePty(_: *Handler, data: [:0]const u8) void { + written = data; + } + fn xtversion(_: *Handler) []const u8 { + return ""; + } + }; + S.written = null; + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.xtversion = &S.xtversion; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Empty string from effect should fall back to "libghostty" + s.nextSlice("\x1b[>0q"); + try testing.expectEqualStrings("\x1bP>|libghostty\x1b\\", S.written.?); +} + +test "size report csi_14_t with effect" { + var t: Terminal = try .init(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"); + } + fn getSize(_: *Handler) ?size_report.Size { + return .{ .rows = 24, .columns = 80, .cell_width = 9, .cell_height = 18 }; + } + }; + S.written = null; + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.size = &S.getSize; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // CSI 14 t - report text area size in pixels + s.nextSlice("\x1b[14t"); + defer testing.allocator.free(S.written.?); + try testing.expectEqualStrings("\x1b[4;432;720t", S.written.?); +} + +test "size report csi_16_t with effect" { + var t: Terminal = try .init(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"); + } + fn getSize(_: *Handler) ?size_report.Size { + return .{ .rows = 24, .columns = 80, .cell_width = 9, .cell_height = 18 }; + } + }; + S.written = null; + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.size = &S.getSize; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // CSI 16 t - report cell size in pixels + s.nextSlice("\x1b[16t"); + defer testing.allocator.free(S.written.?); + try testing.expectEqualStrings("\x1b[6;18;9t", S.written.?); +} + +test "size report csi_18_t with effect" { + var t: Terminal = try .init(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"); + } + fn getSize(_: *Handler) ?size_report.Size { + return .{ .rows = 24, .columns = 80, .cell_width = 9, .cell_height = 18 }; + } + }; + S.written = null; + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.size = &S.getSize; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // CSI 18 t - report text area size in characters + s.nextSlice("\x1b[18t"); + defer testing.allocator.free(S.written.?); + try testing.expectEqualStrings("\x1b[8;24;80t", S.written.?); +} + +test "size report no effect callback" { + var t: Terminal = try .init(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; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Without size effect, size reports should be silently ignored + s.nextSlice("\x1b[14t"); + try testing.expect(S.written == null); +} + +test "size report csi_21_t title" { + var t: Terminal = try .init(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; + + var s: Stream = .initAlloc(testing.allocator, 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"); + defer testing.allocator.free(S.written.?); + try testing.expectEqualStrings("\x1b]lMy Title\x1b\\", S.written.?); +} + +test "enquiry no effect" { + var t: Terminal = try .init(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; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // ENQ without enquiry effect should not write anything + s.nextSlice("\x05"); + try testing.expect(S.written == null); +} + +test "enquiry with effect" { + var t: Terminal = try .init(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"); + } + fn enquiry(_: *Handler) []const u8 { + return "ghostty"; + } + }; + S.written = null; + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.enquiry = &S.enquiry; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + s.nextSlice("\x05"); + defer testing.allocator.free(S.written.?); + try testing.expectEqualStrings("ghostty", S.written.?); +} + +test "enquiry with empty response" { + var t: Terminal = try .init(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"); + } + fn enquiry(_: *Handler) []const u8 { + return ""; + } + }; + S.written = null; + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.enquiry = &S.enquiry; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Empty enquiry response should not write anything + s.nextSlice("\x05"); + try testing.expect(S.written == null); +} + +test "device status: operating status" { + var t: Terminal = try .init(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 { + if (written) |old| testing.allocator.free(old); + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + }; + S.written = null; + defer if (S.written) |old| testing.allocator.free(old); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // CSI 5 n — operating status report + s.nextSlice("\x1B[5n"); + try testing.expectEqualStrings("\x1B[0n", S.written.?); +} + +test "device status: cursor position" { + var t: Terminal = try .init(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 { + if (written) |old| testing.allocator.free(old); + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + }; + S.written = null; + defer if (S.written) |old| testing.allocator.free(old); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Default position is 0,0 — reported as 1,1 + s.nextSlice("\x1B[6n"); + try testing.expectEqualStrings("\x1B[1;1R", S.written.?); + + // Move cursor to row 5, col 10 + s.nextSlice("\x1B[5;10H"); + s.nextSlice("\x1B[6n"); + try testing.expectEqualStrings("\x1B[5;10R", S.written.?); +} + +test "device status: cursor position with origin mode" { + var t: Terminal = try .init(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 { + if (written) |old| testing.allocator.free(old); + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + }; + S.written = null; + defer if (S.written) |old| testing.allocator.free(old); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Set scroll region rows 5-20 + s.nextSlice("\x1B[5;20r"); + // Enable origin mode + s.nextSlice("\x1B[?6h"); + // Move to row 3, col 5 within the region + s.nextSlice("\x1B[3;5H"); + // Query cursor position + s.nextSlice("\x1B[6n"); + // Should report position relative to the scroll region + try testing.expectEqualStrings("\x1B[3;5R", S.written.?); +} + +test "device status: color scheme dark" { + var t: Terminal = try .init(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 { + if (written) |old| testing.allocator.free(old); + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + fn colorScheme(_: *Handler) ?device_status.ColorScheme { + return .dark; + } + }; + S.written = null; + defer if (S.written) |old| testing.allocator.free(old); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.color_scheme = &S.colorScheme; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // CSI ? 996 n — color scheme query + s.nextSlice("\x1B[?996n"); + try testing.expectEqualStrings("\x1B[?997;1n", S.written.?); +} + +test "device status: color scheme light" { + var t: Terminal = try .init(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 { + if (written) |old| testing.allocator.free(old); + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + fn colorScheme(_: *Handler) ?device_status.ColorScheme { + return .light; + } + }; + S.written = null; + defer if (S.written) |old| testing.allocator.free(old); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.color_scheme = &S.colorScheme; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // CSI ? 996 n — color scheme query + s.nextSlice("\x1B[?996n"); + try testing.expectEqualStrings("\x1B[?997;2n", S.written.?); +} + +test "device status: color scheme without callback" { + var t: Terminal = try .init(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 { + if (written) |old| testing.allocator.free(old); + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + }; + S.written = null; + defer if (S.written) |old| testing.allocator.free(old); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Without color_scheme effect, query should be silently ignored + s.nextSlice("\x1B[?996n"); + try testing.expect(S.written == null); +} + +test "device status: readonly ignores all" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // All device status queries should be silently ignored without effects + s.nextSlice("\x1B[5n"); + s.nextSlice("\x1B[6n"); + s.nextSlice("\x1B[?996n"); + + // Terminal should still be functional + s.nextSlice("Test"); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Test", str); +} + +test "device attributes: primary DA" { + var t: Terminal = try .init(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 { + if (written) |old| testing.allocator.free(old); + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + fn da(_: *Handler) device_attributes.Attributes { + return .{}; + } + }; + S.written = null; + defer if (S.written) |old| testing.allocator.free(old); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.device_attributes = &S.da; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + s.nextSlice("\x1B[c"); + try testing.expectEqualStrings("\x1b[?62;22c", S.written.?); +} + +test "device attributes: secondary DA" { + var t: Terminal = try .init(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 { + if (written) |old| testing.allocator.free(old); + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + fn da(_: *Handler) device_attributes.Attributes { + return .{}; + } + }; + S.written = null; + defer if (S.written) |old| testing.allocator.free(old); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.device_attributes = &S.da; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + s.nextSlice("\x1B[>c"); + try testing.expectEqualStrings("\x1b[>1;0;0c", S.written.?); +} + +test "device attributes: tertiary DA" { + var t: Terminal = try .init(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 { + if (written) |old| testing.allocator.free(old); + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + fn da(_: *Handler) device_attributes.Attributes { + return .{}; + } + }; + S.written = null; + defer if (S.written) |old| testing.allocator.free(old); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.device_attributes = &S.da; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + s.nextSlice("\x1B[=c"); + try testing.expectEqualStrings("\x1bP!|00000000\x1b\\", S.written.?); +} + +test "device attributes: readonly ignores" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // All DA queries should be silently ignored without effects + s.nextSlice("\x1B[c"); + s.nextSlice("\x1B[>c"); + s.nextSlice("\x1B[=c"); + + // Terminal should still be functional + s.nextSlice("Test"); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Test", str); +} + +test "device attributes: custom response" { + var t: Terminal = try .init(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 { + if (written) |old| testing.allocator.free(old); + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + fn da(_: *Handler) device_attributes.Attributes { + return .{ + .primary = .{ + .conformance_level = .vt420, + .features = &.{ .ansi_color, .clipboard }, + }, + .secondary = .{ + .device_type = .vt420, + .firmware_version = 100, + }, + }; + } + }; + S.written = null; + defer if (S.written) |old| testing.allocator.free(old); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.device_attributes = &S.da; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + s.nextSlice("\x1B[c"); + try testing.expectEqualStrings("\x1b[?64;22;52c", S.written.?); + + s.nextSlice("\x1B[>c"); + try testing.expectEqualStrings("\x1b[>41;100;0c", S.written.?); +} diff --git a/src/terminal/style.zig b/src/terminal/style.zig index 7908beefa..ae9488af8 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -1,6 +1,5 @@ const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; -const configpkg = @import("../config.zig"); const color = @import("color.zig"); const sgr = @import("sgr.zig"); const page = @import("page.zig"); @@ -126,6 +125,13 @@ pub const Style = struct { }; } + /// The color to use for bold text. This avoids a dependency on the + /// config module by using terminal-native color types. + pub const BoldColor = union(enum) { + color: color.RGB, + bright, + }; + pub const Fg = struct { /// The default color to use if the style doesn't specify a /// foreground color and no configuration options override @@ -137,7 +143,7 @@ pub const Style = struct { palette: *const color.Palette, /// If specified, the color to use for bold text. - bold: ?configpkg.BoldColor = null, + bold: ?BoldColor = null, }; /// Returns the fg color for a cell with this style given the palette @@ -155,7 +161,7 @@ pub const Style = struct { if (self.flags.bold) { if (opts.bold) |bold| switch (bold) { .bright => {}, - .color => |v| break :default v.toTerminalRGB(), + .color => |v| break :default v, }; } @@ -178,7 +184,7 @@ pub const Style = struct { .rgb => |rgb| rgb: { if (self.flags.bold and rgb.eql(opts.default)) { if (opts.bold) |bold| switch (bold) { - .color => |v| break :rgb v.toTerminalRGB(), + .color => |v| break :rgb v, .bright => {}, }; } diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig index dbc64b340..3b94eb16b 100644 --- a/src/terminal/tmux/control.zig +++ b/src/terminal/tmux/control.zig @@ -116,19 +116,23 @@ pub const Parser = struct { )) |v| v + 1 else 0; const line = written[idx..]; - if (std.mem.startsWith(u8, line, "%end") or - std.mem.startsWith(u8, line, "%error")) - { - const err = std.mem.startsWith(u8, line, "%error"); - const output = std.mem.trimRight(u8, written[0..idx], "\r\n"); - - // If it is an error then log it. - if (err) log.warn("tmux control mode error={s}", .{output}); + if (parseBlockTerminator(line)) |terminator| { + const output = std.mem.trimRight( + u8, + written[0..idx], + "\r\n", + ); // Important: do not clear buffer since the notification // contains it. self.state = .idle; - return if (err) .{ .block_err = output } else .{ .block_end = output }; + switch (terminator) { + .end => return .{ .block_end = output }, + .err => { + log.warn("tmux control mode error={s}", .{output}); + return .{ .block_err = output }; + }, + } } // Didn't end the block, continue accumulating. @@ -144,6 +148,41 @@ pub const Parser = struct { const ParseError = error{RegexError}; + const BlockTerminator = enum { end, err }; + + /// Block payload is raw data, so a line only terminates a block if it + /// exactly matches tmux's `%end`/`%error` guard-line shape. + fn parseBlockTerminator(line_raw: []const u8) ?BlockTerminator { + var line = line_raw; + if (line.len > 0 and line[line.len - 1] == '\r') { + line = line[0 .. line.len - 1]; + } + + var fields = std.mem.tokenizeScalar(u8, line, ' '); + const cmd = fields.next() orelse return null; + const terminator: BlockTerminator = if (std.mem.eql(u8, cmd, "%end")) + .end + else if (std.mem.eql(u8, cmd, "%error")) + .err + else + return null; + + const time = fields.next() orelse return null; + const command_id = fields.next() orelse return null; + const flags = fields.next() orelse return null; + const extra = fields.next(); + + // In the future, we should compare these to the %begin block + // because the tmux source guarantees that these always match and + // that is a more robust way to match. + _ = std.fmt.parseInt(usize, time, 10) catch return null; + _ = std.fmt.parseInt(usize, command_id, 10) catch return null; + _ = std.fmt.parseInt(usize, flags, 10) catch return null; + if (extra != null) return null; + + return terminator; + } + fn parseNotification(self: *Parser) ParseError!?Notification { assert(self.state == .notification); @@ -597,6 +636,81 @@ test "tmux begin/end data" { try testing.expectEqualStrings("hello\nworld", n.block_end); } +test "tmux block payload may start with %end" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end not really\n") |byte| try testing.expect(try c.put(byte) == null); + for ("hello\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1 1 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_end); + try testing.expectEqualStrings("%end not really\nhello", n.block_end); +} + +test "tmux block payload may start with %error" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%error not really\n") |byte| try testing.expect(try c.put(byte) == null); + for ("hello\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1 1 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_end); + try testing.expectEqualStrings("%error not really\nhello", n.block_end); +} + +test "tmux block may terminate with real %error after misleading payload" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%error not really\n") |byte| try testing.expect(try c.put(byte) == null); + for ("hello\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%error 1 1 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_err); + try testing.expectEqualStrings("%error not really\nhello", n.block_err); +} + +test "tmux block terminator requires exact token count" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1 1 1 trailing\n") |byte| try testing.expect(try c.put(byte) == null); + for ("hello\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1 1 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_end); + try testing.expectEqualStrings("%end 1 1 1 trailing\nhello", n.block_end); +} + +test "tmux block terminator requires numeric metadata" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end foo bar baz\n") |byte| try testing.expect(try c.put(byte) == null); + for ("hello\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1 1 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_end); + try testing.expectEqualStrings("%end foo bar baz\nhello", n.block_end); +} + test "tmux output" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 62a0f1d00..585c95403 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -1053,10 +1053,7 @@ pub const Viewer = struct { // correct but we'll get the active contents soon. var stream = t.vtStream(); defer stream.deinit(); - stream.nextSlice(content) catch |err| { - log.info("failed to process pane history for pane id={}: {}", .{ id, err }); - return err; - }; + stream.nextSlice(content); // Populate the active area to be empty since this is only history. // We'll fill it with blanks and move the cursor to the top-left. @@ -1097,10 +1094,7 @@ pub const Viewer = struct { var stream = t.vtStream(); defer stream.deinit(); - stream.nextSlice(content) catch |err| { - log.info("failed to process pane visible for pane id={}: {}", .{ id, err }); - return err; - }; + stream.nextSlice(content); } fn receivedOutput( @@ -1117,10 +1111,7 @@ pub const Viewer = struct { var stream = t.vtStream(); defer stream.deinit(); - stream.nextSlice(data) catch |err| { - log.info("failed to process output for pane id={}: {}", .{ id, err }); - return err; - }; + stream.nextSlice(data); } fn initLayout( diff --git a/src/terminal/x11_color.zig b/src/terminal/x11_color.zig index 95dcb76db..97f3a7ce4 100644 --- a/src/terminal/x11_color.zig +++ b/src/terminal/x11_color.zig @@ -25,12 +25,18 @@ fn colorMap() ColorMap { // of our unit tests will catch it. var iter = std.mem.splitScalar(u8, data, '\n'); var i: usize = 0; - while (iter.next()) |line| { + while (iter.next()) |raw_line| { + // Trim \r so this works with both LF and CRLF line endings, + // since git may convert rgb.txt to CRLF on Windows checkouts. + const line = if (raw_line.len > 0 and raw_line[raw_line.len - 1] == '\r') + raw_line[0 .. raw_line.len - 1] + else + raw_line; if (line.len == 0) continue; const r = try std.fmt.parseInt(u8, std.mem.trim(u8, line[0..3], " "), 10); const g = try std.fmt.parseInt(u8, std.mem.trim(u8, line[4..7], " "), 10); const b = try std.fmt.parseInt(u8, std.mem.trim(u8, line[8..11], " "), 10); - const name = std.mem.trim(u8, line[12..], " \t\n"); + const name = std.mem.trim(u8, line[12..], " \t"); kvs[i] = .{ name, .{ .r = r, .g = g, .b = b } }; i += 1; } diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index af4df3fef..0f35b5787 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -27,6 +27,7 @@ const Pty = ptypkg.Pty; const EnvMap = std.process.EnvMap; const PasswdEntry = internal_os.passwd.Entry; const windows = internal_os.windows; +const ProcessInfo = @import("../pty.zig").ProcessInfo; const log = std.log.scoped(.io_exec); @@ -1226,6 +1227,14 @@ const Subprocess = struct { fn killCommandFlatpak(command: *FlatpakHostCommand) !void { try command.signal(c.SIGHUP, true); } + + /// Get information about the process(es) running within the subprocess. + /// Returns `null` if there was an error getting the information or the + /// information is not available on a particular platform. + pub fn getProcessInfo(self: *Subprocess, comptime info: ProcessInfo) ?ProcessInfo.Type(info) { + const pty = &(self.pty orelse return null); + return pty.getProcessInfo(info); + } }; /// The read thread sits in a loop doing the following pseudo code: @@ -1580,6 +1589,13 @@ fn execCommand( }; } +/// Get information about the process(es) running within the backend. Returns +/// `null` if there was an error getting the information or the information is +/// not available on a particular platform. +pub fn getProcessInfo(self: *Exec, comptime info: ProcessInfo) ?ProcessInfo.Type(info) { + return self.subprocess.getProcessInfo(info); +} + test "execCommand darwin: shell command" { if (comptime !builtin.os.tag.isDarwin()) return error.SkipZigTest; diff --git a/src/termio/Options.zig b/src/termio/Options.zig index f41709f4a..a6bf8c4d4 100644 --- a/src/termio/Options.zig +++ b/src/termio/Options.zig @@ -30,7 +30,7 @@ mailbox: termio.Mailbox, /// terminal implementation.) renderer_state: *renderer.State, -/// A handle to wake up the renderer. This hints to the renderer that that +/// A handle to wake up the renderer. This hints to the renderer that /// a repaint should happen. renderer_wakeup: xev.Async, diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index dcd0d8cf7..f05379077 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -19,6 +19,7 @@ const apprt = @import("../apprt.zig"); const internal_os = @import("../os/main.zig"); const windows = internal_os.windows; const configpkg = @import("../config.zig"); +const ProcessInfo = @import("../pty.zig").ProcessInfo; const log = std.log.scoped(.io_exec); @@ -42,7 +43,7 @@ terminal: terminalpkg.Terminal, /// The shared render state renderer_state: *renderer.State, -/// A handle to wake up the renderer. This hints to the renderer that that +/// A handle to wake up the renderer. This hints to the renderer that /// a repaint should happen. renderer_wakeup: xev.Async, @@ -391,8 +392,8 @@ pub fn threadExit(self: *Termio, data: *ThreadData) void { self.backend.threadExit(data); } -/// Send a message to the the mailbox. Depending on the mailbox type in -/// use this may process now or it may just enqueue and process later. +/// Send a message to the mailbox. Depending on the mailbox type in use +/// this may process now or it may just enqueue and process later. /// /// This will also notify the mailbox thread to process the message. If /// you're sending a lot of messages, it may be more efficient to use @@ -526,48 +527,24 @@ pub fn sizeReport(self: *Termio, td: *ThreadData, style: termio.Message.SizeRepo fn sizeReportLocked(self: *Termio, td: *ThreadData, style: termio.Message.SizeReport) !void { const grid_size = self.size.grid(); + const report_size: terminalpkg.size_report.Size = .{ + .rows = grid_size.rows, + .columns = grid_size.columns, + .cell_width = self.size.cell.width, + .cell_height = self.size.cell.height, + }; // 1024 bytes should be enough for size report since report // in columns and pixels. var buf: [1024]u8 = undefined; - const message = switch (style) { - .mode_2048 => try std.fmt.bufPrint( - &buf, - "\x1B[48;{};{};{};{}t", - .{ - grid_size.rows, - grid_size.columns, - grid_size.rows * self.size.cell.height, - grid_size.columns * self.size.cell.width, - }, - ), - .csi_14_t => try std.fmt.bufPrint( - &buf, - "\x1b[4;{};{}t", - .{ - grid_size.rows * self.size.cell.height, - grid_size.columns * self.size.cell.width, - }, - ), - .csi_16_t => try std.fmt.bufPrint( - &buf, - "\x1b[6;{};{}t", - .{ - self.size.cell.height, - self.size.cell.width, - }, - ), - .csi_18_t => try std.fmt.bufPrint( - &buf, - "\x1b[8;{};{}t", - .{ - grid_size.rows, - grid_size.columns, - }, - ), - }; + var writer: std.Io.Writer = .fixed(&buf); + try terminalpkg.size_report.encode( + &writer, + style, + report_size, + ); - try self.queueWrite(td, message, false); + try self.queueWrite(td, writer.buffered(), false); } /// Reset the synchronized output mode. This is usually called by timer @@ -609,7 +586,7 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void { // Clear all Kitty graphics state for this screen. This copies // Kitty's behavior when Cmd+K deletes all Kitty graphics. I // didn't spend time researching whether it only deletes Kitty - // graphics that are placed baove the cursor or if it deletes + // graphics that are placed above the cursor or if it deletes // all of them. We delete all of them for now but if this behavior // isn't fully correct we should fix this later. self.terminal.screens.active.kitty_images.delete( @@ -664,8 +641,13 @@ pub fn focusGained(self: *Termio, td: *ThreadData, focused: bool) !void { // If we have focus events enabled, we send the focus event. if (focus_event) { - const seq = if (focused) "\x1b[I" else "\x1b[O"; - try self.queueWrite(td, seq, false); + var buf: [terminalpkg.focus.max_encode_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + terminalpkg.focus.encode(&writer, if (focused) .gained else .lost) catch |err| { + log.err("error encoding focus event err={}", .{err}); + return; + }; + try self.queueWrite(td, writer.buffered(), false); } // We always notify our backend of focus changes. @@ -721,12 +703,10 @@ fn processOutputLocked(self: *Termio, buf: []const u8) void { log.err("error recording pty read in inspector err={}", .{err}); }; - self.terminal_stream.next(byte) catch |err| - log.err("error processing terminal data: {}", .{err}); + self.terminal_stream.next(byte); } } else { - self.terminal_stream.nextSlice(buf) catch |err| - log.err("error processing terminal data: {}", .{err}); + self.terminal_stream.nextSlice(buf); } // If our stream handling caused messages to be sent to the mailbox @@ -785,3 +765,10 @@ pub const ThreadData = struct { self.* = undefined; } }; + +/// Get information about the process(es) attached to the backend. Returns +/// `null` if there was an error getting the information or the information is +/// not available on a particular platform. +pub fn getProcessInfo(self: *Termio, comptime info: ProcessInfo) ?ProcessInfo.Type(info) { + return self.backend.getProcessInfo(info); +} diff --git a/src/termio/backend.zig b/src/termio/backend.zig index ae0e2004f..c29009acb 100644 --- a/src/termio/backend.zig +++ b/src/termio/backend.zig @@ -4,6 +4,7 @@ const posix = std.posix; const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); const termio = @import("../termio.zig"); +const ProcessInfo = @import("../pty.zig").ProcessInfo; // 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. @@ -100,6 +101,15 @@ pub const Backend = union(Kind) { ), } } + + /// Get information about the process(es) attached to the backend. Returns + /// `null` if there was an error getting the information or the information + /// is not available on a particular platform. + pub fn getProcessInfo(self: *Backend, comptime info: ProcessInfo) ?ProcessInfo.Type(info) { + return switch (self.*) { + .exec => |*exec| exec.getProcessInfo(info), + }; + } }; /// Termio thread data. See termio.ThreadData for docs. diff --git a/src/termio/message.zig b/src/termio/message.zig index d7a59bf5e..4ee7f245e 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -93,13 +93,8 @@ pub const Message = union(enum) { }; } - /// The types of size reports that we support - pub const SizeReport = enum { - mode_2048, - csi_14_t, - csi_16_t, - csi_18_t, - }; + /// The types of size reports that we support. + pub const SizeReport = terminal.size_report.Style; }; test { diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index e5b9eab10..2dd09ee29 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -670,6 +670,8 @@ fn setupXdgDataDirs( } test "xdg: empty XDG_DATA_DIRS" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); @@ -696,6 +698,8 @@ test "xdg: empty XDG_DATA_DIRS" { } test "xdg: existing XDG_DATA_DIRS" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 8c1b5b8ab..fb3a6b3ff 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -36,7 +36,7 @@ pub const StreamHandler = struct { /// The mailbox for notifying the renderer of things. renderer_mailbox: *renderer.Thread.Mailbox, - /// A handle to wake up the renderer. This hints to the renderer that that + /// A handle to wake up the renderer. This hints to the renderer that /// a repaint should happen. renderer_wakeup: xev.Async, @@ -176,6 +176,16 @@ pub const StreamHandler = struct { self: *StreamHandler, comptime action: Stream.Action.Tag, value: Stream.Action.Value(action), + ) void { + self.vtFallible(action, value) catch |err| { + log.warn("error handling VT action action={} err={}", .{ action, err }); + }; + } + + inline fn vtFallible( + self: *StreamHandler, + comptime action: Stream.Action.Tag, + value: Stream.Action.Value(action), ) !void { // The branch hints here are based on real world data // which indicates that the most common actions are: @@ -600,35 +610,24 @@ pub const StreamHandler = struct { } fn requestMode(self: *StreamHandler, mode: terminal.Mode) !void { - const tag: terminal.modes.ModeTag = @bitCast(@intFromEnum(mode)); - const code: u8 = if (self.terminal.modes.get(mode)) 1 else 2; - - var msg: termio.Message = .{ .write_small = .{} }; - const resp = try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B[{s}{};{}$y", - .{ - if (tag.ansi) "" else "?", - tag.value, - code, - }, - ); - msg.write_small.len = @intCast(resp.len); - self.messageWriter(msg); + self.sendModeReport(self.terminal.modes.getReport(.fromMode(mode))); } fn requestModeUnknown(self: *StreamHandler, mode_raw: u16, ansi: bool) !void { - var msg: termio.Message = .{ .write_small = .{} }; - const resp = try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B[{s}{};0$y", - .{ - if (ansi) "" else "?", - mode_raw, - }, - ); - msg.write_small.len = @intCast(resp.len); - self.messageWriter(msg); + self.sendModeReport(self.terminal.modes.getReport(.{ .value = @truncate(mode_raw), .ansi = ansi })); + } + + fn sendModeReport(self: *StreamHandler, report: terminal.modes.Report) void { + var data: termio.Message.WriteReq.Small.Array = undefined; + var writer: std.Io.Writer = .fixed(&data); + report.encode(&writer) catch |err| { + log.err("error encoding mode report err={}", .{err}); + return; + }; + self.messageWriter(.{ .write_small = .{ + .data = data, + .len = @intCast(writer.buffered().len), + } }); } pub fn setMode(self: *StreamHandler, mode: terminal.Mode, enabled: bool) !void { @@ -1000,6 +999,12 @@ pub const StreamHandler = struct { return; } + // Set the title on the terminal state. We ignore any errors since + // we can continue to operate just fine without it. + self.terminal.setTitle(title) catch |err| { + log.warn("error setting title in terminal state: {}", .{err}); + }; + @memcpy(buf[0..title.len], title); buf[title.len] = 0; @@ -1028,7 +1033,7 @@ pub const StreamHandler = struct { self: *StreamHandler, shape: terminal.MouseShape, ) !void { - // Avoid changing the shape it it is already set to avoid excess + // Avoid changing the shape if it is already set to avoid excess // cross-thread messaging. if (self.terminal.mouse_shape == shape) return; diff --git a/test/fuzz-libghostty/src/fuzz_stream.zig b/test/fuzz-libghostty/src/fuzz_stream.zig index 17f63766f..75c6fd425 100644 --- a/test/fuzz-libghostty/src/fuzz_stream.zig +++ b/test/fuzz-libghostty/src/fuzz_stream.zig @@ -2,7 +2,7 @@ const std = @import("std"); const ghostty_vt = @import("ghostty-vt"); const mem = @import("mem.zig"); const Terminal = ghostty_vt.Terminal; -const ReadonlyStream = ghostty_vt.ReadonlyStream; +const TerminalStream = ghostty_vt.TerminalStream; /// Use a single global allocator for simplicity and to avoid heap /// allocation overhead in the fuzzer. The allocator is backed by a fixed @@ -33,7 +33,7 @@ pub export fn zig_fuzz_test( }) catch return; defer t.deinit(alloc); - var stream: ReadonlyStream = t.vtStream(); + var stream: TerminalStream = t.vtStream(); defer stream.deinit(); // Use the first byte to decide between the scalar and slice paths @@ -43,11 +43,9 @@ pub export fn zig_fuzz_test( if (mode & 1 == 0) { // Slice path — exercises SIMD fast-path if enabled - stream.nextSlice(data) catch |err| - std.debug.panic("nextSlice: {}", .{err}); + stream.nextSlice(data); } else { // Scalar path — exercises byte-at-a-time UTF-8 decoding - for (data) |byte| _ = stream.next(byte) catch |err| - std.debug.panic("next: {}", .{err}); + for (data) |byte| stream.next(byte); } } diff --git a/typos.toml b/typos.toml index 6cdf04dbb..408d58f73 100644 --- a/typos.toml +++ b/typos.toml @@ -46,6 +46,9 @@ extend-ignore-re = [ "\"hel\\\\x", # Ignore long hex-like IDs such as 815E26BA2EF1E00F005C67B1 "[0-9A-F]{12,}", + # Ignore Apple four char codes + 'code="[A-Za-z]{4,8}"', + '"[A-Za-z]{4}"\.fourCharCode', ] [default.extend-words]