diff --git a/.agents/commands/gh-issue b/.agents/commands/gh-issue deleted file mode 100755 index de2f3733509..00000000000 --- 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 00000000000..dedadbe5e87 --- /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 9158b397935..6ab2e8bf481 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,47 @@ +#-------------------------------------------------------------------- +# Line endings +#-------------------------------------------------------------------- +# Source code - always LF +*.zig text eol=lf +*.c text eol=lf +*.h text eol=lf +*.cpp text eol=lf +*.m text eol=lf +*.swift text eol=lf +*.py text eol=lf +*.sh text eol=lf +*.glsl text eol=lf +*.blp text eol=lf + +# Config/build files - always LF +*.zon text eol=lf +*.nix text eol=lf +*.md text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.toml text eol=lf +CMakeLists.txt text eol=lf +*.cmake text eol=lf +Makefile text eol=lf + +# Text data files - always LF (embedded in Zig, parsed with \n split) +*.txt text eol=lf + +# Windows resource files - preserve as-is (native Windows tooling) +*.rc -text +*.manifest -text + +# Binary files +*.png binary +*.ico binary +*.icns binary +*.ttf binary +*.otf binary + +#-------------------------------------------------------------------- +# Linguist +#-------------------------------------------------------------------- build.zig.zon.nix linguist-generated=true build.zig.zon.txt linguist-generated=true build.zig.zon.json linguist-generated=true diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index 7bb39faf2c8..da1e665309d 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 }} @@ -41,7 +41,7 @@ jobs: mkdir dist tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - - uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6 + - uses: flatpak/flatpak-github-actions/flatpak-builder@401fe28a8384095fc1531b9d320b292f0ee45adb # v6.7 with: bundle: com.mitchellh.ghostty manifest-path: dist/flatpak/com.mitchellh.ghostty.yml diff --git a/.github/workflows/milestone.yml b/.github/workflows/milestone.yml index 33a074159e9..34e7652a7b9 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 22c088b50e1..5eaf95aa9e6 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -8,7 +8,7 @@ concurrency: jobs: required: name: "Required Checks: Nix" - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-sm needs: - check-zig-cache-hash steps: @@ -32,18 +32,25 @@ jobs: exit 1 check-zig-cache-hash: - runs-on: ubuntu-24.04 + if: github.repository == 'ghostty-org/ghostty' + runs-on: namespace-profile-ghostty-sm env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + 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 - name: Setup Nix - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 f7d4a7b6e50..984c2bcdd07 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@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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,13 +147,13 @@ 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 }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_26.2.app + run: sudo xcode-select -s /Applications/Xcode_26.3.app - name: Xcode Version run: xcodebuild -version @@ -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 a2d8c10786b..8b1cbb4c4ee 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@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -163,7 +163,7 @@ jobs: github.ref_name == 'main' ) ) - runs-on: namespace-profile-ghostty-md + runs-on: namespace-profile-ghostty-sm env: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache @@ -175,10 +175,10 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 @@ -206,6 +206,165 @@ jobs: ghostty-source.tar.gz.minisig token: ${{ secrets.GH_RELEASE_TOKEN }} + source-tarball-lib-vt: + needs: [setup] + if: | + needs.setup.outputs.should_skip != 'true' && + ( + github.event_name == 'workflow_dispatch' || + ( + github.repository_owner == 'ghostty-org' && + github.ref_name == 'main' + ) + ) + runs-on: namespace-profile-ghostty-sm + env: + GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + path: | + /nix + /zig + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + - name: Create Tarball + run: | + rm -rf zig-out/dist + nix develop -c zig build dist -Demit-lib-vt=true + cp zig-out/dist/*.tar.gz libghostty-vt-source.tar.gz + + - name: Sign Tarball + run: | + echo -n "${{ secrets.MINISIGN_KEY }}" > minisign.key + echo -n "${{ secrets.MINISIGN_PASSWORD }}" > minisign.password + nix develop -c minisign -S -m libghostty-vt-source.tar.gz -s minisign.key < minisign.password + + - name: Update Release + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + with: + name: 'Ghostty Tip ("Nightly")' + prerelease: true + tag_name: tip + target_commitish: ${{ github.sha }} + files: | + libghostty-vt-source.tar.gz + libghostty-vt-source.tar.gz.minisig + token: ${{ secrets.GH_RELEASE_TOKEN }} + + - name: Prep R2 Storage + run: | + mkdir -p blob/${GHOSTTY_COMMIT_LONG} + cp libghostty-vt-source.tar.gz blob/${GHOSTTY_COMMIT_LONG}/libghostty-vt-source.tar.gz + - name: Upload to R2 + uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4 + with: + r2-account-id: ${{ secrets.CF_R2_TIP_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.CF_R2_TIP_AWS_KEY }} + r2-secret-access-key: ${{ secrets.CF_R2_TIP_SECRET_KEY }} + r2-bucket: ghostty-tip + source-dir: blob + destination-dir: ./ + + - name: Echo Release URLs + run: | + echo "Release URLs:" + echo " Source Tarball: https://tip.files.ghostty.org/${GHOSTTY_COMMIT_LONG}/libghostty-vt-source.tar.gz" + + build-lib-vt-xcframework: + needs: [setup] + if: | + needs.setup.outputs.should_skip != 'true' && + ( + github.event_name == 'workflow_dispatch' || + ( + github.repository_owner == 'ghostty-org' && + github.ref_name == 'main' + ) + ) + runs-on: namespace-profile-ghostty-macos-tahoe + env: + GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 + - uses: DeterminateSystems/nix-installer-action@main + with: + determinate: true + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Xcode Select + run: sudo xcode-select -s /Applications/Xcode_26.3.app + + - name: Build XCFramework + run: nix develop -c zig build -Demit-lib-vt -Doptimize=ReleaseFast + + - name: Zip XCFramework + run: | + cd zig-out/lib + zip -9 -r ../../ghostty-vt.xcframework.zip ghostty-vt.xcframework + + - name: Sign XCFramework + run: | + echo -n "${{ secrets.MINISIGN_KEY }}" > minisign.key + echo -n "${{ secrets.MINISIGN_PASSWORD }}" > minisign.password + nix develop -c minisign -S -m ghostty-vt.xcframework.zip -s minisign.key < minisign.password + + - name: Update Release + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + with: + name: 'Ghostty Tip ("Nightly")' + prerelease: true + tag_name: tip + target_commitish: ${{ github.sha }} + files: | + ghostty-vt.xcframework.zip + ghostty-vt.xcframework.zip.minisig + token: ${{ secrets.GH_RELEASE_TOKEN }} + + - name: Prep R2 Storage + run: | + mkdir -p blob/${GHOSTTY_COMMIT_LONG} + cp ghostty-vt.xcframework.zip blob/${GHOSTTY_COMMIT_LONG}/ghostty-vt.xcframework.zip + - name: Upload to R2 + uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4 + with: + r2-account-id: ${{ secrets.CF_R2_TIP_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.CF_R2_TIP_AWS_KEY }} + r2-secret-access-key: ${{ secrets.CF_R2_TIP_SECRET_KEY }} + r2-bucket: ghostty-tip + source-dir: blob + destination-dir: ./ + + - name: Echo Release URLs + run: | + echo "Release URLs:" + echo " XCFramework: https://tip.files.ghostty.org/${GHOSTTY_COMMIT_LONG}/ghostty-vt.xcframework.zip" + build-macos: needs: [setup] if: | @@ -245,13 +404,13 @@ 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 }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_26.2.app + run: sudo xcode-select -s /Applications/Xcode_26.3.app - name: Xcode Version run: xcodebuild -version @@ -378,7 +537,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,13 +660,13 @@ 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 }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_26.2.app + run: sudo xcode-select -s /Applications/Xcode_26.3.app - name: Xcode Version run: xcodebuild -version @@ -627,7 +786,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,13 +857,13 @@ 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 }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_26.2.app + run: sudo xcode-select -s /Applications/Xcode_26.3.app - name: Xcode Version run: xcodebuild -version @@ -824,7 +983,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 bdef91c302f..67f291601d0 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 2b01f1ef8cf..173c1cf0452 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,9 +15,10 @@ jobs: # are other fast skip conditions, and add it as an output. Then modify # other tests `needs/if` to check them. Document the outputs. skip: - runs-on: ubuntu-24.04 + if: github.repository == 'ghostty-org/ghostty' + runs-on: namespace-profile-ghostty-xsm outputs: - # 'true' when all changed files are non-code, + # 'true' when all changed files are non-code (e.g. only VOUCHED.td), # signaling that all other jobs can be skipped entirely. skip: ${{ steps.determine.outputs.skip }} # Path-based filters to gate specific linter/formatter jobs. @@ -30,18 +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: - '**' - - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + - '!.github/VOUCHED.td' + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: filter_any with: - token: ${{ secrets.GITHUB_TOKEN }} + token: "" filters: | macos: - '.swiftlint.yml' @@ -78,30 +80,39 @@ jobs: required: name: "Required Checks: Test" if: always() - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-xsm needs: - skip - build-bench - build-dist - - build-examples + - build-dist-lib-vt + - build-examples-zig + - build-examples-cmake + - build-examples-cmake-windows + - build-examples-swift + - 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-nix-macos - 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-lib-vt-pkgconfig - test-macos + - test-windows - pinact - prettier - swiftlint @@ -137,20 +148,27 @@ jobs: build-bench: # We build benchmarks on large because it uses ReleaseFast - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-lg needs: test env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + 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@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -158,35 +176,57 @@ 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 }} + swift: ${{ steps.list.outputs.swift }} + 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" + swift=$(ls example/*/Package.swift 2>/dev/null | xargs -n1 dirname | xargs -n1 basename | jq -R -s -c 'split("\n") | map(select(. != ""))') + echo "$swift" | jq . + echo "swift=$swift" >> "$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: ubuntu-24.04 - needs: test + runs-on: namespace-profile-ghostty-xsm + needs: [test, list-examples] env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + 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@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -196,23 +236,286 @@ jobs: 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@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 + 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) }} + exclude: + # Cross-compilation with zig cc requires a single-config + # generator (Makefiles/Ninja). The Windows CI uses Visual + # Studio which always uses MSVC and ignores CMAKE_C_COMPILER. + - dir: c-vt-cmake-cross + name: Example ${{ matrix.dir }} (Windows) + runs-on: namespace-profile-ghostty-windows + 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-examples-swift: + strategy: + fail-fast: false + matrix: + dir: ${{ fromJSON(needs.list-examples.outputs.swift) }} + name: Example ${{ matrix.dir }} + runs-on: namespace-profile-ghostty-macos-tahoe + needs: [test, list-examples] + env: + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 + - uses: DeterminateSystems/nix-installer-action@main + with: + determinate: true + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Xcode Select + run: sudo xcode-select -s /Applications/Xcode_26.3.app + + - name: Build XCFramework + run: nix develop -c zig build -Demit-lib-vt + + - name: Build and Run Example + run: | + cd example/${{ matrix.dir }} + swift run + + build-cmake: + runs-on: namespace-profile-ghostty-sm + needs: test + 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@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Build + run: | + 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/ + + test-lib-vt-pkgconfig: + runs-on: namespace-profile-ghostty-sm + needs: test + 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@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Build libghostty-vt + run: nix develop -c zig build -Demit-lib-vt + + - name: Verify pkg-config file + run: | + export PKG_CONFIG_PATH="$PWD/zig-out/share/pkgconfig" + pkg-config --validate libghostty-vt + echo "Cflags: $(pkg-config --cflags libghostty-vt)" + echo "Libs: $(pkg-config --libs libghostty-vt)" + echo "Static: $(pkg-config --libs --static libghostty-vt)" + + # Libs.private must include the C++ standard library + pkg-config --libs --static libghostty-vt | grep -q -- '-lc++' + + - name: Verify static archive contains SIMD deps + run: | + nm -g zig-out/lib/libghostty-vt.a | grep -q ' T .*simdutf' + nm -g zig-out/lib/libghostty-vt.a | grep -q ' T .*3hwy' + + - name: Write test program + run: | + cat > /tmp/test_libghostty_vt.c << 'TESTEOF' + #include + #include + int main(void) { + bool simd = false; + GhosttyResult r = ghostty_build_info(GHOSTTY_BUILD_INFO_SIMD, &simd); + if (r != GHOSTTY_SUCCESS) return 1; + printf("SIMD: %s\n", simd ? "yes" : "no"); + return 0; + } + TESTEOF + + - name: Test shared link via pkg-config + run: | + export PKG_CONFIG_PATH="$PWD/zig-out/share/pkgconfig" + nix develop -c cc -o /tmp/test_shared /tmp/test_libghostty_vt.c \ + $(pkg-config --cflags --libs libghostty-vt) \ + -Wl,-rpath,"$PWD/zig-out/lib" + /tmp/test_shared + + - name: Test static link via pkg-config + run: | + export PKG_CONFIG_PATH="$PWD/zig-out/share/pkgconfig" + # The static library is compiled with LLVM libc++ (not GNU + # libstdc++), so linking requires a libc++-compatible toolchain. + # zig cc, clang, or gcc with libc++-dev installed all work. + nix develop -c zig cc -o /tmp/test_static /tmp/test_libghostty_vt.c \ + $(pkg-config --cflags libghostty-vt) \ + "$PWD/zig-out/lib/libghostty-vt.a" \ + $(pkg-config --libs-only-l --static libghostty-vt | sed 's/-lghostty-vt//') + /tmp/test_static + # Verify it's truly statically linked (no libghostty-vt.so dependency) + ! ldd /tmp/test_static 2>/dev/null | grep -q libghostty-vt + + # Test system integration: rebuild with -Dsystem-simdutf=true so + # simdutf comes from the system instead of being vendored. This + # verifies the .pc file uses Requires.private for system deps and + # the fat archive only bundles the remaining vendored deps. + - name: Rebuild with system simdutf + run: | + rm -rf zig-out + nix develop -c zig build -Demit-lib-vt -fsys=simdutf + + - name: Verify pkg-config with system simdutf + run: | + export PKG_CONFIG_PATH="$PWD/zig-out/share/pkgconfig" + pc_content=$(cat zig-out/share/pkgconfig/libghostty-vt.pc) + echo "$pc_content" + + # Requires.private must reference simdutf + echo "$pc_content" | grep -q 'Requires.private:.*simdutf' + + # Requires.private must NOT reference libhwy (still vendored) + ! echo "$pc_content" | grep -q 'Requires.private:.*libhwy' + + - name: Verify archive with system simdutf + run: | + # simdutf symbols must NOT be defined (comes from system) + ! nm -g zig-out/lib/libghostty-vt.a | grep -q ' T .*simdutf' + + # highway symbols must still be defined (vendored) + nm -g zig-out/lib/libghostty-vt.a | grep -q ' T .*3hwy' + build-flatpak: strategy: fail-fast: false - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-sm needs: test env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + 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@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -226,20 +529,27 @@ jobs: build-snap: strategy: fail-fast: false - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-sm needs: test env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + 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@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -263,59 +573,74 @@ jobs: x86_64-windows, wasm32-freestanding, ] - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-sm needs: test env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + 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@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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: target: [aarch64-macos, x86_64-macos, aarch64-ios] - runs-on: macos-latest + runs-on: namespace-profile-ghostty-macos-tahoe needs: test env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: Xcode Select - run: sudo xcode-select -s /Applications/Xcode_26.2.app + run: sudo xcode-select -s /Applications/Xcode_26.3.app - 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 @@ -324,21 +649,28 @@ jobs: matrix: target: [aarch64-linux-android, x86_64-linux-android, arm-linux-androideabi] - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-sm needs: test env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache ANDROID_NDK_VERSION: r29 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@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -354,30 +686,54 @@ 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: namespace-profile-ghostty-windows + 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 matrix: - os: [ubuntu-24.04] + os: [namespace-profile-ghostty-md, namespace-profile-ghostty-md-arm64] runs-on: ${{ matrix.os }} needs: test env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + 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@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -386,20 +742,27 @@ jobs: run: nix develop -c zig build build-linux-libghostty: - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-md needs: test env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + 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@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -411,21 +774,28 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-24.04] + os: [namespace-profile-ghostty-md, namespace-profile-ghostty-md-arm64] runs-on: ${{ matrix.os }} needs: test env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + 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@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -448,23 +818,66 @@ jobs: - name: Check to see if the binary has not been stripped run: nm result/bin/.ghostty-wrapped 2>&1 | grep -q 'main_ghostty.main' + - name: Test ReleaseFast build of libghostty-vt + run: | + nix build .#libghostty-vt-releasefast + nix build .#libghostty-vt-releasefast.tests.sanity-check + nix build .#libghostty-vt-releasefast.tests.pkg-config + nix build .#libghostty-vt-releasefast.tests.build-with-shared + nix build .#libghostty-vt-releasefast.tests.build-with-static + nix build .#libghostty-vt-releasefast.tests.build-example-c-vt-build-info + + - name: Test ReleaseFast (no SIMD) build of libghostty-vt + run: | + nix build .#libghostty-vt-releasefast-no-simd + nix build .#libghostty-vt-releasefast-no-simd.tests.sanity-check + nix build .#libghostty-vt-releasefast-no-simd.tests.pkg-config + nix build .#libghostty-vt-releasefast-no-simd.tests.build-with-shared + nix build .#libghostty-vt-releasefast-no-simd.tests.build-with-static + nix build .#libghostty-vt-releasefast-no-simd.tests.build-example-c-vt-build-info + + - name: Test Debug build of libghostty-vt + run: | + nix build .#libghostty-vt-debug + nix build .#libghostty-vt-debug.tests.sanity-check + nix build .#libghostty-vt-debug.tests.pkg-config + nix build .#libghostty-vt-debug.tests.build-with-shared + nix build .#libghostty-vt-debug.tests.build-with-static + nix build .#libghostty-vt-debug.tests.build-example-c-vt-build-info + + - name: Test Debug (no SIMD) build of libghostty-vt + run: | + nix build .#libghostty-vt-debug-no-simd + nix build .#libghostty-vt-debug-no-simd.tests.sanity-check + nix build .#libghostty-vt-debug-no-simd.tests.pkg-config + nix build .#libghostty-vt-debug-no-simd.tests.build-with-shared + nix build .#libghostty-vt-debug-no-simd.tests.build-with-static + nix build .#libghostty-vt-debug-no-simd.tests.build-example-c-vt-build-info + build-dist: - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-sm needs: test outputs: artifact-id: ${{ steps.upload-artifact.outputs.artifact-id }} env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + 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@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -483,9 +896,51 @@ jobs: path: |- ghostty-source.tar.gz + build-dist-lib-vt: + runs-on: namespace-profile-ghostty-sm + needs: test + 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@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Build and Check Source Tarball + run: | + rm -rf zig-out/dist + nix develop -c zig build distcheck -Demit-lib-vt=true + + - name: Verify tarball size + run: | + tarball=$(ls zig-out/dist/*.tar.gz) + size=$(stat --format=%s "$tarball") + max=$((5 * 1024 * 1024)) + echo "Tarball size: $size bytes (max: $max)" + if [ "$size" -gt "$max" ]; then + echo "ERROR: tarball exceeds 5 MB" + exit 1 + fi + trigger-snap: - if: github.repository == 'ghostty-org/ghostty' && github.event_name != 'pull_request' - runs-on: ubuntu-24.04 + if: github.event_name != 'pull_request' + runs-on: namespace-profile-ghostty-xsm needs: [build-dist, build-snap] steps: - name: Checkout code @@ -502,8 +957,8 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} trigger-flatpak: - if: github.repository == 'ghostty-org/ghostty' && github.event_name != 'pull_request' - runs-on: ubuntu-24.04 + if: github.event_name != 'pull_request' + runs-on: namespace-profile-ghostty-xsm needs: [build-dist, build-flatpak] steps: - name: Checkout code @@ -519,27 +974,87 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # build-nix-macos: + # runs-on: namespace-profile-ghostty-macos-tahoe + # needs: test + # steps: + # - name: Checkout code + # uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # # Install Nix and use that to run our tests so our environment matches exactly. + # - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 + # with: + # nix_path: nixpkgs=channel:nixos-unstable + # - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + # with: + # name: ghostty + # authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + # - name: Test ReleaseFast build of libghostty-vt + # run: | + # nix build .#libghostty-vt-releasefast + # nix build .#libghostty-vt-releasefast.tests.sanity-check + # nix build .#libghostty-vt-releasefast.tests.pkg-config + # nix build .#libghostty-vt-releasefast.tests.build-with-shared + # nix build .#libghostty-vt-releasefast.tests.build-with-static + # nix build .#libghostty-vt-releasefast.tests.build-example-c-vt-build-info + + # - name: Test ReleaseFast (no SIMD) build of libghostty-vt + # run: | + # nix build .#libghostty-vt-releasefast-no-simd + # nix build .#libghostty-vt-releasefast-no-simd.tests.sanity-check + # nix build .#libghostty-vt-releasefast-no-simd.tests.pkg-config + # nix build .#libghostty-vt-releasefast-no-simd.tests.build-with-shared + # nix build .#libghostty-vt-releasefast-no-simd.tests.build-with-static + # nix build .#libghostty-vt-releasefast-no-simd.tests.build-example-c-vt-build-info + + # - name: Test Debug build of libghostty-vt + # run: | + # nix build .#libghostty-vt-debug + # nix build .#libghostty-vt-debug.tests.sanity-check + # nix build .#libghostty-vt-debug.tests.pkg-config + # nix build .#libghostty-vt-debug.tests.build-with-shared + # nix build .#libghostty-vt-debug.tests.build-with-static + # nix build .#libghostty-vt-debug.tests.build-example-c-vt-build-info + + # - name: Test Debug (no SIMD) build of libghostty-vt + # run: | + # nix build .#libghostty-vt-debug-no-simd + # nix build .#libghostty-vt-debug-no-simd.tests.sanity-check + # nix build .#libghostty-vt-debug-no-simd.tests.pkg-config + # nix build .#libghostty-vt-debug-no-simd.tests.build-with-shared + # nix build .#libghostty-vt-debug-no-simd.tests.build-with-static + # nix build .#libghostty-vt-debug-no-simd.tests.build-example-c-vt-build-info + build-macos: - runs-on: macos-latest + runs-on: namespace-profile-ghostty-macos-tahoe needs: test env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: Xcode Select - run: sudo xcode-select -s /Applications/Xcode_26.2.app + run: sudo xcode-select -s /Applications/Xcode_26.3.app - name: Xcode Version run: xcodebuild -version @@ -559,7 +1074,7 @@ jobs: - name: Build Ghostty.app run: | cd macos - xcodebuild -target Ghostree \ + xcodebuild -target Ghostty \ COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES @@ -572,26 +1087,34 @@ jobs: COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES build-macos-freetype: - runs-on: macos-latest + runs-on: namespace-profile-ghostty-macos-tahoe needs: test env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: Xcode Select - run: sudo xcode-select -s /Applications/Xcode_26.2.app + run: sudo xcode-select -s /Applications/Xcode_26.3.app - name: Xcode Version run: xcodebuild -version @@ -600,92 +1123,25 @@ 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: needs.skip.outputs.skip != 'true' + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' needs: skip - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-md outputs: zig_version: ${{ steps.zig.outputs.version }} env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + 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 @@ -695,11 +1151,18 @@ jobs: run: | echo "version=$(sed -n -E 's/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon)" >> $GITHUB_OUTPUT + - 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@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -714,6 +1177,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@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 + 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 @@ -721,20 +1214,27 @@ jobs: x11: ["true", "false"] wayland: ["true", "false"] name: GTK x11=${{ matrix.x11 }} wayland=${{ matrix.wayland }} - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-sm needs: test env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + 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@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -762,20 +1262,27 @@ jobs: matrix: simd: ["true", "false"] name: Build -Dsimd=${{ matrix.simd }} - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-sm needs: test env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + 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@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -790,20 +1297,27 @@ jobs: matrix: sentry: ["true", "false"] name: Build -Dsentry=${{ matrix.sentry }} - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-sm needs: test env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + 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@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -813,26 +1327,34 @@ jobs: nix develop -c zig build -Dsentry=${{ matrix.sentry }} test-macos: - runs-on: macos-latest + runs-on: namespace-profile-ghostty-macos-tahoe needs: test env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: Xcode Select - run: sudo xcode-select -s /Applications/Xcode_26.2.app + run: sudo xcode-select -s /Applications/Xcode_26.3.app - name: Xcode Version run: xcodebuild -version @@ -844,26 +1366,48 @@ jobs: - name: test run: nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} + test-windows: + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' + needs: skip + runs-on: namespace-profile-ghostty-windows + timeout-minutes: 45 + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Zig + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + + - name: Test + run: zig build -Dapp-runtime=none test + test-i18n: strategy: fail-fast: false matrix: i18n: ["true", "false"] name: Build -Di18n=${{ matrix.i18n }} - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-sm needs: test env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + 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@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -874,20 +1418,27 @@ jobs: test-fuzz-libghostty: name: Build test/fuzz-libghostty - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-sm needs: test env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + 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@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -911,19 +1462,25 @@ jobs: nix develop -c sh -c 'cd test/fuzz-libghostty && zig build' zig-fmt: - if: needs.skip.outputs.skip != 'true' && needs.skip.outputs.zig == 'true' + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' && needs.skip.outputs.zig == 'true' needs: skip - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-xsm timeout-minutes: 60 env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + path: | + /nix + /zig + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -934,21 +1491,27 @@ jobs: pinact: name: "GitHub Actions Pins" - if: needs.skip.outputs.skip != 'true' && needs.skip.outputs.actions_pins == 'true' + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' && needs.skip.outputs.actions_pins == 'true' needs: skip - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-xsm timeout-minutes: 60 permissions: contents: read env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + path: | + /nix + /zig + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -960,19 +1523,25 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} prettier: - if: needs.skip.outputs.skip != 'true' + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' needs: skip - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-xsm timeout-minutes: 60 env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + path: | + /nix + /zig + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -982,18 +1551,26 @@ jobs: run: nix develop -c prettier --check . swiftlint: - if: needs.skip.outputs.skip != 'true' && needs.skip.outputs.macos == 'true' - runs-on: macos-latest + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' && needs.skip.outputs.macos == 'true' + runs-on: namespace-profile-ghostty-macos-tahoe needs: skip timeout-minutes: 60 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1004,19 +1581,25 @@ jobs: run: nix develop -c swiftlint lint --strict alejandra: - if: needs.skip.outputs.skip != 'true' && needs.skip.outputs.nix == 'true' + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' && needs.skip.outputs.nix == 'true' needs: skip - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-xsm timeout-minutes: 60 env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + path: | + /nix + /zig + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -1026,19 +1609,25 @@ jobs: run: nix develop -c alejandra --check . typos: - if: needs.skip.outputs.skip != 'true' + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' needs: skip - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-xsm timeout-minutes: 60 env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + path: | + /nix + /zig + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -1048,19 +1637,25 @@ jobs: run: nix develop -c typos shellcheck: - if: needs.skip.outputs.skip != 'true' && needs.skip.outputs.shell == 'true' + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' && needs.skip.outputs.shell == 'true' needs: skip - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-xsm timeout-minutes: 60 env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + path: | + /nix + /zig + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -1075,19 +1670,25 @@ jobs: $(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort) translations: - if: needs.skip.outputs.skip != 'true' + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' needs: skip - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-xsm timeout-minutes: 60 env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + path: | + /nix + /zig + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -1097,19 +1698,25 @@ jobs: run: nix develop -c .github/scripts/check-translations.sh blueprint-compiler: - if: needs.skip.outputs.skip != 'true' && needs.skip.outputs.blueprints == 'true' + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' && needs.skip.outputs.blueprints == 'true' needs: skip - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-xsm timeout-minutes: 60 env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + path: | + /nix + /zig + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -1126,20 +1733,27 @@ jobs: matrix: pkg: ["wuffs"] name: Test pkg/${{ matrix.pkg }} - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-sm needs: test env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + 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@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 }}" @@ -1150,14 +1764,18 @@ jobs: test-debian-13: name: Test build on Debian 13 - runs-on: ubuntu-24.04 + runs-on: namespace-profile-ghostty-sm timeout-minutes: 10 needs: [test, build-dist] - permissions: - contents: read steps: + - name: Install and configure Namespace CLI + uses: namespacelabs/nscloud-setup@df198f982fcecfb8264bea3f1274b56a61b6dfdc # v0.0.12 + + - name: Configure Namespace powered Buildx + 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 @@ -1175,21 +1793,29 @@ jobs: DISTRO_VERSION=13 valgrind: - runs-on: ubuntu-24.04 + if: github.repository == 'ghostty-org/ghostty' + runs-on: namespace-profile-ghostty-lg timeout-minutes: 30 needs: test env: - ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/local - ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + 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@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 762b3d007c2..5f4cca5c430 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@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4 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 new file mode 100644 index 00000000000..d0456b00a4f --- /dev/null +++ b/.github/workflows/vouch-check-issue.yml @@ -0,0 +1,22 @@ +on: + issues: + types: [opened, reopened] + +name: "Vouch - Check Issue" + +jobs: + check: + runs-on: namespace-profile-ghostty-xsm + steps: + - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + id: app-token + with: + app-id: ${{ secrets.VOUCH_APP_ID }} + private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} + + - uses: mitchellh/vouch/action/check-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 + with: + issue-number: ${{ github.event.issue.number }} + auto-close: true + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml new file mode 100644 index 00000000000..0602553ca82 --- /dev/null +++ b/.github/workflows/vouch-check-pr.yml @@ -0,0 +1,22 @@ +on: + pull_request_target: + types: [opened, reopened] + +name: "Vouch - Check PR" + +jobs: + check: + runs-on: namespace-profile-ghostty-xsm + steps: + - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + id: app-token + with: + app-id: ${{ secrets.VOUCH_APP_ID }} + private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} + + - uses: mitchellh/vouch/action/check-pr@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 + with: + pr-number: ${{ github.event.pull_request.number }} + auto-close: true + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/vouch-manage-by-discussion.yml b/.github/workflows/vouch-manage-by-discussion.yml new file mode 100644 index 00000000000..7288c4ab2a6 --- /dev/null +++ b/.github/workflows/vouch-manage-by-discussion.yml @@ -0,0 +1,35 @@ +on: + discussion_comment: + types: [created] + +name: "Vouch - Manage by Discussion" + +concurrency: + group: vouch-manage + cancel-in-progress: false + +jobs: + manage: + runs-on: namespace-profile-ghostty-xsm + steps: + - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + id: app-token + with: + app-id: ${{ secrets.VOUCH_APP_ID }} + private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: ${{ steps.app-token.outputs.token }} + + - uses: mitchellh/vouch/action/manage-by-discussion@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 + with: + discussion-number: ${{ github.event.discussion.number }} + comment-node-id: ${{ github.event.comment.node_id }} + vouch-keyword: "!vouch" + denounce-keyword: "!denounce" + unvouch-keyword: "!unvouch" + pull-request: "true" + merge-immediately: "true" + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml new file mode 100644 index 00000000000..6f61592ffe3 --- /dev/null +++ b/.github/workflows/vouch-manage-by-issue.yml @@ -0,0 +1,36 @@ +on: + issue_comment: + types: [created] + +name: "Vouch - Manage by Issue" + +concurrency: + group: vouch-manage + cancel-in-progress: false + +jobs: + manage: + runs-on: namespace-profile-ghostty-xsm + steps: + - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + id: app-token + with: + app-id: ${{ secrets.VOUCH_APP_ID }} + private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: ${{ steps.app-token.outputs.token }} + + - uses: mitchellh/vouch/action/manage-by-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 + with: + repo: ${{ github.repository }} + issue-id: ${{ github.event.issue.number }} + comment-id: ${{ github.event.comment.id }} + vouch-keyword: "!vouch" + denounce-keyword: "!denounce" + unvouch-keyword: "!unvouch" + pull-request: "true" + merge-immediately: "true" + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/vouch-sync-codeowners.yml b/.github/workflows/vouch-sync-codeowners.yml new file mode 100644 index 00000000000..0879c972290 --- /dev/null +++ b/.github/workflows/vouch-sync-codeowners.yml @@ -0,0 +1,32 @@ +on: + schedule: + - cron: "0 0 * * 1" # Every Monday at midnight UTC + workflow_dispatch: + +name: "Vouch - Sync CODEOWNERS" + +concurrency: + group: vouch-manage + cancel-in-progress: false + +jobs: + sync: + runs-on: namespace-profile-ghostty-xsm + steps: + - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + id: app-token + with: + app-id: ${{ secrets.VOUCH_APP_ID }} + private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: ${{ steps.app-token.outputs.token }} + + - uses: mitchellh/vouch/action/sync-codeowners@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 + with: + repo: ${{ github.repository }} + pull-request: "true" + merge-immediately: "true" + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.gitignore b/.gitignore index fa6b43f8932..5e5cf6103e9 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 diff --git a/.prettierignore b/.prettierignore index 2699f7e1058..2abcb149b51 100644 --- a/.prettierignore +++ b/.prettierignore @@ -26,3 +26,4 @@ website/.next # fuzz corpus files test/fuzz-libghostty/corpus/ test/fuzz-libghostty/afl-out/ + diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 00000000000..e564f8811ac --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,382 @@ +# 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 +# +# Cross-compilation +# ------------------- +# +# For building libghostty-vt for a non-native Zig target (e.g. cross- +# compiling), use the ghostty_vt_add_target() function after FetchContent: +# +# FetchContent_MakeAvailable(ghostty) +# ghostty_vt_add_target(NAME linux-amd64 ZIG_TARGET x86_64-linux-gnu) +# +# target_link_libraries(myapp PRIVATE ghostty-vt-static-linux-amd64) # static +# target_link_libraries(myapp PRIVATE ghostty-vt-linux-amd64) # shared +# +# This handles zig discovery, build-type-to-optimize mapping, and output +# path conventions internally. Extra flags can be forwarded with ZIG_FLAGS: +# +# ghostty_vt_add_target(NAME linux-amd64 ZIG_TARGET x86_64-linux-gnu +# ZIG_FLAGS -Dsimd=false) +# +# See dist/cmake/README.md for more details, example/c-vt-cmake/ for a +# complete working example, and example/c-vt-cmake-cross/ for a cross- +# compilation 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. The result is stored in +# _GHOSTTY_ZIG_OPT_FLAG so both the native build and ghostty_vt_add_target() +# can reuse it without duplicating the mapping logic. +set(_GHOSTTY_ZIG_OPT_FLAG "") +if(CMAKE_BUILD_TYPE) + string(TOUPPER "${CMAKE_BUILD_TYPE}" _bt) + if(_bt STREQUAL "RELEASE" OR _bt STREQUAL "MINSIZEREL" OR _bt STREQUAL "RELWITHDEBINFO") + set(_GHOSTTY_ZIG_OPT_FLAG "-Doptimize=ReleaseFast") + endif() + unset(_bt) +endif() + +if(_GHOSTTY_ZIG_OPT_FLAG) + list(APPEND GHOSTTY_ZIG_BUILD_FLAGS "${_GHOSTTY_ZIG_OPT_FLAG}") +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 +# +# On Linux and macOS, the static library is a fat archive that bundles +# the vendored SIMD dependencies (highway, simdutf, utfcpp). Consumers +# only need to link libc and libc++ (LLVM's C++ runtime, not GNU +# libstdc++). Use zig cc, clang, or any toolchain with libc++ support. +# +# On Windows, the SIMD dependencies are not bundled and must be linked +# separately. +# +# Building with -Dsimd=false removes all runtime dependencies. +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" + INTERFACE_COMPILE_DEFINITIONS "GHOSTTY_STATIC" +) +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" +) + +# --- Cross-compilation helper ------------------------------------------------ +# +# For downstream projects that need to build libghostty-vt for a specific +# Zig target triple. For native builds, use the IMPORTED targets above +# (ghostty-vt, ghostty-vt-static) directly. +# +# Usage (in a downstream CMakeLists.txt after FetchContent_MakeAvailable): +# +# ghostty_vt_add_target(NAME linux-amd64 ZIG_TARGET x86_64-linux-gnu) +# +# Creates: +# ghostty-vt-static-linux-amd64 (IMPORTED STATIC library) +# ghostty-vt-linux-amd64 (IMPORTED SHARED library) +# +# Optional ZIG_FLAGS to pass additional flags to zig build: +# +# ghostty_vt_add_target(NAME linux-amd64 ZIG_TARGET x86_64-linux-gnu +# ZIG_FLAGS -Dsimd=false) + +function(ghostty_vt_add_target) + cmake_parse_arguments(PARSE_ARGV 0 _GVT "" "NAME;ZIG_TARGET" "ZIG_FLAGS") + + if(NOT _GVT_NAME) + message(FATAL_ERROR "ghostty_vt_add_target: NAME is required") + endif() + if(NOT _GVT_ZIG_TARGET) + message(FATAL_ERROR "ghostty_vt_add_target: ZIG_TARGET is required") + endif() + + set(_src_dir "${CMAKE_CURRENT_FUNCTION_LIST_DIR}") + set(_prefix "${CMAKE_CURRENT_BINARY_DIR}/ghostty-${_GVT_NAME}") + + # Build flags + set(_flags + -Demit-lib-vt + -Dtarget=${_GVT_ZIG_TARGET} + --prefix "${_prefix}" + ) + + # Default to ReleaseFast when no build type is set. Debug builds enable + # UBSan in zig, and the sanitizer runtime is not available for all + # cross-compilation targets. + if(_GHOSTTY_ZIG_OPT_FLAG) + list(APPEND _flags "${_GHOSTTY_ZIG_OPT_FLAG}") + else() + list(APPEND _flags "-Doptimize=ReleaseFast") + endif() + + if(_GVT_ZIG_FLAGS) + list(APPEND _flags ${_GVT_ZIG_FLAGS}) + endif() + + # Output paths + set(_include_dir "${_prefix}/include") + + if(_GVT_ZIG_TARGET MATCHES "windows") + set(_static_lib "${_prefix}/lib/ghostty-vt-static.lib") + set(_shared_lib "${_prefix}/bin/ghostty-vt.dll") + set(_implib "${_prefix}/lib/ghostty-vt.lib") + elseif(_GVT_ZIG_TARGET MATCHES "darwin|macos") + set(_static_lib "${_prefix}/lib/libghostty-vt.a") + set(_shared_lib "${_prefix}/lib/libghostty-vt.0.1.0.dylib") + else() + set(_static_lib "${_prefix}/lib/libghostty-vt.a") + set(_shared_lib "${_prefix}/lib/libghostty-vt.so.0.1.0") + endif() + + file(MAKE_DIRECTORY "${_include_dir}") + + # Custom command: invoke zig build + add_custom_command( + OUTPUT "${_static_lib}" "${_shared_lib}" + COMMAND "${ZIG_EXECUTABLE}" build ${_flags} + WORKING_DIRECTORY "${_src_dir}" + COMMENT "Building libghostty-vt for ${_GVT_ZIG_TARGET}..." + USES_TERMINAL + ) + + set(_build_target "zig_build_lib_vt_${_GVT_NAME}") + add_custom_target(${_build_target} ALL + DEPENDS "${_static_lib}" "${_shared_lib}" + ) + + # Static target + set(_static_target "ghostty-vt-static-${_GVT_NAME}") + add_library(${_static_target} STATIC IMPORTED GLOBAL) + set_target_properties(${_static_target} PROPERTIES + IMPORTED_LOCATION "${_static_lib}" + INTERFACE_INCLUDE_DIRECTORIES "${_include_dir}" + INTERFACE_COMPILE_DEFINITIONS "GHOSTTY_STATIC" + ) + if(_GVT_ZIG_TARGET MATCHES "windows") + set_target_properties(${_static_target} PROPERTIES + INTERFACE_LINK_LIBRARIES "c++;ntdll;kernel32" + ) + else() + set_target_properties(${_static_target} PROPERTIES + INTERFACE_LINK_LIBRARIES "c++" + ) + endif() + add_dependencies(${_static_target} ${_build_target}) + + # Shared target + set(_shared_target "ghostty-vt-${_GVT_NAME}") + add_library(${_shared_target} SHARED IMPORTED GLOBAL) + set_target_properties(${_shared_target} PROPERTIES + IMPORTED_LOCATION "${_shared_lib}" + INTERFACE_INCLUDE_DIRECTORIES "${_include_dir}" + ) + if(_GVT_ZIG_TARGET MATCHES "windows") + set_target_properties(${_shared_target} PROPERTIES + IMPORTED_IMPLIB "${_implib}" + ) + elseif(_GVT_ZIG_TARGET MATCHES "darwin|macos") + set_target_properties(${_shared_target} PROPERTIES + IMPORTED_SONAME "@rpath/libghostty-vt.0.dylib" + ) + else() + set_target_properties(${_shared_target} PROPERTIES + IMPORTED_SONAME "libghostty-vt.so.0" + ) + endif() + add_dependencies(${_shared_target} ${_build_target}) +endfunction() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c048800bdbd..6924ada173a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,3 +29,129 @@ See the [Translator's Guide](po/README_TRANSLATORS.md) for contributing translat ## Code of Conduct Be respectful and constructive. We're all here to make Ghostree better. + +["contributor friendly"]: https://github.com/ghostty-org/ghostty/issues?q=is%3Aissue%20is%3Aopen%20label%3A%22contributor%20friendly%22 + +### I'd like to translate Ghostty to my language + +We have written a [Translator's Guide](po/README_TRANSLATORS.md) for +everyone interested in contributing translations to Ghostty. +Translations usually do not need to go through the process of issue triage +and you can submit pull requests directly, although please make sure that +our [Style Guide](po/README_TRANSLATORS.md#style-guide) is followed before +submission. + +### I have a bug! / Something isn't working + +First, search the issue tracker and discussions for similar issues. Tip: also +search for [closed issues] and [discussions] — your issue might have already +been fixed! + +> [!NOTE] +> +> If there is an _open_ issue or discussion that matches your problem, +> **please do not comment on it unless you have valuable insight to add**. +> +> GitHub has a very _noisy_ set of default notification settings which +> sends an email to _every participant_ in an issue/discussion every time +> someone adds a comment. Instead, use the handy upvote button for discussions, +> and/or emoji reactions on both discussions and issues, which are a visible +> yet non-disruptive way to show your support. + +If your issue hasn't been reported already, open an ["Issue Triage"] discussion +and make sure to fill in the template **completely**. They are vital for +maintainers to figure out important details about your setup. + +> [!WARNING] +> +> A _very_ common mistake is to file a bug report either as a Q&A or a Feature +> Request. **Please don't do this.** Otherwise, maintainers would have to ask +> for your system information again manually, and sometimes they will even ask +> you to create a new discussion because of how few detailed information is +> required for other discussion types compared to Issue Triage. +> +> Because of this, please make sure that you _only_ use the "Issue Triage" +> category for reporting bugs — thank you! + +[closed issues]: https://github.com/ghostty-org/ghostty/issues?q=is%3Aissue%20state%3Aclosed +[discussions]: https://github.com/ghostty-org/ghostty/discussions?discussions_q=is%3Aclosed +["Issue Triage"]: https://github.com/ghostty-org/ghostty/discussions/new?category=issue-triage + +### I have an idea for a feature + +Like bug reports, first search through both issues and discussions and try to +find if your feature has already been requested. Otherwise, open a discussion +in the ["Feature Requests, Ideas"] category. + +["Feature Requests, Ideas"]: https://github.com/ghostty-org/ghostty/discussions/new?category=feature-requests-ideas + +### I've implemented a feature + +1. If there is an issue for the feature, open a pull request straight away. +2. If there is no issue, open a discussion and link to your branch. +3. If you want to live dangerously, open a pull request and + [hope for the best](#pull-requests-implement-an-issue). + +### I have a question which is neither a bug report nor a feature request + +Open an [Q&A discussion], or join our [Discord Server] and ask away in the +`#help` forum channel. + +Do not use the `#terminals` or `#development` channels to ask for help — +those are for general discussion about terminals and Ghostty development +respectively. If you do ask a question there, you will be redirected to +`#help` instead. + +> [!NOTE] +> If your question is about a missing feature, please open a discussion under +> the ["Feature Requests, Ideas"] category. If Ghostty is behaving +> unexpectedly, use the ["Issue Triage"] category. +> +> The "Q&A" category is strictly for other kinds of discussions and do not +> require detailed information unlike the two other categories, meaning that +> maintainers would have to spend the extra effort to ask for basic information +> if you submit a bug report under this category. +> +> Therefore, please **pay attention to the category** before opening +> discussions to save us all some time and energy. Thank you! + +[Q&A discussion]: https://github.com/ghostty-org/ghostty/discussions/new?category=q-a +[Discord Server]: https://discord.gg/ghostty + +## General Patterns + +### Issues are Actionable + +The Ghostty [issue tracker](https://github.com/ghostty-org/ghostty/issues) +is for _actionable items_. + +Unlike some other projects, Ghostty **does not use the issue tracker for +discussion or feature requests**. Instead, we use GitHub +[discussions](https://github.com/ghostty-org/ghostty/discussions) for that. +Once a discussion reaches a point where a well-understood, actionable +item is identified, it is moved to the issue tracker. **This pattern +makes it easier for maintainers or contributors to find issues to work on +since _every issue_ is ready to be worked on.** + +If you are experiencing a bug and have clear steps to reproduce it, please +open an issue. If you are experiencing a bug but you are not sure how to +reproduce it or aren't sure if it's a bug, please open a discussion. +If you have an idea for a feature, please open a discussion. + +### Pull Requests Implement an Issue + +Pull requests should be associated with a previously accepted issue. +**If you open a pull request for something that wasn't previously discussed,** +it may be closed or remain stale for an indefinite period of time. I'm not +saying it will never be accepted, but the odds are stacked against you. + +Issues tagged with "feature" represent accepted, well-scoped feature requests. +If you implement an issue tagged with feature as described in the issue, your +pull request will be accepted with a high degree of certainty. + +> [!NOTE] +> +> **Pull requests are NOT a place to discuss feature design.** Please do +> not open a WIP pull request to discuss a feature. Instead, use a discussion +> and link to your branch. +>>>>>>> upstream-main diff --git a/README.md b/README.md index c8c9e914b53..047d84d8c21 100644 --- a/README.md +++ b/README.md @@ -32,20 +32,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). @@ -67,30 +60,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/)) @@ -102,33 +102,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. - -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. - -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. +Ghostty is generally in the same performance category as the other highest +performing terminal emulators. -> [!NOTE] -> Despite being _very fast_, there is a lot of room for improvement here. +"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. + +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. -#### 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 @@ -139,10 +136,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,21 +153,40 @@ C-compatible library for embedding a fast, feature-rich terminal emulator in any 3rd party project. This library is called `libghostty`. Due to the scope of this project, we're breaking libghostty down into -separate actually libraries, starting with `libghostty-vt`. The goal of +separate libraries, starting with `libghostty-vt`. The goal of this project is to focus on parsing terminal sequences and maintaining 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. - -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. +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. + +`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 f9d861b1946..89142561ba5 100644 --- a/build.zig +++ b/build.zig @@ -3,11 +3,17 @@ const assert = std.debug.assert; const builtin = @import("builtin"); const buildpkg = @import("src/build/main.zig"); -const appVersion = @import("build.zig.zon").version; -const minimumZigVersion = @import("build.zig.zon").minimum_zig_version; +/// App version from build.zig.zon. +const app_zon_version = @import("build.zig.zon").version; + +/// Libghostty version. We use a separate version from the app. +const lib_version = "0.1.0-dev"; + +/// Minimum required zig version. +const minimum_zig_version = @import("build.zig.zon").minimum_zig_version; comptime { - buildpkg.requireZig(minimumZigVersion); + buildpkg.requireZig(minimum_zig_version); } pub fn build(b: *std.Build) !void { @@ -15,7 +21,24 @@ pub fn build(b: *std.Build) !void { // want to know what options are available, you can run `--help` or // you can read `src/build/Config.zig`. - const config = try buildpkg.Config.init(b, appVersion); + // If we have a VERSION file (present in source tarballs) then we + // use that as the version source of truth. Otherwise we fall back + // to what is in the build.zig.zon. + const file_version: ?[]const u8 = if (b.build_root.handle.readFileAlloc( + b.allocator, + "VERSION", + 128, + )) |content| std.mem.trim( + u8, + content, + &std.ascii.whitespace, + ) else |_| null; + + const config = try buildpkg.Config.init( + b, + file_version orelse app_zon_version, + lib_version, + ); const test_filters = b.option( [][]const u8, "test-filter", @@ -35,7 +58,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 +113,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 +127,45 @@ 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); + } + + // libghostty-vt xcframework (Apple only, universal binary). + // Only when building on macOS (not cross-compiling) since + // xcodebuild is required. + if (builtin.os.tag.isDarwin() and config.target.result.os.tag.isDarwin()) { + const apple_libs = try buildpkg.GhosttyLibVt.initStaticAppleUniversal( + b, + &config, + &deps, + &mod, + ); + const xcframework = buildpkg.GhosttyLibVt.xcframework(&apple_libs, b); + b.getInstallStep().dependOn(xcframework.step); + } + // Helpgen if (config.emit_helpgen) deps.help_strings.install(); @@ -128,26 +176,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 +258,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, @@ -271,8 +329,8 @@ pub fn build(b: *std.Build) !void { test_lib_vt_step.dependOn(&mod_vt_c_test_run.step); } - // Tests - { + // Tests (skip when building libghostty-vt) + if (!config.emit_lib_vt) { // Full unit tests const test_exe = b.addTest(.{ .name = "ghostty-test", diff --git a/build.zig.zon b/build.zig.zon index 62de2441203..7cb79c96c20 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -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 = .{ @@ -117,8 +117,8 @@ .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .android_ndk = .{ .path = "./pkg/android-ndk" }, .iterm2_themes = .{ - .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz", - .hash = "N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF", + .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260323-152405-a2c7b60.tgz", + .hash = "N-V-__8AAL6FAwBDPampKgDjoxlJYDIn2jv0VaINS4W6CXJN", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 4a88e20174f..ccadd6ac973 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -54,10 +54,10 @@ "url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz", "hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs=" }, - "N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF": { + "N-V-__8AAL6FAwBDPampKgDjoxlJYDIn2jv0VaINS4W6CXJN": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz", - "hash": "sha256-FCALuGoMgUq2lgnVALKAs5a20uuDXt8Gdt5KeJwKqP0=" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260323-152405-a2c7b60.tgz", + "hash": "sha256-fWgXdUXh2/dNZqERzEu9hz4xyy4nl+GUjLMpUMrsRnA=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", @@ -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 53e1b6c0267..23464bd5e95 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -171,11 +171,11 @@ in }; } { - name = "N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF"; + name = "N-V-__8AAL6FAwBDPampKgDjoxlJYDIn2jv0VaINS4W6CXJN"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz"; - hash = "sha256-FCALuGoMgUq2lgnVALKAs5a20uuDXt8Gdt5KeJwKqP0="; + url = "https://deps.files.ghostty.org/ghostty-themes-release-20260323-152405-a2c7b60.tgz"; + hash = "sha256-fWgXdUXh2/dNZqERzEu9hz4xyy4nl+GUjLMpUMrsRnA="; }; } { @@ -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 4ac9e659273..f9b0322dd6c 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -6,7 +6,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918 https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz -https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz +https://deps.files.ghostty.org/ghostty-themes-release-20260323-152405-a2c7b60.tgz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz @@ -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/GhosttyZigCompiler.cmake b/dist/cmake/GhosttyZigCompiler.cmake new file mode 100644 index 00000000000..e8efa4e5ef6 --- /dev/null +++ b/dist/cmake/GhosttyZigCompiler.cmake @@ -0,0 +1,74 @@ +# GhosttyZigCompiler.cmake — set up zig cc as a cross compiler +# +# Provides ghostty_zig_compiler() which configures zig cc / zig c++ as +# the C/CXX compiler for a given Zig target triple. It creates small +# wrapper scripts (shell on Unix, .cmd on Windows) and sets the +# following CMake variables in the caller's scope: +# +# CMAKE_C_COMPILER, CMAKE_CXX_COMPILER, +# CMAKE_C_COMPILER_FORCED, CMAKE_CXX_COMPILER_FORCED, +# CMAKE_SYSTEM_NAME, CMAKE_EXECUTABLE_SUFFIX (Windows only) +# +# This file is self-contained with no dependencies on the ghostty +# source tree. Copy it into your project and include it directly. +# It cannot be consumed via FetchContent because it must run before +# project(), but FetchContent_MakeAvailable triggers project() +# internally. +# +# Must be called BEFORE project() — CMake reads the compiler variables +# at project() time and won't re-detect after that. +# +# Usage: +# +# cmake_minimum_required(VERSION 3.19) +# +# include(cmake/GhosttyZigCompiler.cmake) +# ghostty_zig_compiler(ZIG_TARGET x86_64-linux-gnu) +# +# project(myapp LANGUAGES C CXX) +# +# FetchContent_MakeAvailable(ghostty) +# ghostty_vt_add_target(NAME linux-amd64 ZIG_TARGET x86_64-linux-gnu) +# target_link_libraries(myapp PRIVATE ghostty-vt-static-linux-amd64) +# +# See example/c-vt-cmake-cross/ for a complete working example. + +include_guard(GLOBAL) + +function(ghostty_zig_compiler) + cmake_parse_arguments(PARSE_ARGV 0 _GZC "" "ZIG_TARGET" "") + + if(NOT _GZC_ZIG_TARGET) + message(FATAL_ERROR "ghostty_zig_compiler: ZIG_TARGET is required") + endif() + + find_program(_GZC_ZIG zig REQUIRED) + + if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") + set(_cc "${CMAKE_CURRENT_BINARY_DIR}/zig-cc.cmd") + set(_cxx "${CMAKE_CURRENT_BINARY_DIR}/zig-cxx.cmd") + file(WRITE "${_cc}" "@\"${_GZC_ZIG}\" cc -target ${_GZC_ZIG_TARGET} %*\n") + file(WRITE "${_cxx}" "@\"${_GZC_ZIG}\" c++ -target ${_GZC_ZIG_TARGET} %*\n") + else() + set(_cc "${CMAKE_CURRENT_BINARY_DIR}/zig-cc") + set(_cxx "${CMAKE_CURRENT_BINARY_DIR}/zig-c++") + file(WRITE "${_cc}" "#!/bin/sh\nexec \"${_GZC_ZIG}\" cc -target ${_GZC_ZIG_TARGET} \"$@\"\n") + file(WRITE "${_cxx}" "#!/bin/sh\nexec \"${_GZC_ZIG}\" c++ -target ${_GZC_ZIG_TARGET} \"$@\"\n") + file(CHMOD "${_cc}" "${_cxx}" + PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE) + endif() + + set(CMAKE_C_COMPILER "${_cc}" PARENT_SCOPE) + set(CMAKE_CXX_COMPILER "${_cxx}" PARENT_SCOPE) + set(CMAKE_C_COMPILER_FORCED TRUE PARENT_SCOPE) + set(CMAKE_CXX_COMPILER_FORCED TRUE PARENT_SCOPE) + + if(_GZC_ZIG_TARGET MATCHES "windows") + set(CMAKE_SYSTEM_NAME Windows PARENT_SCOPE) + set(CMAKE_EXECUTABLE_SUFFIX ".exe" PARENT_SCOPE) + elseif(_GZC_ZIG_TARGET MATCHES "linux") + set(CMAKE_SYSTEM_NAME Linux PARENT_SCOPE) + elseif(_GZC_ZIG_TARGET MATCHES "darwin|macos") + set(CMAKE_SYSTEM_NAME Darwin PARENT_SCOPE) + endif() +endfunction() diff --git a/dist/cmake/README.md b/dist/cmake/README.md new file mode 100644 index 00000000000..10cd477cebd --- /dev/null +++ b/dist/cmake/README.md @@ -0,0 +1,102 @@ +# 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) +``` + +## Cross-compilation + +For cross-compiling to a different Zig target triple, use +`ghostty_vt_add_target()` after `FetchContent_MakeAvailable`: + +```cmake +FetchContent_MakeAvailable(ghostty) +ghostty_vt_add_target(NAME linux-amd64 ZIG_TARGET x86_64-linux-gnu) + +add_executable(myapp main.c) +target_link_libraries(myapp PRIVATE ghostty-vt-static-linux-amd64) +``` + +### Using zig cc as the C/CXX compiler + +When cross-compiling, the host C compiler can't link binaries for the +target platform. `GhosttyZigCompiler.cmake` provides +`ghostty_zig_compiler()` to set up `zig cc` as the C/CXX compiler for +the cross target. It creates wrapper scripts (shell on Unix, `.cmd` on +Windows) and configures `CMAKE_C_COMPILER`, `CMAKE_CXX_COMPILER`, and +`CMAKE_SYSTEM_NAME`. + +The module is self-contained — copy it into your project (e.g. to +`cmake/`) and include it directly. It cannot be consumed via +FetchContent because it must run before `project()`, but +`FetchContent_MakeAvailable` triggers `project()` internally: + +```cmake +cmake_minimum_required(VERSION 3.19) + +include(cmake/GhosttyZigCompiler.cmake) +ghostty_zig_compiler(ZIG_TARGET x86_64-linux-gnu) + +project(myapp LANGUAGES C CXX) + +FetchContent_MakeAvailable(ghostty) +ghostty_vt_add_target(NAME linux-amd64 ZIG_TARGET x86_64-linux-gnu) + +add_executable(myapp main.c) +target_link_libraries(myapp PRIVATE ghostty-vt-static-linux-amd64) +``` + +See `example/c-vt-cmake-cross/` 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 00000000000..8e1d757296f --- /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 da1fa626e35..4f23c35da68 100644 --- a/dist/linux/com.mitchellh.ghostty.metainfo.xml.in +++ b/dist/linux/com.mitchellh.ghostty.metainfo.xml.in @@ -52,6 +52,9 @@ + + https://ghostty.org/docs/install/release-notes/1-3-1 + https://ghostty.org/docs/install/release-notes/1-3-0 diff --git a/dist/linux/ghostty_dolphin.desktop b/dist/linux/ghostty_dolphin.desktop index 5e83513901a..b9ac7fd2601 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 3fa248f4cae..9f88ccfeb40 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -2,3 +2,5 @@ dist/ node_modules/ example.wasm* +build/ +.build/ diff --git a/example/AGENTS.md b/example/AGENTS.md new file mode 100644 index 00000000000..280b97e1842 --- /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 00000000000..25e41aeeb64 --- /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 00000000000..08fc1cb3c5e --- /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 b4b759744d3..2cd3d307a09 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 00000000000..14966615ae3 --- /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 00000000000..2a05e416d96 --- /dev/null +++ b/example/c-vt-build-info/src/main.c @@ -0,0 +1,52 @@ +#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"); + + GhosttyString version_string = {0}; + size_t version_major = 0; + size_t version_minor = 0; + size_t version_patch = 0; + GhosttyString version_pre = {0}; + GhosttyString version_build = {0}; + + ghostty_build_info(GHOSTTY_BUILD_INFO_VERSION_STRING, &version_string); + ghostty_build_info(GHOSTTY_BUILD_INFO_VERSION_MAJOR, &version_major); + ghostty_build_info(GHOSTTY_BUILD_INFO_VERSION_MINOR, &version_minor); + ghostty_build_info(GHOSTTY_BUILD_INFO_VERSION_PATCH, &version_patch); + ghostty_build_info(GHOSTTY_BUILD_INFO_VERSION_PRE, &version_pre); + ghostty_build_info(GHOSTTY_BUILD_INFO_VERSION_BUILD, &version_build); + + printf("Version: %.*s\n", (int)version_string.len, version_string.ptr); + printf("Version major: %zu\n", version_major); + printf("Version minor: %zu\n", version_minor); + printf("Version patch: %zu\n", version_patch); + if (version_pre.len > 0) { + printf("Version pre : %.*s\n", (int)version_pre.len, version_pre.ptr); + } else { + printf("Version pre : (none)\n"); + } + if (version_build.len > 0) { + printf("Version build: %.*s\n", (int)version_build.len, version_build.ptr); + } else { + printf("Version build: (none)\n"); + } +} +//! [build-info-query] + +int main() { + query_build_info(); + return 0; +} diff --git a/example/c-vt-cmake-cross/CMakeLists.txt b/example/c-vt-cmake-cross/CMakeLists.txt new file mode 100644 index 00000000000..f0cc3685414 --- /dev/null +++ b/example/c-vt-cmake-cross/CMakeLists.txt @@ -0,0 +1,59 @@ +cmake_minimum_required(VERSION 3.19) + +# --- Determine cross-compilation target before project() -------------------- +# +# We need to know the target before project() so we can set up zig cc as the +# C/C++ compiler for the cross target. + +# Pick a cross-compilation target: build for a different OS than the host. +# Can be overridden with -DZIG_TARGET=... on the command line. +if(NOT ZIG_TARGET) + # CMAKE_HOST_SYSTEM_PROCESSOR may not be set before project(), so + # fall back to `uname -m`. + if(CMAKE_HOST_SYSTEM_PROCESSOR) + set(_arch "${CMAKE_HOST_SYSTEM_PROCESSOR}") + else() + execute_process(COMMAND uname -m OUTPUT_VARIABLE _arch OUTPUT_STRIP_TRAILING_WHITESPACE) + endif() + if(_arch MATCHES "^(x86_64|AMD64)$") + set(_arch "x86_64") + elseif(_arch MATCHES "^(aarch64|arm64|ARM64)$") + set(_arch "aarch64") + endif() + + if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux") + set(ZIG_TARGET "${_arch}-windows-gnu") + elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") + set(ZIG_TARGET "${_arch}-linux-gnu") + elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") + set(ZIG_TARGET "${_arch}-linux-gnu") + else() + message(FATAL_ERROR + "Cannot derive ZIG_TARGET for ${CMAKE_HOST_SYSTEM_NAME}. " + "Pass -DZIG_TARGET=... manually.") + endif() + + message(STATUS "Cross-compiling for ZIG_TARGET: ${ZIG_TARGET}") +endif() + +# --- Set up zig cc as the cross compiler ------------------------------------ + +# GhosttyZigCompiler.cmake must be called before project(). +# Downstream projects would copy this file into their tree; here we +# include it directly from the repo. +include(../../dist/cmake/GhosttyZigCompiler.cmake) +ghostty_zig_compiler(ZIG_TARGET "${ZIG_TARGET}") + +project(c-vt-cmake-cross LANGUAGES C CXX) + +include(FetchContent) +FetchContent_Declare(ghostty + GIT_REPOSITORY https://github.com/ghostty-org/ghostty.git + GIT_TAG main +) +FetchContent_MakeAvailable(ghostty) + +ghostty_vt_add_target(NAME cross ZIG_TARGET "${ZIG_TARGET}") + +add_executable(c_vt_cmake_cross src/main.c) +target_link_libraries(c_vt_cmake_cross PRIVATE ghostty-vt-static-cross) diff --git a/example/c-vt-cmake-cross/README.md b/example/c-vt-cmake-cross/README.md new file mode 100644 index 00000000000..e00a8cf3fc2 --- /dev/null +++ b/example/c-vt-cmake-cross/README.md @@ -0,0 +1,21 @@ +# c-vt-cmake-cross + +Demonstrates using `ghostty_vt_add_target()` to cross-compile +libghostty-vt with static linking. The target OS is chosen automatically: + +| Host | Target | +| ------- | --------------- | +| Linux | Windows (MinGW) | +| Windows | Linux (glibc) | +| macOS | Linux (glibc) | + +Override with `-DZIG_TARGET=...` if needed. + +## Building + +```shell-session +cd example/c-vt-cmake-cross +cmake -B build -DFETCHCONTENT_SOURCE_DIR_GHOSTTY=../.. +cmake --build build +file build/c_vt_cmake_cross +``` diff --git a/example/c-vt-cmake-cross/src/main.c b/example/c-vt-cmake-cross/src/main.c new file mode 100644 index 00000000000..9925864511f --- /dev/null +++ b/example/c-vt-cmake-cross/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-cmake-static/CMakeLists.txt b/example/c-vt-cmake-static/CMakeLists.txt new file mode 100644 index 00000000000..bb4b1ac35f1 --- /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 00000000000..6aa503e0411 --- /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 00000000000..233bd34d16b --- /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 00000000000..ff6e35bc1a5 --- /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 00000000000..d76ca946d78 --- /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 00000000000..9925864511f --- /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-colors/README.md b/example/c-vt-colors/README.md new file mode 100644 index 00000000000..881abfcc21a --- /dev/null +++ b/example/c-vt-colors/README.md @@ -0,0 +1,18 @@ +# Example: `ghostty-vt` Terminal Colors + +This contains a simple example of how to set default terminal colors, +read effective and default color values, and observe how OSC overrides +layer on top of defaults using 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-colors/build.zig b/example/c-vt-colors/build.zig new file mode 100644 index 00000000000..ddb62ece389 --- /dev/null +++ b/example/c-vt-colors/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_colors", + .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-colors/build.zig.zon b/example/c-vt-colors/build.zig.zon new file mode 100644 index 00000000000..3d0023d3d1e --- /dev/null +++ b/example/c-vt-colors/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_colors, + .version = "0.0.0", + .fingerprint = 0xe7ec4247f16d4fce, + .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-colors/src/main.c b/example/c-vt-colors/src/main.c new file mode 100644 index 00000000000..6838527f23e --- /dev/null +++ b/example/c-vt-colors/src/main.c @@ -0,0 +1,121 @@ +#include +#include +#include +#include +#include +#include + +//! [colors-set-defaults] +/// Set up a dark color theme with custom palette entries. +void set_color_theme(GhosttyTerminal terminal) { + // Set default foreground (light gray) and background (dark) + GhosttyColorRgb fg = { .r = 0xDD, .g = 0xDD, .b = 0xDD }; + GhosttyColorRgb bg = { .r = 0x1E, .g = 0x1E, .b = 0x2E }; + GhosttyColorRgb cursor = { .r = 0xF5, .g = 0xE0, .b = 0xDC }; + + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_COLOR_FOREGROUND, &fg); + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_COLOR_BACKGROUND, &bg); + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_COLOR_CURSOR, &cursor); + + // Set a custom palette — start from the built-in default and override + // the first 8 entries with a custom dark theme. + GhosttyColorRgb palette[256]; + ghostty_terminal_get(terminal, GHOSTTY_TERMINAL_DATA_COLOR_PALETTE, palette); + + palette[GHOSTTY_COLOR_NAMED_BLACK] = (GhosttyColorRgb){ 0x45, 0x47, 0x5A }; + palette[GHOSTTY_COLOR_NAMED_RED] = (GhosttyColorRgb){ 0xF3, 0x8B, 0xA8 }; + palette[GHOSTTY_COLOR_NAMED_GREEN] = (GhosttyColorRgb){ 0xA6, 0xE3, 0xA1 }; + palette[GHOSTTY_COLOR_NAMED_YELLOW] = (GhosttyColorRgb){ 0xF9, 0xE2, 0xAF }; + palette[GHOSTTY_COLOR_NAMED_BLUE] = (GhosttyColorRgb){ 0x89, 0xB4, 0xFA }; + palette[GHOSTTY_COLOR_NAMED_MAGENTA] = (GhosttyColorRgb){ 0xF5, 0xC2, 0xE7 }; + palette[GHOSTTY_COLOR_NAMED_CYAN] = (GhosttyColorRgb){ 0x94, 0xE2, 0xD5 }; + palette[GHOSTTY_COLOR_NAMED_WHITE] = (GhosttyColorRgb){ 0xBA, 0xC2, 0xDE }; + + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_COLOR_PALETTE, palette); +} +//! [colors-set-defaults] + +//! [colors-read] +/// Print the effective and default values for a color, showing how +/// OSC overrides layer on top of defaults. +void print_color(GhosttyTerminal terminal, + const char* name, + GhosttyTerminalData effective_data, + GhosttyTerminalData default_data) { + GhosttyColorRgb color; + + GhosttyResult res = ghostty_terminal_get(terminal, effective_data, &color); + if (res == GHOSTTY_SUCCESS) { + printf(" %-12s effective: #%02X%02X%02X", name, color.r, color.g, color.b); + } else { + printf(" %-12s effective: (not set)", name); + } + + res = ghostty_terminal_get(terminal, default_data, &color); + if (res == GHOSTTY_SUCCESS) { + printf(" default: #%02X%02X%02X\n", color.r, color.g, color.b); + } else { + printf(" default: (not set)\n"); + } +} + +void print_all_colors(GhosttyTerminal terminal, const char* label) { + printf("%s:\n", label); + print_color(terminal, "foreground", + GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND, + GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND_DEFAULT); + print_color(terminal, "background", + GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND, + GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND_DEFAULT); + print_color(terminal, "cursor", + GHOSTTY_TERMINAL_DATA_COLOR_CURSOR, + GHOSTTY_TERMINAL_DATA_COLOR_CURSOR_DEFAULT); + + // Show palette index 0 (black) as an example + GhosttyColorRgb palette[256]; + ghostty_terminal_get(terminal, GHOSTTY_TERMINAL_DATA_COLOR_PALETTE, palette); + printf(" %-12s effective: #%02X%02X%02X", "palette[0]", + palette[0].r, palette[0].g, palette[0].b); + + ghostty_terminal_get(terminal, GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT, + palette); + printf(" default: #%02X%02X%02X\n", palette[0].r, palette[0].g, palette[0].b); +} +//! [colors-read] + +//! [colors-main] +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; + } + + // Before setting any colors, everything is unset + print_all_colors(terminal, "Before setting defaults"); + + // Set our color theme defaults + set_color_theme(terminal); + print_all_colors(terminal, "\nAfter setting defaults"); + + // Simulate an OSC override (e.g. a program running inside the + // terminal changes the foreground via OSC 10) + const char* osc_fg = "\x1B]10;rgb:FF/00/00\x1B\\"; + ghostty_terminal_vt_write(terminal, (const uint8_t*)osc_fg, + strlen(osc_fg)); + print_all_colors(terminal, "\nAfter OSC foreground override"); + + // Clear the foreground default — the OSC override is still active + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_COLOR_FOREGROUND, NULL); + print_all_colors(terminal, "\nAfter clearing foreground default"); + + ghostty_terminal_free(terminal); + return 0; +} +//! [colors-main] diff --git a/example/c-vt-effects/README.md b/example/c-vt-effects/README.md new file mode 100644 index 00000000000..5f5a22b14bb --- /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 00000000000..c3b1af73b75 --- /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 00000000000..0275f4f6835 --- /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 00000000000..1e3a3d64549 --- /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 00000000000..f433e88084e --- /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 00000000000..2904371fb1b --- /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 00000000000..0da20475cdb --- /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 00000000000..15854792f62 --- /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 00000000000..de878a7adc9 --- /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 00000000000..99b782022fb --- /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 00000000000..754e098052f --- /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 00000000000..4fdb353c011 --- /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 00000000000..1ab5da284c6 --- /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 00000000000..d75ed9c5483 --- /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 00000000000..f416c8dbdbe --- /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 00000000000..637b48f13cb --- /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 00000000000..a14f0aedbf7 --- /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 00000000000..56f9d1220f1 --- /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 00000000000..f9a15851a91 --- /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 00000000000..caf1740283f --- /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 00000000000..21b6cea184e --- /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 00000000000..f07169eb683 --- /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 82444f99d2e..00000000000 --- 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-kitty-graphics/README.md b/example/c-vt-kitty-graphics/README.md new file mode 100644 index 00000000000..cbeb6747671 --- /dev/null +++ b/example/c-vt-kitty-graphics/README.md @@ -0,0 +1,18 @@ +# Example: `ghostty-vt` Kitty Graphics Protocol + +This contains a simple example of how to use the system interface +(`ghostty_sys_set`) to install a PNG decoder callback, then send +a Kitty Graphics Protocol image via `ghostty_terminal_vt_write`. + +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-kitty-graphics/build.zig b/example/c-vt-kitty-graphics/build.zig new file mode 100644 index 00000000000..4bbf9e3ff7a --- /dev/null +++ b/example/c-vt-kitty-graphics/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_kitty_graphics", + .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-kitty-graphics/build.zig.zon b/example/c-vt-kitty-graphics/build.zig.zon new file mode 100644 index 00000000000..fce0e590678 --- /dev/null +++ b/example/c-vt-kitty-graphics/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_kitty_graphics, + .version = "0.0.0", + .fingerprint = 0x432d40ecc8f15589, + .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-kitty-graphics/src/main.c b/example/c-vt-kitty-graphics/src/main.c new file mode 100644 index 00000000000..d88ee195227 --- /dev/null +++ b/example/c-vt-kitty-graphics/src/main.c @@ -0,0 +1,211 @@ +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> +#include <stdio.h> +#include <string.h> +#include <ghostty/vt.h> + +//! [kitty-graphics-decode-png] +/** + * Minimal PNG decoder callback for the sys interface. + * + * A real implementation would use a PNG library (libpng, stb_image, etc.) + * to decode the PNG data. This example uses a hardcoded 1x1 red pixel + * since we know exactly what image we're sending. + * + * WARNING: This is only an example for providing a callback, it DOES NOT + * actually decode the PNG it is passed. It hardcodes a response. + */ +bool decode_png(void* userdata, + const GhosttyAllocator* allocator, + const uint8_t* data, + size_t data_len, + GhosttySysImage* out) { + int* count = (int*)userdata; + (*count)++; + printf(" decode_png called (size=%zu, call #%d)\n", data_len, *count); + + /* Allocate RGBA pixel data through the provided allocator. */ + const size_t pixel_len = 4; /* 1x1 RGBA */ + uint8_t* pixels = ghostty_alloc(allocator, pixel_len); + if (!pixels) return false; + + /* Fill with red (R=255, G=0, B=0, A=255). */ + pixels[0] = 255; + pixels[1] = 0; + pixels[2] = 0; + pixels[3] = 255; + + out->width = 1; + out->height = 1; + out->data = pixels; + out->data_len = pixel_len; + return true; +} +//! [kitty-graphics-decode-png] + +//! [kitty-graphics-write-pty] +/** + * write_pty callback to capture terminal responses. + * + * The Kitty graphics protocol sends an APC response back to the pty + * when an image is loaded (unless suppressed with q=2). + */ +void on_write_pty(GhosttyTerminal terminal, + void* userdata, + const uint8_t* data, + size_t len) { + (void)terminal; + (void)userdata; + printf(" response (%zu bytes): ", len); + fwrite(data, 1, len, stdout); + printf("\n"); +} +//! [kitty-graphics-write-pty] + +//! [kitty-graphics-main] +int main() { + /* Install the PNG decoder via the sys interface. */ + int decode_count = 0; + ghostty_sys_set(GHOSTTY_SYS_OPT_USERDATA, &decode_count); + ghostty_sys_set(GHOSTTY_SYS_OPT_DECODE_PNG, (const void*)decode_png); + + /* Create a terminal with Kitty graphics enabled. */ + 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 cell pixel dimensions so kitty graphics can compute grid sizes. */ + ghostty_terminal_resize(terminal, 80, 24, 8, 16); + + /* Set a storage limit to enable Kitty graphics. */ + uint64_t storage_limit = 64 * 1024 * 1024; /* 64 MiB */ + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT, + &storage_limit); + + /* Install write_pty to see the protocol response. */ + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_WRITE_PTY, + (const void*)on_write_pty); + + /* + * Send a Kitty graphics command with an inline 1x1 PNG image. + * + * The escape sequence is: + * ESC _G a=T,f=100,q=1; <base64 PNG data> ESC \ + * + * Where: + * a=T — transmit and display + * f=100 — PNG format + * q=1 — request a response (q=0 would suppress it) + */ + printf("Sending Kitty graphics PNG image:\n"); + const char* kitty_cmd = + "\x1b_Ga=T,f=100,q=1;" + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAA" + "DUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" + "\x1b\\"; + ghostty_terminal_vt_write(terminal, (const uint8_t*)kitty_cmd, + strlen(kitty_cmd)); + + printf("PNG decode calls: %d\n", decode_count); + + /* Query the kitty graphics storage to verify the image was stored. */ + GhosttyKittyGraphics graphics = NULL; + if (ghostty_terminal_get(terminal, GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS, + &graphics) != GHOSTTY_SUCCESS || !graphics) { + fprintf(stderr, "Failed to get kitty graphics storage\n"); + return 1; + } + printf("\nKitty graphics storage is available.\n"); + + /* Iterate placements to find the image ID. */ + GhosttyKittyGraphicsPlacementIterator iter = NULL; + if (ghostty_kitty_graphics_placement_iterator_new(NULL, &iter) != GHOSTTY_SUCCESS) { + fprintf(stderr, "Failed to create placement iterator\n"); + return 1; + } + if (ghostty_kitty_graphics_get(graphics, + GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR, &iter) != GHOSTTY_SUCCESS) { + fprintf(stderr, "Failed to get placement iterator\n"); + return 1; + } + + int placement_count = 0; + while (ghostty_kitty_graphics_placement_next(iter)) { + placement_count++; + uint32_t image_id = 0; + uint32_t placement_id = 0; + bool is_virtual = false; + int32_t z = 0; + + ghostty_kitty_graphics_placement_get_multi(iter, 4, + (GhosttyKittyGraphicsPlacementData[]){ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID, + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID, + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IS_VIRTUAL, + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Z, + }, + (void*[]){ &image_id, &placement_id, &is_virtual, &z }, + NULL); + + printf(" placement #%d: image_id=%u placement_id=%u virtual=%s z=%d\n", + placement_count, image_id, placement_id, + is_virtual ? "true" : "false", z); + + /* Look up the image and print its properties. */ + GhosttyKittyGraphicsImage image = + ghostty_kitty_graphics_image(graphics, image_id); + if (!image) { + fprintf(stderr, "Failed to look up image %u\n", image_id); + return 1; + } + + uint32_t width = 0, height = 0, number = 0; + GhosttyKittyImageFormat format = 0; + size_t data_len = 0; + + ghostty_kitty_graphics_image_get_multi(image, 5, + (GhosttyKittyGraphicsImageData[]){ + GHOSTTY_KITTY_IMAGE_DATA_NUMBER, + GHOSTTY_KITTY_IMAGE_DATA_WIDTH, + GHOSTTY_KITTY_IMAGE_DATA_HEIGHT, + GHOSTTY_KITTY_IMAGE_DATA_FORMAT, + GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN, + }, + (void*[]){ &number, &width, &height, &format, &data_len }, + NULL); + + printf(" image: number=%u size=%ux%u format=%d data_len=%zu\n", + number, width, height, format, data_len); + + /* Compute the rendered pixel size and grid size. */ + uint32_t px_w = 0, px_h = 0, cols = 0, rows = 0; + if (ghostty_kitty_graphics_placement_pixel_size(iter, image, terminal, + &px_w, &px_h) == GHOSTTY_SUCCESS) { + printf(" rendered pixel size: %ux%u\n", px_w, px_h); + } + if (ghostty_kitty_graphics_placement_grid_size(iter, image, terminal, + &cols, &rows) == GHOSTTY_SUCCESS) { + printf(" grid size: %u cols x %u rows\n", cols, rows); + } + } + printf("Total placements: %d\n", placement_count); + ghostty_kitty_graphics_placement_iterator_free(iter); + + /* Clean up. */ + ghostty_terminal_free(terminal); + + /* Clear the sys callbacks. */ + ghostty_sys_set(GHOSTTY_SYS_OPT_DECODE_PNG, NULL); + ghostty_sys_set(GHOSTTY_SYS_OPT_USERDATA, NULL); + + return 0; +} +//! [kitty-graphics-main] diff --git a/example/c-vt-modes/README.md b/example/c-vt-modes/README.md new file mode 100644 index 00000000000..bd43c17996b --- /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 00000000000..1a4b3f8d82f --- /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 00000000000..bdfeefdcac6 --- /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 00000000000..e957c97777e --- /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/README.md b/example/c-vt-paste/README.md index 0f911771f8e..377cd3c3b88 100644 --- a/example/c-vt-paste/README.md +++ b/example/c-vt-paste/README.md @@ -1,7 +1,7 @@ -# Example: `ghostty-vt` Paste Safety Check +# Example: `ghostty-vt` Paste Utilities This contains a simple example of how to use the `ghostty-vt` paste -utilities to check if paste data is safe. +utilities to check if paste data is safe and encode it for terminal input. 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 diff --git a/example/c-vt-paste/src/main.c b/example/c-vt-paste/src/main.c index 153861ca9ef..e6e4b3d61c6 100644 --- a/example/c-vt-paste/src/main.c +++ b/example/c-vt-paste/src/main.c @@ -2,18 +2,41 @@ #include <string.h> #include <ghostty/vt.h> -int main() { - // Test safe paste data - const char *safe_data = "hello world"; +//! [paste-safety] +void safety_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] + +//! [paste-encode] +void encode_example() { + // The input buffer is modified in place (unsafe bytes are stripped). + char data[] = "hello\nworld"; + char buf[64]; + size_t written = 0; + + GhosttyResult result = ghostty_paste_encode( + data, strlen(data), true, buf, sizeof(buf), &written); + + if (result == GHOSTTY_SUCCESS) { + printf("Encoded %zu bytes: ", written); + fwrite(buf, 1, written, stdout); + printf("\n"); + } +} +//! [paste-encode] + +int main() { + safety_example(); // Test unsafe paste data with bracketed paste end sequence const char *unsafe_escape = "evil\x1b[201~code"; @@ -27,5 +50,7 @@ int main() { printf("Empty data is safe\n"); } + encode_example(); + return 0; } diff --git a/example/c-vt-render/README.md b/example/c-vt-render/README.md new file mode 100644 index 00000000000..3725ed46f15 --- /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 00000000000..15e3e540528 --- /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 00000000000..3919970f95a --- /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 00000000000..0714d416033 --- /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 21a5297265a..e213c0c93f1 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 00000000000..0e6ef2c8575 --- /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 00000000000..fbd0f5e2309 --- /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 00000000000..71d10d343ed --- /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 00000000000..99e9c10dc2c --- /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 00000000000..52da4ddb0c7 --- /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 00000000000..0e53d69c529 --- /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 00000000000..413bf66fb9a --- /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 00000000000..b1297d7a76d --- /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/c-vt-stream/README.md b/example/c-vt-stream/README.md new file mode 100644 index 00000000000..6620537ed47 --- /dev/null +++ b/example/c-vt-stream/README.md @@ -0,0 +1,19 @@ +# Example: VT Stream Processing in C + +This contains a simple example of how to use `ghostty_terminal_vt_write` +to parse and process VT sequences in C. This is the C equivalent of +the `zig-vt-stream` example, ideal for read-only terminal applications +such as replay tooling, CI log viewers, and PaaS builder output. + +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-stream/build.zig b/example/c-vt-stream/build.zig new file mode 100644 index 00000000000..21575ddd6bc --- /dev/null +++ b/example/c-vt-stream/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_stream", + .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-stream/build.zig.zon b/example/c-vt-stream/build.zig.zon new file mode 100644 index 00000000000..4c37e852c54 --- /dev/null +++ b/example/c-vt-stream/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_stream, + .version = "0.0.0", + .fingerprint = 0xd5bb3fc45e3f4dfc, + .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-stream/src/main.c b/example/c-vt-stream/src/main.c new file mode 100644 index 00000000000..7063e1f1472 --- /dev/null +++ b/example/c-vt-stream/src/main.c @@ -0,0 +1,74 @@ +#include <assert.h> +#include <stdio.h> +#include <string.h> +#include <ghostty/vt.h> + +int main(void) { + //! [vt-stream-init] + // Create a terminal + GhosttyTerminal terminal; + GhosttyTerminalOptions opts = { + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }; + GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts); + assert(result == GHOSTTY_SUCCESS); + //! [vt-stream-init] + + //! [vt-stream-write] + // Feed VT data into the terminal + const char *text = "Hello, World!\r\n"; + ghostty_terminal_vt_write(terminal, (const uint8_t *)text, strlen(text)); + + // ANSI color codes: ESC[1;32m = bold green, ESC[0m = reset + text = "\x1b[1;32mGreen Text\x1b[0m\r\n"; + ghostty_terminal_vt_write(terminal, (const uint8_t *)text, strlen(text)); + + // Cursor positioning: ESC[1;1H = move to row 1, column 1 + text = "\x1b[1;1HTop-left corner\r\n"; + ghostty_terminal_vt_write(terminal, (const uint8_t *)text, strlen(text)); + + // Cursor movement: ESC[5B = move down 5 lines + text = "\x1b[5B"; + ghostty_terminal_vt_write(terminal, (const uint8_t *)text, strlen(text)); + text = "Moved down!\r\n"; + ghostty_terminal_vt_write(terminal, (const uint8_t *)text, strlen(text)); + + // Erase line: ESC[2K = clear entire line + text = "\x1b[2K"; + ghostty_terminal_vt_write(terminal, (const uint8_t *)text, strlen(text)); + text = "New content\r\n"; + ghostty_terminal_vt_write(terminal, (const uint8_t *)text, strlen(text)); + + // Multiple lines + text = "Line A\r\nLine B\r\nLine C\r\n"; + ghostty_terminal_vt_write(terminal, (const uint8_t *)text, strlen(text)); + //! [vt-stream-write] + + //! [vt-stream-read] + // Get the final terminal state as a plain string using the formatter + 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); + + fwrite(buf, 1, len, stdout); + printf("\n"); + + ghostty_free(NULL, buf, len); + ghostty_formatter_free(formatter); + //! [vt-stream-read] + + ghostty_terminal_free(terminal); + return 0; +} diff --git a/example/cpp-vt-stream/README.md b/example/cpp-vt-stream/README.md new file mode 100644 index 00000000000..7dccbe30166 --- /dev/null +++ b/example/cpp-vt-stream/README.md @@ -0,0 +1,19 @@ +# Example: VT Stream Processing in C++ + +This contains a simple example of how to use `ghostty_terminal_vt_write` +to parse and process VT sequences in C++. This is a simplified C++ port +of the `c-vt-stream` example that verifies libghostty compiles in C++ +mode. + +> [!IMPORTANT] +> +> **`libghostty` is a C library.** This example is only here so our CI +> verifies that the library can be built in used from C++ files. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/cpp-vt-stream/build.zig b/example/cpp-vt-stream/build.zig new file mode 100644 index 00000000000..c1fd8708164 --- /dev/null +++ b/example/cpp-vt-stream/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.cpp"}, + }); + + // 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 = "cpp_vt_stream", + .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/cpp-vt-stream/build.zig.zon b/example/cpp-vt-stream/build.zig.zon new file mode 100644 index 00000000000..bc6a39a0e77 --- /dev/null +++ b/example/cpp-vt-stream/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .cpp_vt_stream, + .version = "0.0.0", + .fingerprint = 0x112f5d044ef8c2ac, + .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/cpp-vt-stream/src/main.cpp b/example/cpp-vt-stream/src/main.cpp new file mode 100644 index 00000000000..a77f98ad506 --- /dev/null +++ b/example/cpp-vt-stream/src/main.cpp @@ -0,0 +1,49 @@ +#include <cassert> +#include <cstdio> +#include <cstring> +#include <ghostty/vt.h> + +int main() { + // Create a terminal + GhosttyTerminal terminal; + GhosttyTerminalOptions opts = { + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }; + GhosttyResult result = ghostty_terminal_new(nullptr, &terminal, opts); + assert(result == GHOSTTY_SUCCESS); + + // Feed VT data into the terminal + const char *text = "Hello from C++!\r\n"; + ghostty_terminal_vt_write(terminal, reinterpret_cast<const uint8_t *>(text), std::strlen(text)); + + text = "\x1b[1;32mGreen Text\x1b[0m\r\n"; + ghostty_terminal_vt_write(terminal, reinterpret_cast<const uint8_t *>(text), std::strlen(text)); + + text = "\x1b[1;1HTop-left corner\r\n"; + ghostty_terminal_vt_write(terminal, reinterpret_cast<const uint8_t *>(text), std::strlen(text)); + + // Get the final terminal state as a plain string + 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(nullptr, &formatter, terminal, fmt_opts); + assert(result == GHOSTTY_SUCCESS); + + uint8_t *buf = nullptr; + size_t len = 0; + result = ghostty_formatter_format_alloc(formatter, nullptr, &buf, &len); + assert(result == GHOSTTY_SUCCESS); + + std::fwrite(buf, 1, len, stdout); + std::printf("\n"); + + ghostty_free(nullptr, buf, len); + ghostty_formatter_free(formatter); + ghostty_terminal_free(terminal); + return 0; +} diff --git a/example/swift-vt-xcframework/Package.swift b/example/swift-vt-xcframework/Package.swift new file mode 100644 index 00000000000..a831a42c849 --- /dev/null +++ b/example/swift-vt-xcframework/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "swift-vt-xcframework", + platforms: [.macOS(.v13)], + targets: [ + .executableTarget( + name: "swift-vt-xcframework", + dependencies: ["GhosttyVt"], + path: "Sources", + linkerSettings: [ + .linkedLibrary("c++"), + ] + ), + .binaryTarget( + name: "GhosttyVt", + path: "../../zig-out/lib/ghostty-vt.xcframework" + ), + ] +) diff --git a/example/swift-vt-xcframework/README.md b/example/swift-vt-xcframework/README.md new file mode 100644 index 00000000000..3bbe8948ca6 --- /dev/null +++ b/example/swift-vt-xcframework/README.md @@ -0,0 +1,23 @@ +# swift-vt-xcframework + +Demonstrates consuming libghostty-vt from a Swift Package using the +pre-built XCFramework. Creates a terminal, writes VT sequences into it, +and formats the screen contents as plain text. + +This example requires the XCFramework to be built first. + +## Building + +First, build the XCFramework from the repository root: + +```shell-session +zig build -Demit-lib-vt +``` + +Then build and run the Swift package: + +```shell-session +cd example/swift-vt-xcframework +swift build +swift run +``` diff --git a/example/swift-vt-xcframework/Sources/main.swift b/example/swift-vt-xcframework/Sources/main.swift new file mode 100644 index 00000000000..d374f539f75 --- /dev/null +++ b/example/swift-vt-xcframework/Sources/main.swift @@ -0,0 +1,47 @@ +import Foundation +import GhosttyVt + +// Create a terminal with a small grid +var terminal: GhosttyTerminal? +var opts = GhosttyTerminalOptions( + cols: 80, + rows: 24, + max_scrollback: 0 +) +let result = ghostty_terminal_new(nil, &terminal, opts) +guard result == GHOSTTY_SUCCESS, let terminal else { + fatalError("Failed to create terminal") +} + +// Write some VT-encoded content +let text = "Hello from \u{1b}[1mSwift\u{1b}[0m via xcframework!\r\n" +text.withCString { ptr in + ghostty_terminal_vt_write(terminal, ptr, strlen(ptr)) +} + +// Format the terminal contents as plain text +var fmtOpts = GhosttyFormatterTerminalOptions() +fmtOpts.size = MemoryLayout<GhosttyFormatterTerminalOptions>.size +fmtOpts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN +fmtOpts.trim = true + +var formatter: GhosttyFormatter? +let fmtResult = ghostty_formatter_terminal_new(nil, &formatter, terminal, fmtOpts) +guard fmtResult == GHOSTTY_SUCCESS, let formatter else { + fatalError("Failed to create formatter") +} + +var buf: UnsafeMutablePointer<UInt8>? +var len: Int = 0 +let allocResult = ghostty_formatter_format_alloc(formatter, nil, &buf, &len) +guard allocResult == GHOSTTY_SUCCESS, let buf else { + fatalError("Failed to format") +} + +print("Plain text (\(len) bytes):") +let data = Data(bytes: buf, count: len) +print(String(data: data, encoding: .utf8) ?? "<invalid UTF-8>") + +ghostty_free(nil, buf, len) +ghostty_formatter_free(formatter) +ghostty_terminal_free(terminal) diff --git a/example/wasm-key-encode/README.md b/example/wasm-key-encode/README.md index ccd906cf793..e528449911c 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 a107c910d43..465d6fdbb3c 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/wasm-vt/README.md b/example/wasm-vt/README.md new file mode 100644 index 00000000000..92e928405c6 --- /dev/null +++ b/example/wasm-vt/README.md @@ -0,0 +1,39 @@ +# WebAssembly VT Terminal Example + +This example demonstrates how to use the Ghostty VT library from WebAssembly +to initialize a terminal, write VT-encoded data to it, and format the +terminal contents as plain text. + +## Building + +First, build the WebAssembly module: + +```bash +zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall +``` + +This will create `zig-out/bin/ghostty-vt.wasm`. + +## Running + +**Important:** You must serve this via HTTP, not open it as a file directly. +Browsers block loading WASM files from `file://` URLs. + +From the **root of the ghostty repository**, serve with a local HTTP server: + +```bash +# Using Python (recommended) +python3 -m http.server 8000 + +# Or using Node.js +npx serve . + +# Or using PHP +php -S localhost:8000 +``` + +Then open your browser to: + +``` +http://localhost:8000/example/wasm-vt/ +``` diff --git a/example/wasm-vt/index.html b/example/wasm-vt/index.html new file mode 100644 index 00000000000..d720e23757a --- /dev/null +++ b/example/wasm-vt/index.html @@ -0,0 +1,342 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Ghostty VT Terminal - WebAssembly Example + + + +

Ghostty VT Terminal - WebAssembly Example

+

This example demonstrates initializing a terminal, writing VT-encoded data to it, and formatting the output using the Ghostty VT WebAssembly module.

+ +
Loading WebAssembly module...
+ +
+

Terminal Size

+
+ + +
+

VT Input

+ +

Use \x1b for ESC, \r\n for CR+LF. Press "Run" to process.

+ +
+ +
Waiting for input...
+ +

Note: This example must be served via HTTP (not opened directly as a file). See the README for instructions.

+ + + + diff --git a/example/zig-formatter/src/main.zig b/example/zig-formatter/src/main.zig index ad101dbf177..df21a20468f 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 8fd438b7027..87d8857ddb1 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 6f12f66b92d..f3a4814bbd2 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 e063f2d70d7..19e7b3157c9 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"; }; }; @@ -87,18 +91,36 @@ }); packages = - forAllPlatforms (pkgs: { - # Deps are needed for environmental setup on macOS - deps = pkgs.callPackage ./build.zig.zon.nix {}; - }) - // forBuildablePlatforms (pkgs: rec { - ghostty-debug = pkgs.callPackage ./nix/package.nix (mkPkgArgs "Debug"); - ghostty-releasesafe = pkgs.callPackage ./nix/package.nix (mkPkgArgs "ReleaseSafe"); - ghostty-releasefast = pkgs.callPackage ./nix/package.nix (mkPkgArgs "ReleaseFast"); - - ghostty = ghostty-releasefast; - default = ghostty; - }); + builtins.foldl' + lib.recursiveUpdate + {} + [ + ( + forAllPlatforms (pkgs: rec { + # Deps are needed for environmental setup on macOS + deps = pkgs.callPackage ./build.zig.zon.nix {}; + + libghostty-vt-debug = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "Debug"); + libghostty-vt-releasesafe = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "ReleaseSafe"); + libghostty-vt-releasefast = pkgs.callPackage ./nix/libghostty-vt.nix (mkPkgArgs "ReleaseFast"); + libghostty-vt-debug-no-simd = pkgs.callPackage ./nix/libghostty-vt.nix ((mkPkgArgs "Debug") // {simd = false;}); + libghostty-vt-releasesafe-no-simd = pkgs.callPackage ./nix/libghostty-vt.nix ((mkPkgArgs "ReleaseSafe") // {simd = false;}); + libghostty-vt-releasefast-no-simd = pkgs.callPackage ./nix/libghostty-vt.nix ((mkPkgArgs "ReleaseFast") // {simd = false;}); + + libghostty-vt = libghostty-vt-releasefast; + }) + ) + ( + forBuildablePlatforms (pkgs: rec { + ghostty-debug = pkgs.callPackage ./nix/package.nix (mkPkgArgs "Debug"); + ghostty-releasesafe = pkgs.callPackage ./nix/package.nix (mkPkgArgs "ReleaseSafe"); + ghostty-releasefast = pkgs.callPackage ./nix/package.nix (mkPkgArgs "ReleaseFast"); + + ghostty = ghostty-releasefast; + default = ghostty; + }) + ) + ]; formatter = forAllPlatforms (pkgs: pkgs.alejandra); diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index e58ecd4488b..6a7e4e521c7 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -67,9 +67,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz", - "dest": "vendor/p/N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF", - "sha256": "14200bb86a0c814ab69609d500b280b396b6d2eb835edf0676de4a789c0aa8fd" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260323-152405-a2c7b60.tgz", + "dest": "vendor/p/N-V-__8AAL6FAwBDPampKgDjoxlJYDIn2jv0VaINS4W6CXJN", + "sha256": "7d68177545e1dbf74d66a111cc4bbd873e31cb2e2797e1948cb32950caec4670" }, { "type": "archive", @@ -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 19a200f100e..afc20bb3f5b 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -15,13 +15,41 @@ extern "C" { #include #include #include + +#ifdef _MSC_VER +#include +typedef SSIZE_T ssize_t; +#else #include +#endif //------------------------------------------------------------------- // Macros #define GHOSTTY_SUCCESS 0 +// Symbol visibility for shared library builds. On Windows, functions +// are exported from the DLL when building and imported when consuming. +// On other platforms with GCC/Clang, functions are marked with default +// visibility so they remain accessible when the library is built with +// -fvisibility=hidden. For static library builds, define GHOSTTY_STATIC +// before including this header to make this a no-op. +#ifndef GHOSTTY_API +#if defined(GHOSTTY_STATIC) + #define GHOSTTY_API +#elif defined(_WIN32) || defined(_WIN64) + #ifdef GHOSTTY_BUILD_SHARED + #define GHOSTTY_API __declspec(dllexport) + #else + #define GHOSTTY_API __declspec(dllimport) + #endif +#elif defined(__GNUC__) && __GNUC__ >= 4 + #define GHOSTTY_API __attribute__((visibility("default"))) +#else + #define GHOSTTY_API +#endif +#endif + //------------------------------------------------------------------- // Types @@ -889,6 +917,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 +966,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 +998,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)( @@ -1030,144 +1060,144 @@ typedef enum { //------------------------------------------------------------------- // Published API -int ghostty_init(uintptr_t, char**); -void ghostty_cli_try_action(void); -ghostty_info_s ghostty_info(void); -const char* ghostty_translate(const char*); -void ghostty_string_free(ghostty_string_s); - -ghostty_config_t ghostty_config_new(); -void ghostty_config_free(ghostty_config_t); -ghostty_config_t ghostty_config_clone(ghostty_config_t); -void ghostty_config_load_cli_args(ghostty_config_t); -void ghostty_config_load_file(ghostty_config_t, const char*); -void ghostty_config_load_default_files(ghostty_config_t); -void ghostty_config_load_recursive_files(ghostty_config_t); -void ghostty_config_finalize(ghostty_config_t); -bool ghostty_config_get(ghostty_config_t, void*, const char*, uintptr_t); -ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t, - const char*, - uintptr_t); -uint32_t ghostty_config_diagnostics_count(ghostty_config_t); -ghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t); -ghostty_string_s ghostty_config_open_path(void); - -ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*, - ghostty_config_t); -void ghostty_app_free(ghostty_app_t); -void ghostty_app_tick(ghostty_app_t); -void* ghostty_app_userdata(ghostty_app_t); -void ghostty_app_set_focus(ghostty_app_t, bool); -bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s); -bool ghostty_app_key_is_binding(ghostty_app_t, ghostty_input_key_s); -void ghostty_app_keyboard_changed(ghostty_app_t); -void ghostty_app_open_config(ghostty_app_t); -void ghostty_app_update_config(ghostty_app_t, ghostty_config_t); -bool ghostty_app_needs_confirm_quit(ghostty_app_t); -bool ghostty_app_has_global_keybinds(ghostty_app_t); -void ghostty_app_set_color_scheme(ghostty_app_t, ghostty_color_scheme_e); - -ghostty_surface_config_s ghostty_surface_config_new(); - -ghostty_surface_t ghostty_surface_new(ghostty_app_t, - const ghostty_surface_config_s*); -void ghostty_surface_free(ghostty_surface_t); -void* ghostty_surface_userdata(ghostty_surface_t); -ghostty_app_t ghostty_surface_app(ghostty_surface_t); -ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t, ghostty_surface_context_e); -void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t); -bool ghostty_surface_needs_confirm_quit(ghostty_surface_t); -bool ghostty_surface_process_exited(ghostty_surface_t); -void ghostty_surface_refresh(ghostty_surface_t); -void ghostty_surface_draw(ghostty_surface_t); -void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); -void ghostty_surface_set_focus(ghostty_surface_t, bool); -void ghostty_surface_set_occlusion(ghostty_surface_t, bool); -void ghostty_surface_set_size(ghostty_surface_t, uint32_t, uint32_t); -ghostty_surface_size_s ghostty_surface_size(ghostty_surface_t); -void ghostty_surface_set_color_scheme(ghostty_surface_t, - ghostty_color_scheme_e); -ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, - ghostty_input_mods_e); -bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); -bool ghostty_surface_key_is_binding(ghostty_surface_t, - ghostty_input_key_s, - ghostty_binding_flags_e*); -void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); -void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t); -bool ghostty_surface_mouse_captured(ghostty_surface_t); -bool ghostty_surface_mouse_button(ghostty_surface_t, - ghostty_input_mouse_state_e, - ghostty_input_mouse_button_e, - ghostty_input_mods_e); -void ghostty_surface_mouse_pos(ghostty_surface_t, - double, - double, - ghostty_input_mods_e); -void ghostty_surface_mouse_scroll(ghostty_surface_t, - double, - double, - ghostty_input_scroll_mods_t); -void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double); -void ghostty_surface_ime_point(ghostty_surface_t, double*, double*, double*, double*); -void ghostty_surface_request_close(ghostty_surface_t); -void ghostty_surface_split(ghostty_surface_t, ghostty_action_split_direction_e); -void ghostty_surface_split_focus(ghostty_surface_t, - ghostty_action_goto_split_e); -void ghostty_surface_split_resize(ghostty_surface_t, - ghostty_action_resize_split_direction_e, - uint16_t); -void ghostty_surface_split_equalize(ghostty_surface_t); -bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t); -void ghostty_surface_complete_clipboard_request(ghostty_surface_t, - const char*, - void*, - bool); -bool ghostty_surface_has_selection(ghostty_surface_t); -bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*); -bool ghostty_surface_read_text(ghostty_surface_t, - ghostty_selection_s, - ghostty_text_s*); -void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*); +GHOSTTY_API int ghostty_init(uintptr_t, char**); +GHOSTTY_API void ghostty_cli_try_action(void); +GHOSTTY_API ghostty_info_s ghostty_info(void); +GHOSTTY_API const char* ghostty_translate(const char*); +GHOSTTY_API void ghostty_string_free(ghostty_string_s); + +GHOSTTY_API ghostty_config_t ghostty_config_new(); +GHOSTTY_API void ghostty_config_free(ghostty_config_t); +GHOSTTY_API ghostty_config_t ghostty_config_clone(ghostty_config_t); +GHOSTTY_API void ghostty_config_load_cli_args(ghostty_config_t); +GHOSTTY_API void ghostty_config_load_file(ghostty_config_t, const char*); +GHOSTTY_API void ghostty_config_load_default_files(ghostty_config_t); +GHOSTTY_API void ghostty_config_load_recursive_files(ghostty_config_t); +GHOSTTY_API void ghostty_config_finalize(ghostty_config_t); +GHOSTTY_API bool ghostty_config_get(ghostty_config_t, void*, const char*, uintptr_t); +GHOSTTY_API ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t, + const char*, + uintptr_t); +GHOSTTY_API uint32_t ghostty_config_diagnostics_count(ghostty_config_t); +GHOSTTY_API ghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t); +GHOSTTY_API ghostty_string_s ghostty_config_open_path(void); + +GHOSTTY_API ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*, + ghostty_config_t); +GHOSTTY_API void ghostty_app_free(ghostty_app_t); +GHOSTTY_API void ghostty_app_tick(ghostty_app_t); +GHOSTTY_API void* ghostty_app_userdata(ghostty_app_t); +GHOSTTY_API void ghostty_app_set_focus(ghostty_app_t, bool); +GHOSTTY_API bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s); +GHOSTTY_API bool ghostty_app_key_is_binding(ghostty_app_t, ghostty_input_key_s); +GHOSTTY_API void ghostty_app_keyboard_changed(ghostty_app_t); +GHOSTTY_API void ghostty_app_open_config(ghostty_app_t); +GHOSTTY_API void ghostty_app_update_config(ghostty_app_t, ghostty_config_t); +GHOSTTY_API bool ghostty_app_needs_confirm_quit(ghostty_app_t); +GHOSTTY_API bool ghostty_app_has_global_keybinds(ghostty_app_t); +GHOSTTY_API void ghostty_app_set_color_scheme(ghostty_app_t, ghostty_color_scheme_e); + +GHOSTTY_API ghostty_surface_config_s ghostty_surface_config_new(); + +GHOSTTY_API ghostty_surface_t ghostty_surface_new(ghostty_app_t, + const ghostty_surface_config_s*); +GHOSTTY_API void ghostty_surface_free(ghostty_surface_t); +GHOSTTY_API void* ghostty_surface_userdata(ghostty_surface_t); +GHOSTTY_API ghostty_app_t ghostty_surface_app(ghostty_surface_t); +GHOSTTY_API ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t, ghostty_surface_context_e); +GHOSTTY_API void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t); +GHOSTTY_API bool ghostty_surface_needs_confirm_quit(ghostty_surface_t); +GHOSTTY_API bool ghostty_surface_process_exited(ghostty_surface_t); +GHOSTTY_API void ghostty_surface_refresh(ghostty_surface_t); +GHOSTTY_API void ghostty_surface_draw(ghostty_surface_t); +GHOSTTY_API void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); +GHOSTTY_API void ghostty_surface_set_focus(ghostty_surface_t, bool); +GHOSTTY_API void ghostty_surface_set_occlusion(ghostty_surface_t, bool); +GHOSTTY_API void ghostty_surface_set_size(ghostty_surface_t, uint32_t, uint32_t); +GHOSTTY_API ghostty_surface_size_s ghostty_surface_size(ghostty_surface_t); +GHOSTTY_API void ghostty_surface_set_color_scheme(ghostty_surface_t, + ghostty_color_scheme_e); +GHOSTTY_API ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, + ghostty_input_mods_e); +GHOSTTY_API bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); +GHOSTTY_API bool ghostty_surface_key_is_binding(ghostty_surface_t, + ghostty_input_key_s, + ghostty_binding_flags_e*); +GHOSTTY_API void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); +GHOSTTY_API void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t); +GHOSTTY_API bool ghostty_surface_mouse_captured(ghostty_surface_t); +GHOSTTY_API bool ghostty_surface_mouse_button(ghostty_surface_t, + ghostty_input_mouse_state_e, + ghostty_input_mouse_button_e, + ghostty_input_mods_e); +GHOSTTY_API void ghostty_surface_mouse_pos(ghostty_surface_t, + double, + double, + ghostty_input_mods_e); +GHOSTTY_API void ghostty_surface_mouse_scroll(ghostty_surface_t, + double, + double, + ghostty_input_scroll_mods_t); +GHOSTTY_API void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double); +GHOSTTY_API void ghostty_surface_ime_point(ghostty_surface_t, double*, double*, double*, double*); +GHOSTTY_API void ghostty_surface_request_close(ghostty_surface_t); +GHOSTTY_API void ghostty_surface_split(ghostty_surface_t, ghostty_action_split_direction_e); +GHOSTTY_API void ghostty_surface_split_focus(ghostty_surface_t, + ghostty_action_goto_split_e); +GHOSTTY_API void ghostty_surface_split_resize(ghostty_surface_t, + ghostty_action_resize_split_direction_e, + uint16_t); +GHOSTTY_API void ghostty_surface_split_equalize(ghostty_surface_t); +GHOSTTY_API bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t); +GHOSTTY_API void ghostty_surface_complete_clipboard_request(ghostty_surface_t, + const char*, + void*, + bool); +GHOSTTY_API bool ghostty_surface_has_selection(ghostty_surface_t); +GHOSTTY_API bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*); +GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t, + ghostty_selection_s, + ghostty_text_s*); +GHOSTTY_API void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*); #ifdef __APPLE__ -void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t); -void* ghostty_surface_quicklook_font(ghostty_surface_t); -bool ghostty_surface_quicklook_word(ghostty_surface_t, ghostty_text_s*); +GHOSTTY_API void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t); +GHOSTTY_API void* ghostty_surface_quicklook_font(ghostty_surface_t); +GHOSTTY_API bool ghostty_surface_quicklook_word(ghostty_surface_t, ghostty_text_s*); #endif -ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t); -void ghostty_inspector_free(ghostty_surface_t); -void ghostty_inspector_set_focus(ghostty_inspector_t, bool); -void ghostty_inspector_set_content_scale(ghostty_inspector_t, double, double); -void ghostty_inspector_set_size(ghostty_inspector_t, uint32_t, uint32_t); -void ghostty_inspector_mouse_button(ghostty_inspector_t, - ghostty_input_mouse_state_e, - ghostty_input_mouse_button_e, - ghostty_input_mods_e); -void ghostty_inspector_mouse_pos(ghostty_inspector_t, double, double); -void ghostty_inspector_mouse_scroll(ghostty_inspector_t, - double, - double, - ghostty_input_scroll_mods_t); -void ghostty_inspector_key(ghostty_inspector_t, - ghostty_input_action_e, - ghostty_input_key_e, - ghostty_input_mods_e); -void ghostty_inspector_text(ghostty_inspector_t, const char*); +GHOSTTY_API ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t); +GHOSTTY_API void ghostty_inspector_free(ghostty_surface_t); +GHOSTTY_API void ghostty_inspector_set_focus(ghostty_inspector_t, bool); +GHOSTTY_API void ghostty_inspector_set_content_scale(ghostty_inspector_t, double, double); +GHOSTTY_API void ghostty_inspector_set_size(ghostty_inspector_t, uint32_t, uint32_t); +GHOSTTY_API void ghostty_inspector_mouse_button(ghostty_inspector_t, + ghostty_input_mouse_state_e, + ghostty_input_mouse_button_e, + ghostty_input_mods_e); +GHOSTTY_API void ghostty_inspector_mouse_pos(ghostty_inspector_t, double, double); +GHOSTTY_API void ghostty_inspector_mouse_scroll(ghostty_inspector_t, + double, + double, + ghostty_input_scroll_mods_t); +GHOSTTY_API void ghostty_inspector_key(ghostty_inspector_t, + ghostty_input_action_e, + ghostty_input_key_e, + ghostty_input_mods_e); +GHOSTTY_API void ghostty_inspector_text(ghostty_inspector_t, const char*); #ifdef __APPLE__ -bool ghostty_inspector_metal_init(ghostty_inspector_t, void*); -void ghostty_inspector_metal_render(ghostty_inspector_t, void*, void*); -bool ghostty_inspector_metal_shutdown(ghostty_inspector_t); +GHOSTTY_API bool ghostty_inspector_metal_init(ghostty_inspector_t, void*); +GHOSTTY_API void ghostty_inspector_metal_render(ghostty_inspector_t, void*, void*); +GHOSTTY_API bool ghostty_inspector_metal_shutdown(ghostty_inspector_t); #endif // APIs I'd like to get rid of eventually but are still needed for now. // Don't use these unless you know what you're doing. -void ghostty_set_window_background_blur(ghostty_app_t, void*); +GHOSTTY_API void ghostty_set_window_background_blur(ghostty_app_t, void*); // Benchmark API, if available. -bool ghostty_benchmark_cli(const char*, const char*); +GHOSTTY_API bool ghostty_benchmark_cli(const char*, const char*); #ifdef __cplusplus } diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 4f8fef88ecc..649ab1d4d94 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,22 @@ * 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. + */ + +/** @example c-vt-kitty-graphics/src/main.c + * This example demonstrates how to use the system interface to install a + * PNG decoder callback and send a Kitty Graphics Protocol image. + */ + #ifndef GHOSTTY_VT_H #define GHOSTTY_VT_H @@ -72,12 +110,28 @@ extern "C" { #endif -#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include +#include +#include #include +#include +#include +#include #include +#include +#include +#include #include #ifdef __cplusplus diff --git a/include/ghostty/vt/allocator.h b/include/ghostty/vt/allocator.h index 4cebe91bb10..2e8685e8445 100644 --- a/include/ghostty/vt/allocator.h +++ b/include/ghostty/vt/allocator.h @@ -10,6 +10,7 @@ #include #include #include +#include /** @defgroup allocator Memory Management * @@ -44,6 +45,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 +210,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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 00000000000..8573556f7f8 --- /dev/null +++ b/include/ghostty/vt/build_info.h @@ -0,0 +1,150 @@ +/** + * @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 +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Build optimization mode. + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_OPTIMIZE_DEBUG = 0, + GHOSTTY_OPTIMIZE_RELEASE_SAFE = 1, + GHOSTTY_OPTIMIZE_RELEASE_SMALL = 2, + GHOSTTY_OPTIMIZE_RELEASE_FAST = 3, + GHOSTTY_OPTIMIZE_MODE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyOptimizeMode; + +/** + * Build info data types that can be queried. + * + * Each variant documents the expected output pointer type. + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** 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, + + /** + * The full version string (e.g. "1.2.3" or "1.2.3-dev+abcdef"). + * + * Output type: GhosttyString * + */ + GHOSTTY_BUILD_INFO_VERSION_STRING = 5, + + /** + * The major version number. + * + * Output type: size_t * + */ + GHOSTTY_BUILD_INFO_VERSION_MAJOR = 6, + + /** + * The minor version number. + * + * Output type: size_t * + */ + GHOSTTY_BUILD_INFO_VERSION_MINOR = 7, + + /** + * The patch version number. + * + * Output type: size_t * + */ + GHOSTTY_BUILD_INFO_VERSION_PATCH = 8, + + /** + * The pre metadata string (e.g. "alpha", "beta", "dev"). Has zero length if + * no pre metadata is present. + * + * Output type: GhosttyString * + */ + GHOSTTY_BUILD_INFO_VERSION_PRE = 9, + + /** + * The build metadata string (e.g. commit hash). Has zero length if + * no build metadata is present. + * + * Output type: GhosttyString * + */ + GHOSTTY_BUILD_INFO_VERSION_BUILD = 10, + GHOSTTY_BUILD_INFO_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 + */ +GHOSTTY_API GhosttyResult ghostty_build_info(GhosttyBuildInfo data, void *out); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_BUILD_INFO_H */ diff --git a/include/ghostty/vt/color.h b/include/ghostty/vt/color.h index 0d57b8db4ab..9dc21864eb9 100644 --- a/include/ghostty/vt/color.h +++ b/include/ghostty/vt/color.h @@ -8,6 +8,7 @@ #define GHOSTTY_VT_COLOR_H #include +#include #ifdef __cplusplus extern "C" { @@ -84,7 +85,7 @@ typedef uint8_t GhosttyColorPaletteIndex; * * @ingroup sgr */ -void ghostty_color_rgb_get(GhosttyColorRgb color, +GHOSTTY_API void ghostty_color_rgb_get(GhosttyColorRgb color, uint8_t* r, uint8_t* g, uint8_t* b); diff --git a/include/ghostty/vt/device.h b/include/ghostty/vt/device.h new file mode 100644 index 00000000000..0a1567280b8 --- /dev/null +++ b/include/ghostty/vt/device.h @@ -0,0 +1,151 @@ +/** + * @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 +#include + +/* 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_ENUM_TYPED { + GHOSTTY_COLOR_SCHEME_LIGHT = 0, + GHOSTTY_COLOR_SCHEME_DARK = 1, + GHOSTTY_COLOR_SCHEME_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 00000000000..b9940f79247 --- /dev/null +++ b/include/ghostty/vt/focus.h @@ -0,0 +1,76 @@ +/** + * @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 +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Focus event types for focus reporting mode (mode 1004). + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Terminal window gained focus */ + GHOSTTY_FOCUS_GAINED = 0, + /** Terminal window lost focus */ + GHOSTTY_FOCUS_LOST = 1, + GHOSTTY_FOCUS_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 + */ +GHOSTTY_API 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 00000000000..358e95f6639 --- /dev/null +++ b/include/ghostty/vt/formatter.h @@ -0,0 +1,224 @@ +/** + * @file formatter.h + * + * Format terminal content as plain text, VT sequences, or HTML. + */ + +#ifndef GHOSTTY_VT_FORMATTER_H +#define GHOSTTY_VT_FORMATTER_H + +#include +#include +#include +#include +#include +#include +#include + +#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 GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_FORMATTER_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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; + +/** + * 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; + + /** Optional selection to restrict output to a range. + * If NULL, the entire screen is formatted. */ + const GhosttySelection *selection; +} 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 00000000000..1f9f52b9bc8 --- /dev/null +++ b/include/ghostty/vt/grid_ref.h @@ -0,0 +1,157 @@ +/** + * @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 +#include +#include +#include +#include + +#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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API GhosttyResult ghostty_grid_ref_graphemes(const GhosttyGridRef *ref, + uint32_t *buf, + size_t buf_len, + size_t *out_len); + +/** + * Get the hyperlink URI for the cell at the grid reference's position. + * + * Writes the URI bytes into the provided buffer. If the cell has no + * hyperlink, 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 bytes to + * out_len. The caller can then retry with a sufficiently sized buffer. + * + * @param ref Pointer to the grid reference + * @param buf Output buffer for the URI bytes (may be NULL) + * @param buf_len Size of the output buffer in bytes + * @param[out] out_len On success, the number of bytes written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size in bytes. + * @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 + */ +GHOSTTY_API GhosttyResult ghostty_grid_ref_hyperlink_uri( + const GhosttyGridRef *ref, + uint8_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 + */ +GHOSTTY_API 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 772b5d43bcf..61b95475357 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 * + * @snippet c-vt-encode-key/src/main.c key-encode + * + * ## 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} - * #include - * #include - * #include - * - * 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 + * // 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}); * - * For a complete working example, see example/c-vt-key-encode in the - * repository. + * // 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 766a2942796..dc9e27e7e2e 100644 --- a/include/ghostty/vt/key/encoder.h +++ b/include/ghostty/vt/key/encoder.h @@ -9,8 +9,9 @@ #include #include -#include +#include #include +#include #include /** @@ -21,7 +22,7 @@ * * @ingroup key */ -typedef struct GhosttyKeyEncoder *GhosttyKeyEncoder; +typedef struct GhosttyKeyEncoderImpl *GhosttyKeyEncoder; /** * Kitty keyboard protocol flags. @@ -63,7 +64,7 @@ typedef uint8_t GhosttyKittyKeyFlags; * * @ingroup key */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Option key is not treated as alt */ GHOSTTY_OPTION_AS_ALT_FALSE = 0, /** Option key is treated as alt */ @@ -72,6 +73,7 @@ typedef enum { GHOSTTY_OPTION_AS_ALT_LEFT = 2, /** Only right option key is treated as alt */ GHOSTTY_OPTION_AS_ALT_RIGHT = 3, + GHOSTTY_OPTION_AS_ALT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyOptionAsAlt; /** @@ -82,7 +84,7 @@ typedef enum { * * @ingroup key */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Terminal DEC mode 1: cursor key application mode (value: bool) */ GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0, @@ -103,6 +105,7 @@ typedef enum { /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */ GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6, + GHOSTTY_KEY_ENCODER_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKeyEncoderOption; /** @@ -118,7 +121,7 @@ typedef enum { * * @ingroup key */ -GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder); +GHOSTTY_API GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder); /** * Free a key encoder instance. @@ -130,7 +133,7 @@ GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, Ghostty * * @ingroup key */ -void ghostty_key_encoder_free(GhosttyKeyEncoder encoder); +GHOSTTY_API void ghostty_key_encoder_free(GhosttyKeyEncoder encoder); /** * Set an option on the key encoder. @@ -140,6 +143,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. * @@ -149,7 +156,26 @@ void ghostty_key_encoder_free(GhosttyKeyEncoder encoder); * * @ingroup key */ -void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value); +GHOSTTY_API 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 + */ +GHOSTTY_API void ghostty_key_encoder_setopt_from_terminal(GhosttyKeyEncoder encoder, GhosttyTerminal terminal); /** * Encode a key event into a terminal escape sequence. @@ -161,7 +187,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 +196,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 +230,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); @@ -216,6 +242,6 @@ void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOpti * * @ingroup key */ -GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len); +GHOSTTY_API GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len); #endif /* GHOSTTY_VT_KEY_ENCODER_H */ diff --git a/include/ghostty/vt/key/event.h b/include/ghostty/vt/key/event.h index dbd2e9f841a..eba433c6a55 100644 --- a/include/ghostty/vt/key/event.h +++ b/include/ghostty/vt/key/event.h @@ -10,7 +10,7 @@ #include #include #include -#include +#include #include /** @@ -21,20 +21,21 @@ * * @ingroup key */ -typedef struct GhosttyKeyEvent *GhosttyKeyEvent; +typedef struct GhosttyKeyEventImpl *GhosttyKeyEvent; /** * Keyboard input event types. * * @ingroup key */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Key was released */ GHOSTTY_KEY_ACTION_RELEASE = 0, /** Key was pressed */ GHOSTTY_KEY_ACTION_PRESS = 1, /** Key is being repeated (held down) */ GHOSTTY_KEY_ACTION_REPEAT = 2, + GHOSTTY_KEY_ACTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKeyAction; /** @@ -103,7 +104,7 @@ typedef uint16_t GhosttyMods; * * @ingroup key */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_KEY_UNIDENTIFIED = 0, // Writing System Keys (W3C § 3.1.1) @@ -296,6 +297,7 @@ typedef enum { GHOSTTY_KEY_COPY, GHOSTTY_KEY_CUT, GHOSTTY_KEY_PASTE, + GHOSTTY_KEY_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKey; /** @@ -310,7 +312,7 @@ typedef enum { * * @ingroup key */ -GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event); +GHOSTTY_API GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event); /** * Free a key event instance. @@ -322,7 +324,7 @@ GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKe * * @ingroup key */ -void ghostty_key_event_free(GhosttyKeyEvent event); +GHOSTTY_API void ghostty_key_event_free(GhosttyKeyEvent event); /** * Set the key action (press, release, repeat). @@ -332,7 +334,7 @@ void ghostty_key_event_free(GhosttyKeyEvent event); * * @ingroup key */ -void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action); +GHOSTTY_API void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action); /** * Get the key action (press, release, repeat). @@ -342,7 +344,7 @@ void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action * * @ingroup key */ -GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event); +GHOSTTY_API GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event); /** * Set the physical key code. @@ -352,7 +354,7 @@ GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event); * * @ingroup key */ -void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key); +GHOSTTY_API void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key); /** * Get the physical key code. @@ -362,7 +364,7 @@ void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key); * * @ingroup key */ -GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event); +GHOSTTY_API GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event); /** * Set the modifier keys bitmask. @@ -372,7 +374,7 @@ GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event); * * @ingroup key */ -void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods); +GHOSTTY_API void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods); /** * Get the modifier keys bitmask. @@ -382,7 +384,7 @@ void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods); * * @ingroup key */ -GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event); +GHOSTTY_API GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event); /** * Set the consumed modifiers bitmask. @@ -392,7 +394,7 @@ GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event); * * @ingroup key */ -void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods); +GHOSTTY_API void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods); /** * Get the consumed modifiers bitmask. @@ -402,7 +404,7 @@ void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods cons * * @ingroup key */ -GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event); +GHOSTTY_API GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event); /** * Set whether the key event is part of a composition sequence. @@ -412,7 +414,7 @@ GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event); * * @ingroup key */ -void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing); +GHOSTTY_API void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing); /** * Get whether the key event is part of a composition sequence. @@ -422,10 +424,16 @@ void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing); * * @ingroup key */ -bool ghostty_key_event_get_composing(GhosttyKeyEvent event); +GHOSTTY_API bool ghostty_key_event_get_composing(GhosttyKeyEvent event); /** - * Set the UTF-8 text generated by the key event. + * Set the UTF-8 text generated by the key for the current keyboard layout. + * + * Must contain the unmodified character before any Ctrl/Meta transformations. + * The encoder derives modifier sequences from the logical key and mods + * bitmask, not from this text. Do not pass C0 control characters + * (U+0000-U+001F, U+007F) or platform function key codes (e.g. macOS PUA + * U+F700-U+F8FF); pass NULL instead and let the encoder use the logical key. * * The key event does NOT take ownership of the text pointer. The caller * must ensure the string remains valid for the lifetime needed by the event. @@ -436,7 +444,7 @@ bool ghostty_key_event_get_composing(GhosttyKeyEvent event); * * @ingroup key */ -void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len); +GHOSTTY_API void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len); /** * Get the UTF-8 text generated by the key event. @@ -449,7 +457,7 @@ void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t * * @ingroup key */ -const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len); +GHOSTTY_API const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len); /** * Set the unshifted Unicode codepoint. @@ -459,7 +467,7 @@ const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len); * * @ingroup key */ -void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint); +GHOSTTY_API void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint); /** * Get the unshifted Unicode codepoint. @@ -469,6 +477,6 @@ void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t c * * @ingroup key */ -uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event); +GHOSTTY_API uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event); #endif /* GHOSTTY_VT_KEY_EVENT_H */ diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h new file mode 100644 index 00000000000..9bace3a3ccf --- /dev/null +++ b/include/ghostty/vt/kitty_graphics.h @@ -0,0 +1,775 @@ +/** + * @file kitty_graphics.h + * + * Kitty graphics protocol + * + * See @ref kitty_graphics for a full usage guide. + */ + +#ifndef GHOSTTY_VT_KITTY_GRAPHICS_H +#define GHOSTTY_VT_KITTY_GRAPHICS_H + +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup kitty_graphics Kitty Graphics + * + * API for inspecting images and placements stored via the + * [Kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/). + * + * The central object is @ref GhosttyKittyGraphics, an opaque handle to + * the image storage associated with a terminal's active screen. From it + * you can iterate over placements and look up individual images. + * + * ## Obtaining a KittyGraphics Handle + * + * A @ref GhosttyKittyGraphics handle is obtained from a terminal via + * ghostty_terminal_get() with @ref GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS. + * The handle is borrowed from the terminal and remains valid until the + * next mutating terminal call (e.g. ghostty_terminal_vt_write() or + * ghostty_terminal_reset()). + * + * Before images can be stored, Kitty graphics must be enabled on the + * terminal by setting a non-zero storage limit with + * @ref GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT, and a PNG + * decoder callback must be installed via ghostty_sys_set() with + * @ref GHOSTTY_SYS_OPT_DECODE_PNG. + * + * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-decode-png + * + * ## Iterating Placements + * + * Placements are inspected through a @ref GhosttyKittyGraphicsPlacementIterator. + * The typical workflow is: + * + * 1. Create an iterator with ghostty_kitty_graphics_placement_iterator_new(). + * 2. Populate it from the storage with ghostty_kitty_graphics_get() using + * @ref GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR. + * 3. Optionally filter by z-layer with + * ghostty_kitty_graphics_placement_iterator_set(). + * 4. Advance with ghostty_kitty_graphics_placement_next() and read + * per-placement data with ghostty_kitty_graphics_placement_get(). + * 5. For each placement, look up its image with + * ghostty_kitty_graphics_image() to access pixel data and dimensions. + * 6. Free the iterator with ghostty_kitty_graphics_placement_iterator_free(). + * + * ## Looking Up Images + * + * Given an image ID (obtained from a placement via + * @ref GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID), call + * ghostty_kitty_graphics_image() to get a @ref GhosttyKittyGraphicsImage + * handle. From this handle, ghostty_kitty_graphics_image_get() provides + * the image dimensions, pixel format, compression, and a borrowed pointer + * to the raw pixel data. + * + * ## Rendering Helpers + * + * Several functions assist with rendering a placement: + * + * - ghostty_kitty_graphics_placement_pixel_size() — rendered pixel + * dimensions accounting for source rect and aspect ratio. + * - ghostty_kitty_graphics_placement_grid_size() — number of grid + * columns and rows the placement occupies. + * - ghostty_kitty_graphics_placement_viewport_pos() — viewport-relative + * grid position (may be negative for partially scrolled placements). + * - ghostty_kitty_graphics_placement_source_rect() — resolved source + * rectangle in pixels, clamped to image bounds. + * - ghostty_kitty_graphics_placement_rect() — bounding rectangle as a + * @ref GhosttySelection. + * + * ## Lifetime and Thread Safety + * + * All handles borrowed from the terminal (GhosttyKittyGraphics, + * GhosttyKittyGraphicsImage) are invalidated by any mutating terminal + * call. The placement iterator is independently owned and must be freed + * by the caller, but the data it yields is only valid while the + * underlying terminal is not mutated. + * + * ## Example + * + * The following example creates a terminal, sends a Kitty graphics + * image, then iterates placements and prints image metadata: + * + * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-main + * + * @{ + */ + +/** + * Queryable data kinds for ghostty_kitty_graphics_get(). + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid / sentinel value. */ + GHOSTTY_KITTY_GRAPHICS_DATA_INVALID = 0, + + /** + * Populate a pre-allocated placement iterator with placement data from + * the storage. Iterator data is only valid as long as the underlying + * terminal is not mutated. + * + * Output type: GhosttyKittyGraphicsPlacementIterator * + */ + GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR = 1, + GHOSTTY_KITTY_GRAPHICS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyGraphicsData; + +/** + * Queryable data kinds for ghostty_kitty_graphics_placement_get(). + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid / sentinel value. */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_INVALID = 0, + + /** + * The image ID this placement belongs to. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID = 1, + + /** + * The placement ID. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID = 2, + + /** + * Whether this is a virtual placement (unicode placeholder). + * + * Output type: bool * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IS_VIRTUAL = 3, + + /** + * Pixel offset from the left edge of the cell. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_X_OFFSET = 4, + + /** + * Pixel offset from the top edge of the cell. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Y_OFFSET = 5, + + /** + * Source rectangle x origin in pixels. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_X = 6, + + /** + * Source rectangle y origin in pixels. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_Y = 7, + + /** + * Source rectangle width in pixels (0 = full image width). + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_WIDTH = 8, + + /** + * Source rectangle height in pixels (0 = full image height). + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_HEIGHT = 9, + + /** + * Number of columns this placement occupies. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_COLUMNS = 10, + + /** + * Number of rows this placement occupies. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_ROWS = 11, + + /** + * Z-index for this placement. + * + * Output type: int32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Z = 12, + + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyGraphicsPlacementData; + +/** + * Z-layer classification for kitty graphics placements. + * + * Based on the kitty protocol z-index conventions: + * - BELOW_BG: z < INT32_MIN/2 (drawn below cell background) + * - BELOW_TEXT: INT32_MIN/2 <= z < 0 (above background, below text) + * - ABOVE_TEXT: z >= 0 (above text) + * - ALL: no filtering (current behavior) + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_KITTY_PLACEMENT_LAYER_ALL = 0, + GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_BG = 1, + GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_TEXT = 2, + GHOSTTY_KITTY_PLACEMENT_LAYER_ABOVE_TEXT = 3, + GHOSTTY_KITTY_PLACEMENT_LAYER_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyPlacementLayer; + +/** + * Settable options for ghostty_kitty_graphics_placement_iterator_set(). + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** + * Set the z-layer filter for the iterator. + * + * Input type: GhosttyKittyPlacementLayer * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_LAYER = 0, + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyGraphicsPlacementIteratorOption; + +/** + * Pixel format of a Kitty graphics image. + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_KITTY_IMAGE_FORMAT_RGB = 0, + GHOSTTY_KITTY_IMAGE_FORMAT_RGBA = 1, + GHOSTTY_KITTY_IMAGE_FORMAT_PNG = 2, + GHOSTTY_KITTY_IMAGE_FORMAT_GRAY_ALPHA = 3, + GHOSTTY_KITTY_IMAGE_FORMAT_GRAY = 4, + GHOSTTY_KITTY_IMAGE_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyImageFormat; + +/** + * Compression of a Kitty graphics image. + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE = 0, + GHOSTTY_KITTY_IMAGE_COMPRESSION_ZLIB_DEFLATE = 1, + GHOSTTY_KITTY_IMAGE_COMPRESSION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyImageCompression; + +/** + * Queryable data kinds for ghostty_kitty_graphics_image_get(). + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid / sentinel value. */ + GHOSTTY_KITTY_IMAGE_DATA_INVALID = 0, + + /** + * The image ID. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_ID = 1, + + /** + * The image number. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_NUMBER = 2, + + /** + * Image width in pixels. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_WIDTH = 3, + + /** + * Image height in pixels. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_HEIGHT = 4, + + /** + * Pixel format of the image. + * + * Output type: GhosttyKittyImageFormat * + */ + GHOSTTY_KITTY_IMAGE_DATA_FORMAT = 5, + + /** + * Compression of the image. + * + * Output type: GhosttyKittyImageCompression * + */ + GHOSTTY_KITTY_IMAGE_DATA_COMPRESSION = 6, + + /** + * Borrowed pointer to the raw pixel data. Valid as long as the + * underlying terminal is not mutated. + * + * Output type: const uint8_t ** + */ + GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR = 7, + + /** + * Length of the raw pixel data in bytes. + * + * Output type: size_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN = 8, + + GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyGraphicsImageData; + +/** + * Combined rendering geometry for a placement in a single sized struct. + * + * Combines the results of ghostty_kitty_graphics_placement_pixel_size(), + * ghostty_kitty_graphics_placement_grid_size(), + * ghostty_kitty_graphics_placement_viewport_pos(), and + * ghostty_kitty_graphics_placement_source_rect() into one call. This is + * an optimization over calling those four functions individually, + * particularly useful in environments with high per-call overhead such + * as FFI or Cgo. + * + * This struct uses the sized-struct ABI pattern. Initialize with + * GHOSTTY_INIT_SIZED(GhosttyKittyGraphicsPlacementRenderInfo) before calling + * ghostty_kitty_graphics_placement_render_info(). + * + * @ingroup kitty_graphics + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyKittyGraphicsPlacementRenderInfo). */ + size_t size; + /** Rendered width in pixels. */ + uint32_t pixel_width; + /** Rendered height in pixels. */ + uint32_t pixel_height; + /** Number of grid columns the placement occupies. */ + uint32_t grid_cols; + /** Number of grid rows the placement occupies. */ + uint32_t grid_rows; + /** Viewport-relative column (may be negative for partially visible placements). */ + int32_t viewport_col; + /** Viewport-relative row (may be negative for partially visible placements). */ + int32_t viewport_row; + /** False when the placement is fully off-screen or virtual. */ + bool viewport_visible; + /** Resolved source rectangle x origin in pixels. */ + uint32_t source_x; + /** Resolved source rectangle y origin in pixels. */ + uint32_t source_y; + /** Resolved source rectangle width in pixels. */ + uint32_t source_width; + /** Resolved source rectangle height in pixels. */ + uint32_t source_height; +} GhosttyKittyGraphicsPlacementRenderInfo; + +/** + * Get data from a kitty graphics storage instance. + * + * The output pointer must be of the appropriate type for the requested + * data kind. + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * @param graphics The kitty graphics handle + * @param data The type of data to extract + * @param[out] out Pointer to store the extracted data + * @return GHOSTTY_SUCCESS on success + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_get( + GhosttyKittyGraphics graphics, + GhosttyKittyGraphicsData data, + void* out); + +/** + * Look up a Kitty graphics image by its image ID. + * + * Returns NULL if no image with the given ID exists or if Kitty graphics + * are disabled at build time. + * + * @param graphics The kitty graphics handle + * @param image_id The image ID to look up + * @return An opaque image handle, or NULL if not found + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyKittyGraphicsImage ghostty_kitty_graphics_image( + GhosttyKittyGraphics graphics, + uint32_t image_id); + +/** + * Get data from a Kitty graphics image. + * + * The output pointer must be of the appropriate type for the requested + * data kind. + * + * @param image The image 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 + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_image_get( + GhosttyKittyGraphicsImage image, + GhosttyKittyGraphicsImageData data, + void* out); + +/** + * Get multiple data fields from a Kitty graphics image in a single call. + * + * This is an optimization over calling ghostty_kitty_graphics_image_get() + * repeatedly, particularly useful in environments with high per-call + * overhead such as FFI or Cgo. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * The type of each values[i] pointer must match the output type + * documented for keys[i]. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param image The image handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_image_get_multi( + GhosttyKittyGraphicsImage image, + size_t count, + const GhosttyKittyGraphicsImageData* keys, + void** values, + size_t* out_written); + +/** + * Create a new placement iterator instance. + * + * All fields except the allocator are left undefined until populated + * via ghostty_kitty_graphics_get() with + * GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_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 kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_iterator_new( + const GhosttyAllocator* allocator, + GhosttyKittyGraphicsPlacementIterator* out_iterator); + +/** + * Free a placement iterator. + * + * @param iterator The iterator handle to free (may be NULL) + * + * @ingroup kitty_graphics + */ +GHOSTTY_API void ghostty_kitty_graphics_placement_iterator_free( + GhosttyKittyGraphicsPlacementIterator iterator); + +/** + * Set an option on a placement iterator. + * + * Use GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_LAYER with a + * GhosttyKittyPlacementLayer value to filter placements by z-layer. + * The filter is applied during iteration: ghostty_kitty_graphics_placement_next() + * will skip placements that do not match the configured layer. + * + * The default layer is GHOSTTY_KITTY_PLACEMENT_LAYER_ALL (no filtering). + * + * @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param option The option to set + * @param value Pointer to the value (type depends on option; NULL returns + * GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_iterator_set( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsPlacementIteratorOption option, + const void* value); + +/** + * Advance the placement iterator to the next placement. + * + * If a layer filter has been set via + * ghostty_kitty_graphics_placement_iterator_set(), only placements + * matching that layer are returned. + * + * @param iterator The iterator handle (may be NULL) + * @return true if advanced to the next placement, false if at the end + * + * @ingroup kitty_graphics + */ +GHOSTTY_API bool ghostty_kitty_graphics_placement_next( + GhosttyKittyGraphicsPlacementIterator iterator); + +/** + * Get data from the current placement in a placement iterator. + * + * Call ghostty_kitty_graphics_placement_next() at least once before + * calling this function. + * + * @param iterator The iterator 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 the + * iterator is NULL or not positioned on a placement + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_get( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsPlacementData data, + void* out); + +/** + * Get multiple data fields from the current placement in a single call. + * + * This is an optimization over calling ghostty_kitty_graphics_placement_get() + * repeatedly, particularly useful in environments with high per-call + * overhead such as FFI or Cgo. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * The type of each values[i] pointer must match the output type + * documented for keys[i]. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_get_multi( + GhosttyKittyGraphicsPlacementIterator iterator, + size_t count, + const GhosttyKittyGraphicsPlacementData* keys, + void** values, + size_t* out_written); + +/** + * Compute the grid rectangle occupied by the current placement. + * + * Uses the placement's pin, the image dimensions, and the terminal's + * cell/pixel geometry to calculate the bounding rectangle. Virtual + * placements (unicode placeholders) return GHOSTTY_NO_VALUE. + * + * @param terminal The terminal handle + * @param image The image handle for this placement's image + * @param iterator The placement iterator positioned on a placement + * @param[out] out_selection On success, receives the bounding rectangle + * as a selection with rectangle=true + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any handle + * is NULL or the iterator is not positioned, GHOSTTY_NO_VALUE for + * virtual placements or when Kitty graphics are disabled + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_rect( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + GhosttySelection* out_selection); + +/** + * Compute the rendered pixel size of the current placement. + * + * Takes into account the placement's source rectangle, specified + * columns/rows, and aspect ratio to calculate the final rendered + * pixel dimensions. + * + * @param iterator The placement iterator positioned on a placement + * @param image The image handle for this placement's image + * @param terminal The terminal handle + * @param[out] out_width On success, receives the width in pixels + * @param[out] out_height On success, receives the height in pixels + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any handle + * is NULL or the iterator is not positioned, GHOSTTY_NO_VALUE when + * Kitty graphics are disabled + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_pixel_size( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + uint32_t* out_width, + uint32_t* out_height); + +/** + * Compute the grid cell size of the current placement. + * + * Returns the number of columns and rows that the placement occupies + * in the terminal grid. If the placement specifies explicit columns + * and rows, those are returned directly; otherwise they are calculated + * from the pixel size and cell dimensions. + * + * @param iterator The placement iterator positioned on a placement + * @param image The image handle for this placement's image + * @param terminal The terminal handle + * @param[out] out_cols On success, receives the number of columns + * @param[out] out_rows On success, receives the number of rows + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any handle + * is NULL or the iterator is not positioned, GHOSTTY_NO_VALUE when + * Kitty graphics are disabled + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_grid_size( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + uint32_t* out_cols, + uint32_t* out_rows); + +/** + * Get the viewport-relative grid position of the current placement. + * + * Converts the placement's internal pin to viewport-relative column and + * row coordinates. The returned coordinates represent the top-left + * corner of the placement in the viewport's grid coordinate space. + * + * The row value can be negative when the placement's origin has + * scrolled above the top of the viewport. For example, a 4-row + * image that has scrolled up by 2 rows returns row=-2, meaning + * its top 2 rows are above the visible area but its bottom 2 rows + * are still on screen. Embedders should use these coordinates + * directly when computing the destination rectangle for rendering; + * the embedder is responsible for clipping the portion of the image + * that falls outside the viewport. + * + * Returns GHOSTTY_SUCCESS for any placement that is at least + * partially visible in the viewport. Returns GHOSTTY_NO_VALUE when + * the placement is completely outside the viewport (its bottom edge + * is above the viewport or its top edge is at or below the last + * viewport row), or when the placement is a virtual (unicode + * placeholder) placement. + * + * @param iterator The placement iterator positioned on a placement + * @param image The image handle for this placement's image + * @param terminal The terminal handle + * @param[out] out_col On success, receives the viewport-relative column + * @param[out] out_row On success, receives the viewport-relative row + * (may be negative for partially visible placements) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if fully + * off-screen or virtual, GHOSTTY_INVALID_VALUE if any handle + * is NULL or the iterator is not positioned + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_viewport_pos( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + int32_t* out_col, + int32_t* out_row); + +/** + * Get the resolved source rectangle for the current placement. + * + * Applies kitty protocol semantics: a width or height of 0 in the + * placement means "use the full image dimension", and the resulting + * rectangle is clamped to the actual image bounds. The returned + * values are in pixels and are ready to use for texture sampling. + * + * @param iterator The placement iterator positioned on a placement + * @param image The image handle for this placement's image + * @param[out] out_x Source rect x origin in pixels + * @param[out] out_y Source rect y origin in pixels + * @param[out] out_width Source rect width in pixels + * @param[out] out_height Source rect height in pixels + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any + * handle is NULL or the iterator is not positioned + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_source_rect( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + uint32_t* out_x, + uint32_t* out_y, + uint32_t* out_width, + uint32_t* out_height); + +/** + * Get all rendering geometry for a placement in a single call. + * + * Combines pixel size, grid size, viewport position, and source + * rectangle into one struct. Initialize with + * GHOSTTY_INIT_SIZED(GhosttyKittyGraphicsPlacementRenderInfo). + * + * When viewport_visible is false, the placement is fully off-screen + * or is a virtual placement; viewport_col and viewport_row may + * contain meaningless values in that case. + * + * @param iterator The iterator positioned on a placement + * @param image The image handle for this placement's image + * @param terminal The terminal handle + * @param[out] out_info Pointer to receive the rendering geometry + * @return GHOSTTY_SUCCESS on success + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_render_info( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + GhosttyKittyGraphicsPlacementRenderInfo* out_info); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_KITTY_GRAPHICS_H */ diff --git a/include/ghostty/vt/modes.h b/include/ghostty/vt/modes.h new file mode 100644 index 00000000000..db95a1a7d4a --- /dev/null +++ b/include/ghostty/vt/modes.h @@ -0,0 +1,197 @@ +/** + * @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 +#include +#include + +#include + +#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 GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_MODE_REPORT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 + */ +GHOSTTY_API 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 00000000000..4ba5f52e38b --- /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 +#include + +/** @} */ + +#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 00000000000..d84d863c8d7 --- /dev/null +++ b/include/ghostty/vt/mouse/encoder.h @@ -0,0 +1,214 @@ +/** + * @file encoder.h + * + * Mouse event encoding to terminal escape sequences. + */ + +#ifndef GHOSTTY_VT_MOUSE_ENCODER_H +#define GHOSTTY_VT_MOUSE_ENCODER_H + +#include +#include +#include +#include +#include +#include +#include + +/** + * 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 GhosttyMouseEncoderImpl *GhosttyMouseEncoder; + +/** + * Mouse tracking mode. + * + * @ingroup mouse + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_MOUSE_TRACKING_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyMouseTrackingMode; + +/** + * Mouse output format. + * + * @ingroup mouse + */ +typedef enum GHOSTTY_ENUM_TYPED { + 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, + GHOSTTY_MOUSE_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_MOUSE_ENCODER_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 00000000000..a24b0c079bb --- /dev/null +++ b/include/ghostty/vt/mouse/event.h @@ -0,0 +1,195 @@ +/** + * @file event.h + * + * Mouse event representation and manipulation. + */ + +#ifndef GHOSTTY_VT_MOUSE_EVENT_H +#define GHOSTTY_VT_MOUSE_EVENT_H + +#include +#include +#include +#include + +/** + * 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 GhosttyMouseEventImpl *GhosttyMouseEvent; + +/** + * Mouse event action type. + * + * @ingroup mouse + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_MOUSE_ACTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyMouseAction; + +/** + * Mouse button identity. + * + * @ingroup mouse + */ +typedef enum GHOSTTY_ENUM_TYPED { + 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, + GHOSTTY_MOUSE_BUTTON_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 f53077ab326..9409ebc738f 100644 --- a/include/ghostty/vt/osc.h +++ b/include/ghostty/vt/osc.h @@ -10,29 +10,9 @@ #include #include #include -#include +#include #include -/** - * Opaque handle to an OSC parser instance. - * - * This handle represents an OSC (Operating System Command) parser that can - * be used to parse the contents of OSC sequences. - * - * @ingroup osc - */ -typedef struct GhosttyOscParser *GhosttyOscParser; - -/** - * Opaque handle to a single OSC command. - * - * This handle represents a parsed OSC (Operating System Command) command. - * The command can be queried for its type and associated data. - * - * @ingroup osc - */ -typedef struct GhosttyOscCommand *GhosttyOscCommand; - /** @defgroup osc OSC Parser * * OSC (Operating System Command) sequence parser and command handling. @@ -59,7 +39,7 @@ typedef struct GhosttyOscCommand *GhosttyOscCommand; * * @ingroup osc */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_OSC_COMMAND_INVALID = 0, GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1, GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2, @@ -83,6 +63,7 @@ typedef enum { GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20, GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21, GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22, + GHOSTTY_OSC_COMMAND_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyOscCommandType; /** @@ -93,7 +74,7 @@ typedef enum { * * @ingroup osc */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { /** Invalid data type. Never results in any data extraction. */ GHOSTTY_OSC_DATA_INVALID = 0, @@ -108,6 +89,7 @@ typedef enum { * the same parser instance. Memory is owned by the parser. */ GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1, + GHOSTTY_OSC_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyOscCommandData; /** @@ -123,7 +105,7 @@ typedef enum { * * @ingroup osc */ -GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser); +GHOSTTY_API GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser); /** * Free an OSC parser instance. @@ -135,7 +117,7 @@ GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParse * * @ingroup osc */ -void ghostty_osc_free(GhosttyOscParser parser); +GHOSTTY_API void ghostty_osc_free(GhosttyOscParser parser); /** * Reset an OSC parser instance to its initial state. @@ -148,7 +130,7 @@ void ghostty_osc_free(GhosttyOscParser parser); * * @ingroup osc */ -void ghostty_osc_reset(GhosttyOscParser parser); +GHOSTTY_API void ghostty_osc_reset(GhosttyOscParser parser); /** * Parse the next byte in an OSC sequence. @@ -165,7 +147,7 @@ void ghostty_osc_reset(GhosttyOscParser parser); * * @ingroup osc */ -void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte); +GHOSTTY_API void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte); /** * Finalize OSC parsing and retrieve the parsed command. @@ -195,7 +177,7 @@ void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte); * * @ingroup osc */ -GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator); +GHOSTTY_API GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator); /** * Get the type of an OSC command. @@ -209,7 +191,7 @@ GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator); * * @ingroup osc */ -GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); +GHOSTTY_API GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); /** * Extract data from an OSC command. @@ -226,7 +208,7 @@ GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); * * @ingroup osc */ -bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out); +GHOSTTY_API bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out); /** @} */ diff --git a/include/ghostty/vt/paste.h b/include/ghostty/vt/paste.h index d90f303d43e..b3df5be4e0d 100644 --- a/include/ghostty/vt/paste.h +++ b/include/ghostty/vt/paste.h @@ -9,41 +9,32 @@ /** @defgroup paste Paste Utilities * - * Utilities for validating paste data safety. + * Utilities for validating and encoding paste data for terminal input. * * ## Basic Usage * * Use ghostty_paste_is_safe() to check if paste data contains potentially * dangerous sequences before sending it to the terminal. * - * ## Example - * - * @code{.c} - * #include - * #include - * #include - * - * 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 + * Use ghostty_paste_encode() to encode paste data for writing to the pty, + * including bracketed paste wrapping and unsafe byte stripping. + * + * ## Examples + * + * ### Safety Check + * + * @snippet c-vt-paste/src/main.c paste-safety + * + * ### Encoding + * + * @snippet c-vt-paste/src/main.c paste-encode * * @{ */ #include #include +#include #ifdef __cplusplus extern "C" { @@ -64,7 +55,42 @@ extern "C" { * @param len The length of the data in bytes * @return true if the data is safe to paste, false otherwise */ -bool ghostty_paste_is_safe(const char* data, size_t len); +GHOSTTY_API bool ghostty_paste_is_safe(const char* data, size_t len); + +/** + * Encode paste data for writing to the terminal pty. + * + * This function prepares paste data for terminal input by: + * - Stripping unsafe control bytes (NUL, ESC, DEL, etc.) by replacing + * them with spaces + * - Wrapping the data in bracketed paste sequences if @p bracketed is true + * - Replacing newlines with carriage returns if @p bracketed is false + * + * The input @p data buffer is modified in place during encoding. The + * encoded result (potentially with bracketed paste prefix/suffix) is + * written to the output buffer. + * + * If the output buffer is too small, the function returns + * GHOSTTY_OUT_OF_SPACE and sets the required size in @p out_written. + * The caller can then retry with a sufficiently sized buffer. + * + * @param data The paste data to encode (modified in place, may be NULL) + * @param data_len The length of the input data in bytes + * @param bracketed Whether bracketed paste mode is active + * @param buf Output buffer to write the encoded result 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 + */ +GHOSTTY_API GhosttyResult ghostty_paste_encode( + char* data, + size_t data_len, + bool bracketed, + char* buf, + size_t buf_len, + size_t* out_written); #ifdef __cplusplus } diff --git a/include/ghostty/vt/point.h b/include/ghostty/vt/point.h new file mode 100644 index 00000000000..8b717f4940c --- /dev/null +++ b/include/ghostty/vt/point.h @@ -0,0 +1,89 @@ +/** + * @file point.h + * + * Terminal point types for referencing locations in the terminal grid. + */ + +#ifndef GHOSTTY_VT_POINT_H +#define GHOSTTY_VT_POINT_H + +#include + +#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 GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_POINT_TAG_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, + } 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 00000000000..d1a3687d940 --- /dev/null +++ b/include/ghostty/vt/render.h @@ -0,0 +1,673 @@ +/** + * @file render.h + * + * Render state for creating high performance renderers. + */ + +#ifndef GHOSTTY_VT_RENDER_H +#define GHOSTTY_VT_RENDER_H + +#include +#include +#include +#include +#include +#include +#include + +#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 + * + * @{ + */ + +/** + * Dirty state of a render state after update. + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_RENDER_STATE_DIRTY_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRenderStateDirty; + +/** + * Visual style of the cursor. + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRenderStateCursorVisualStyle; + +/** + * Queryable data kinds for ghostty_render_state_get(). + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_RENDER_STATE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRenderStateData; + +/** + * Settable options for ghostty_render_state_set(). + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Set dirty state (GhosttyRenderStateDirty). */ + GHOSTTY_RENDER_STATE_OPTION_DIRTY = 0, + GHOSTTY_RENDER_STATE_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRenderStateOption; + +/** + * Queryable data kinds for ghostty_render_state_row_get(). + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_RENDER_STATE_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRenderStateRowData; + +/** + * Settable options for ghostty_render_state_row_set(). + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Set dirty state for the current row (bool). */ + GHOSTTY_RENDER_STATE_ROW_OPTION_DIRTY = 0, + GHOSTTY_RENDER_STATE_ROW_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API GhosttyResult ghostty_render_state_get(GhosttyRenderState state, + GhosttyRenderStateData data, + void* out); + +/** + * Get multiple data fields from a render state in a single call. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_get_multi( + GhosttyRenderState state, + size_t count, + const GhosttyRenderStateData* keys, + void** values, + size_t* out_written); + +/** + * 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_get( + GhosttyRenderStateRowIterator iterator, + GhosttyRenderStateRowData data, + void* out); + +/** + * Get multiple data fields from the current row in a single call. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_get_multi( + GhosttyRenderStateRowIterator iterator, + size_t count, + const GhosttyRenderStateRowData* keys, + void** values, + size_t* out_written); + +/** + * 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_cells_get( + GhosttyRenderStateRowCells cells, + GhosttyRenderStateRowCellsData data, + void* out); + +/** + * Get multiple data fields from the current cell in a single call. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param cells The row cells handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_cells_get_multi( + GhosttyRenderStateRowCells cells, + size_t count, + const GhosttyRenderStateRowCellsData* keys, + void** values, + size_t* out_written); + +/** + * Free a row cells instance. + * + * @param cells The row cells handle to free (may be NULL) + * + * @ingroup render + */ +GHOSTTY_API 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 65938ee766f..00000000000 --- 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 00000000000..9f639b58313 --- /dev/null +++ b/include/ghostty/vt/screen.h @@ -0,0 +1,400 @@ +/** + * @file screen.h + * + * Terminal screen cell and row types. + */ + +#ifndef GHOSTTY_VT_SCREEN_H +#define GHOSTTY_VT_SCREEN_H + +#include +#include +#include +#include + +#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 GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_CELL_CONTENT_TAG_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyCellContentTag; + +/** + * Cell wide property. + * + * Describes the width behavior of a cell. + * + * @ingroup screen + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_CELL_WIDE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_CELL_SEMANTIC_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyCellSemanticContent; + +/** + * Cell data types. + * + * These values specify what type of data to extract from a cell + * using `ghostty_cell_get`. + * + * @ingroup screen + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_CELL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_ROW_SEMANTIC_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRowSemanticPrompt; + +/** + * Row data types. + * + * These values specify what type of data to extract from a row + * using `ghostty_row_get`. + * + * @ingroup screen + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 + */ +GHOSTTY_API GhosttyResult ghostty_cell_get(GhosttyCell cell, + GhosttyCellData data, + void *out); + +/** + * Get multiple data fields from a cell in a single call. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param cell The cell value + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup screen + */ +GHOSTTY_API GhosttyResult ghostty_cell_get_multi(GhosttyCell cell, + size_t count, + const GhosttyCellData* keys, + void** values, + size_t* out_written); + +/** + * 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 + */ +GHOSTTY_API GhosttyResult ghostty_row_get(GhosttyRow row, + GhosttyRowData data, + void *out); + +/** + * Get multiple data fields from a row in a single call. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param row The row value + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup screen + */ +GHOSTTY_API GhosttyResult ghostty_row_get_multi(GhosttyRow row, + size_t count, + const GhosttyRowData* keys, + void** values, + size_t* out_written); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_SCREEN_H */ diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h new file mode 100644 index 00000000000..9f878fadcb3 --- /dev/null +++ b/include/ghostty/vt/selection.h @@ -0,0 +1,53 @@ +/** + * @file selection.h + * + * Selection range type for specifying a region of terminal content. + */ + +#ifndef GHOSTTY_VT_SELECTION_H +#define GHOSTTY_VT_SELECTION_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup selection Selection + * + * A selection range defined by two grid references that identifies a + * contiguous or rectangular region of terminal content. + * + * @{ + */ + +/** + * A selection range defined by two grid references. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttySelection). */ + size_t size; + + /** Start of the selection range (inclusive). */ + GhosttyGridRef start; + + /** End of the selection range (inclusive). */ + GhosttyGridRef end; + + /** Whether the selection is rectangular (block) rather than linear. */ + bool rectangle; +} GhosttySelection; + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_SELECTION_H */ diff --git a/include/ghostty/vt/sgr.h b/include/ghostty/vt/sgr.h index 5aaa368d21b..8eec11dc970 100644 --- a/include/ghostty/vt/sgr.h +++ b/include/ghostty/vt/sgr.h @@ -31,49 +31,14 @@ * * ## Example * - * @code{.c} - * #include - * #include - * #include - * - * 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 #include -#include +#include #include #include #include @@ -82,16 +47,6 @@ extern "C" { #endif -/** - * Opaque handle to an SGR parser instance. - * - * This handle represents an SGR (Select Graphic Rendition) parser that can - * be used to parse SGR sequences and extract individual text attributes. - * - * @ingroup sgr - */ -typedef struct GhosttySgrParser* GhosttySgrParser; - /** * SGR attribute tags. * @@ -100,7 +55,7 @@ typedef struct GhosttySgrParser* GhosttySgrParser; * * @ingroup sgr */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_SGR_ATTR_UNSET = 0, GHOSTTY_SGR_ATTR_UNKNOWN = 1, GHOSTTY_SGR_ATTR_BOLD = 2, @@ -132,6 +87,7 @@ typedef enum { GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 28, GHOSTTY_SGR_ATTR_BG_256 = 29, GHOSTTY_SGR_ATTR_FG_256 = 30, + GHOSTTY_SGR_ATTR_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySgrAttributeTag; /** @@ -139,13 +95,14 @@ typedef enum { * * @ingroup sgr */ -typedef enum { +typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_SGR_UNDERLINE_NONE = 0, GHOSTTY_SGR_UNDERLINE_SINGLE = 1, GHOSTTY_SGR_UNDERLINE_DOUBLE = 2, GHOSTTY_SGR_UNDERLINE_CURLY = 3, GHOSTTY_SGR_UNDERLINE_DOTTED = 4, GHOSTTY_SGR_UNDERLINE_DASHED = 5, + GHOSTTY_SGR_UNDERLINE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySgrUnderline; /** @@ -219,7 +176,7 @@ typedef struct { * * @ingroup sgr */ -GhosttyResult ghostty_sgr_new(const GhosttyAllocator* allocator, +GHOSTTY_API GhosttyResult ghostty_sgr_new(const GhosttyAllocator* allocator, GhosttySgrParser* parser); /** @@ -233,7 +190,7 @@ GhosttyResult ghostty_sgr_new(const GhosttyAllocator* allocator, * * @ingroup sgr */ -void ghostty_sgr_free(GhosttySgrParser parser); +GHOSTTY_API void ghostty_sgr_free(GhosttySgrParser parser); /** * Reset an SGR parser instance to the beginning of the parameter list. @@ -246,7 +203,7 @@ void ghostty_sgr_free(GhosttySgrParser parser); * * @ingroup sgr */ -void ghostty_sgr_reset(GhosttySgrParser parser); +GHOSTTY_API void ghostty_sgr_reset(GhosttySgrParser parser); /** * Set SGR parameters for parsing. @@ -278,7 +235,7 @@ void ghostty_sgr_reset(GhosttySgrParser parser); * * @ingroup sgr */ -GhosttyResult ghostty_sgr_set_params(GhosttySgrParser parser, +GHOSTTY_API GhosttyResult ghostty_sgr_set_params(GhosttySgrParser parser, const uint16_t* params, const char* separators, size_t len); @@ -296,7 +253,7 @@ GhosttyResult ghostty_sgr_set_params(GhosttySgrParser parser, * * @ingroup sgr */ -bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute* attr); +GHOSTTY_API bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute* attr); /** * Get the full parameter list from an unknown SGR attribute. @@ -311,7 +268,7 @@ bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute* attr); * * @ingroup sgr */ -size_t ghostty_sgr_unknown_full(GhosttySgrUnknown unknown, +GHOSTTY_API size_t ghostty_sgr_unknown_full(GhosttySgrUnknown unknown, const uint16_t** ptr); /** @@ -327,7 +284,7 @@ size_t ghostty_sgr_unknown_full(GhosttySgrUnknown unknown, * * @ingroup sgr */ -size_t ghostty_sgr_unknown_partial(GhosttySgrUnknown unknown, +GHOSTTY_API size_t ghostty_sgr_unknown_partial(GhosttySgrUnknown unknown, const uint16_t** ptr); /** @@ -342,7 +299,7 @@ size_t ghostty_sgr_unknown_partial(GhosttySgrUnknown unknown, * * @ingroup sgr */ -GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute attr); +GHOSTTY_API GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute attr); /** * Get the value from an SGR attribute. @@ -356,7 +313,7 @@ GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute attr); * * @ingroup sgr */ -GhosttySgrAttributeValue* ghostty_sgr_attribute_value( +GHOSTTY_API GhosttySgrAttributeValue* ghostty_sgr_attribute_value( GhosttySgrAttribute* attr); #ifdef __wasm__ @@ -370,7 +327,7 @@ GhosttySgrAttributeValue* ghostty_sgr_attribute_value( * * @ingroup wasm */ -GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void); +GHOSTTY_API GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void); /** * Free memory for an SGR attribute (WebAssembly only). @@ -381,7 +338,7 @@ GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void); * * @ingroup wasm */ -void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr); +GHOSTTY_API void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr); #endif #ifdef __cplusplus diff --git a/include/ghostty/vt/size_report.h b/include/ghostty/vt/size_report.h new file mode 100644 index 00000000000..da33e5e5593 --- /dev/null +++ b/include/ghostty/vt/size_report.h @@ -0,0 +1,101 @@ +/** + * @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 +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Size report style. + * + * Determines the output format for the terminal size report. + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_SIZE_REPORT_STYLE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 + */ +GHOSTTY_API 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 00000000000..b6bf860ebe7 --- /dev/null +++ b/include/ghostty/vt/style.h @@ -0,0 +1,139 @@ +/** + * @file style.h + * + * Terminal cell style types. + */ + +#ifndef GHOSTTY_VT_STYLE_H +#define GHOSTTY_VT_STYLE_H + +#include +#include +#include +#include +#include + +#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_ENUM_TYPED { + GHOSTTY_STYLE_COLOR_NONE = 0, + GHOSTTY_STYLE_COLOR_PALETTE = 1, + GHOSTTY_STYLE_COLOR_RGB = 2, + GHOSTTY_STYLE_COLOR_TAG_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, + } 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API bool ghostty_style_is_default(const GhosttyStyle* style); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_STYLE_H */ diff --git a/include/ghostty/vt/sys.h b/include/ghostty/vt/sys.h new file mode 100644 index 00000000000..ae90596927d --- /dev/null +++ b/include/ghostty/vt/sys.h @@ -0,0 +1,210 @@ +/** + * @file sys.h + * + * System interface - runtime-swappable implementations for external dependencies. + */ + +#ifndef GHOSTTY_VT_SYS_H +#define GHOSTTY_VT_SYS_H + +#include +#include +#include +#include +#include + +/** @defgroup sys System Interface + * + * Runtime-swappable function pointers for operations that depend on + * external implementations (e.g. image decoding). + * + * These are process-global settings that must be configured at startup + * before any terminal functionality that depends on them is used. + * Setting these enables various optional features of the terminal. For + * example, setting a PNG decoder enables PNG image support in the Kitty + * Graphics Protocol. + * + * Use ghostty_sys_set() with a `GhosttySysOption` to install or clear + * an implementation. Passing NULL as the value clears the implementation + * and disables the corresponding feature. + * + * ## Example + * + * ### Defining a PNG decode callback + * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-decode-png + * + * ### Installing the callback and sending a PNG image + * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-main + * + * @{ + */ + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Result of decoding an image. + * + * The `data` buffer must be allocated through the allocator provided to + * the decode callback. The library takes ownership and will free it + * with the same allocator. + */ +typedef struct { + /** Image width in pixels. */ + uint32_t width; + + /** Image height in pixels. */ + uint32_t height; + + /** Pointer to the decoded RGBA pixel data. */ + uint8_t* data; + + /** Length of the pixel data in bytes. */ + size_t data_len; +} GhosttySysImage; + +/** + * Log severity levels for the log callback. + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_SYS_LOG_LEVEL_ERROR = 0, + GHOSTTY_SYS_LOG_LEVEL_WARNING = 1, + GHOSTTY_SYS_LOG_LEVEL_INFO = 2, + GHOSTTY_SYS_LOG_LEVEL_DEBUG = 3, + GHOSTTY_SYS_LOG_LEVEL_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySysLogLevel; + +/** + * Callback type for logging. + * + * When installed, internal library log messages are delivered through + * this callback instead of being discarded. The embedder is responsible + * for formatting and routing log output. + * + * @p scope is the log scope name as UTF-8 bytes (e.g. "osc", "kitty"). + * When the log is unscoped (default scope), @p scope_len is 0. + * + * All pointer arguments are only valid for the duration of the callback. + * The callback must be safe to call from any thread. + * + * @param userdata The userdata pointer set via GHOSTTY_SYS_OPT_USERDATA + * @param level The severity level of the log message + * @param scope Pointer to the scope name bytes + * @param scope_len Length of the scope name in bytes + * @param message Pointer to the log message bytes + * @param message_len Length of the log message in bytes + */ +typedef void (*GhosttySysLogFn)( + void* userdata, + GhosttySysLogLevel level, + const uint8_t* scope, + size_t scope_len, + const uint8_t* message, + size_t message_len); + +/** + * Callback type for PNG decoding. + * + * Decodes raw PNG data into RGBA pixels. The output pixel data must be + * allocated through the provided allocator. The library takes ownership + * of the buffer and will free it with the same allocator. + * + * @param userdata The userdata pointer set via GHOSTTY_SYS_OPT_USERDATA + * @param allocator The allocator to use for the output pixel buffer + * @param data Pointer to the raw PNG data + * @param data_len Length of the raw PNG data in bytes + * @param[out] out On success, filled with the decoded image + * @return true on success, false on failure + */ +typedef bool (*GhosttySysDecodePngFn)( + void* userdata, + const GhosttyAllocator* allocator, + const uint8_t* data, + size_t data_len, + GhosttySysImage* out); + +/** + * System option identifiers for ghostty_sys_set(). + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** + * Set the userdata pointer passed to all sys callbacks. + * + * Input type: void* (or NULL) + */ + GHOSTTY_SYS_OPT_USERDATA = 0, + + /** + * Set the PNG decode function. + * + * When set, the terminal can accept PNG images via the Kitty + * Graphics Protocol. When cleared (NULL value), PNG decoding is + * unsupported and PNG image data will be rejected. + * + * Input type: GhosttySysDecodePngFn (function pointer, or NULL) + */ + GHOSTTY_SYS_OPT_DECODE_PNG = 1, + + /** + * Set the log callback. + * + * When set, internal library log messages are delivered to this + * callback. When cleared (NULL value), log messages are silently + * discarded. + * + * Use ghostty_sys_log_stderr as a convenience callback that + * writes formatted messages to stderr. + * + * Which log levels are emitted depends on the build mode of the + * library and is not configurable at runtime. Debug builds emit + * all levels (debug and above). Release builds emit info and + * above; debug-level messages are compiled out entirely and will + * never reach the callback. + * + * Input type: GhosttySysLogFn (function pointer, or NULL) + */ + GHOSTTY_SYS_OPT_LOG = 2, + GHOSTTY_SYS_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySysOption; + +/** + * Set a system-level option. + * + * Configures a process-global implementation function. These should be + * set once at startup before using any terminal functionality that + * depends on them. + * + * @param option The option to set + * @param value Pointer to the value (type depends on the option), + * or NULL to clear it + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the + * option is not recognized + */ +GHOSTTY_API GhosttyResult ghostty_sys_set(GhosttySysOption option, + const void* value); + +/** + * Built-in log callback that writes to stderr. + * + * Formats each message as "[level](scope): message\n". + * Can be passed directly to ghostty_sys_set(): + * + * @code + * ghostty_sys_set(GHOSTTY_SYS_OPT_LOG, &ghostty_sys_log_stderr); + * @endcode + */ +GHOSTTY_API void ghostty_sys_log_stderr(void* userdata, + GhosttySysLogLevel level, + const uint8_t* scope, + size_t scope_len, + const uint8_t* message, + size_t message_len); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_SYS_H */ diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h new file mode 100644 index 00000000000..433308c8430 --- /dev/null +++ b/include/ghostty/vt/terminal.h @@ -0,0 +1,1143 @@ +/** + * @file terminal.h + * + * Complete terminal emulator state and rendering. + */ + +#ifndef GHOSTTY_VT_TERMINAL_H +#define GHOSTTY_VT_TERMINAL_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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(). + * + * ### Example: VT stream processing + * @snippet c-vt-stream/src/main.c vt-stream-init + * @snippet c-vt-stream/src/main.c vt-stream-write + * + * ## 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 + * + * ## Color Theme + * + * The terminal maintains a set of colors used for rendering: a foreground + * color, a background color, a cursor color, and a 256-color palette. Each + * of these has two layers: a **default** value set by the embedder, and an + * **override** value that programs running in the terminal can set via OSC + * escape sequences (e.g. OSC 10/11/12 for foreground/background/cursor, + * OSC 4 for individual palette entries). + * + * ### Default Colors + * + * Use ghostty_terminal_set() with the color options to configure the + * default colors. These represent the theme or configuration chosen by + * the embedder. Passing `NULL` clears the default, leaving the color + * unset. + * + * | Option | Input Type | Description | + * |-----------------------------------------|-------------------------|--------------------------------------| + * | `GHOSTTY_TERMINAL_OPT_COLOR_FOREGROUND` | `GhosttyColorRgb*` | Default foreground color | + * | `GHOSTTY_TERMINAL_OPT_COLOR_BACKGROUND` | `GhosttyColorRgb*` | Default background color | + * | `GHOSTTY_TERMINAL_OPT_COLOR_CURSOR` | `GhosttyColorRgb*` | Default cursor color | + * | `GHOSTTY_TERMINAL_OPT_COLOR_PALETTE` | `GhosttyColorRgb[256]*` | Default 256-color palette | + * + * For the palette, passing `NULL` resets to the built-in default palette. + * The palette set operation preserves any per-index OSC overrides that + * programs have applied; only unmodified indices are updated. + * + * ### Reading colors + * + * Use ghostty_terminal_get() to read colors. There are two variants for + * each color: the **effective** value (which returns the OSC override if + * one is active, otherwise the default) and the **default** value (which + * ignores any OSC overrides). + * + * | Data | Output Type | Description | + * |---------------------------------------------------|-------------------------|------------------------------------------------| + * | `GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND` | `GhosttyColorRgb*` | Effective foreground (override or default) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND` | `GhosttyColorRgb*` | Effective background (override or default) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_CURSOR` | `GhosttyColorRgb*` | Effective cursor (override or default) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_PALETTE` | `GhosttyColorRgb[256]*` | Current palette (with any OSC overrides) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND_DEFAULT` | `GhosttyColorRgb*` | Default foreground only (ignores OSC override) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND_DEFAULT` | `GhosttyColorRgb*` | Default background only (ignores OSC override) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_CURSOR_DEFAULT` | `GhosttyColorRgb*` | Default cursor only (ignores OSC override) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT` | `GhosttyColorRgb[256]*` | Default palette only (ignores OSC overrides) | + * + * For foreground, background, and cursor colors, the getters return + * `GHOSTTY_NO_VALUE` if no color is configured (neither a default nor an + * OSC override). The palette getters always succeed since the palette + * always has a value (the built-in default if nothing else is set). + * + * ### Setting a color theme + * @snippet c-vt-colors/src/main.c colors-set-defaults + * + * ### Reading effective and default colors + * @snippet c-vt-colors/src/main.c colors-read + * + * ### Full example with OSC overrides + * @snippet c-vt-colors/src/main.c colors-main + * + * @{ + */ + +/** + * 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 GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_SCROLL_VIEWPORT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 GHOSTTY_ENUM_TYPED { + /** The primary (normal) screen. */ + GHOSTTY_TERMINAL_SCREEN_PRIMARY = 0, + + /** The alternate screen. */ + GHOSTTY_TERMINAL_SCREEN_ALTERNATE = 1, + GHOSTTY_TERMINAL_SCREEN_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 GHOSTTY_ENUM_TYPED { + /** + * 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, + + /** + * Set the default foreground color. + * + * A NULL value pointer clears the default (unset). + * + * Input type: GhosttyColorRgb* + */ + GHOSTTY_TERMINAL_OPT_COLOR_FOREGROUND = 11, + + /** + * Set the default background color. + * + * A NULL value pointer clears the default (unset). + * + * Input type: GhosttyColorRgb* + */ + GHOSTTY_TERMINAL_OPT_COLOR_BACKGROUND = 12, + + /** + * Set the default cursor color. + * + * A NULL value pointer clears the default (unset). + * + * Input type: GhosttyColorRgb* + */ + GHOSTTY_TERMINAL_OPT_COLOR_CURSOR = 13, + + /** + * Set the default 256-color palette. + * + * The value must point to an array of exactly 256 GhosttyColorRgb values. + * A NULL value pointer resets to the built-in default palette. + * + * Input type: GhosttyColorRgb[256]* + */ + GHOSTTY_TERMINAL_OPT_COLOR_PALETTE = 14, + + /** + * Set the Kitty image storage limit in bytes. + * + * Applied to all initialized screens (primary and alternate). + * A value of zero disables the Kitty graphics protocol entirely, + * deleting all stored images and placements. A NULL value pointer + * is equivalent to zero (disables). Has no effect when Kitty graphics + * are disabled at build time. + * + * Input type: uint64_t* + */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT = 15, + + /** + * Enable or disable Kitty image loading via the file medium. + * + * A NULL value pointer is a no-op. Has no effect when Kitty graphics + * are disabled at build time. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_FILE = 16, + + /** + * Enable or disable Kitty image loading via the temporary file medium. + * + * A NULL value pointer is a no-op. Has no effect when Kitty graphics + * are disabled at build time. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_TEMP_FILE = 17, + + /** + * Enable or disable Kitty image loading via the shared memory medium. + * + * A NULL value pointer is a no-op. Has no effect when Kitty graphics + * are disabled at build time. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_SHARED_MEM = 18, + GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalOption; + +/** + * Terminal data types. + * + * These values specify what type of data to extract from a terminal + * using `ghostty_terminal_get`. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** 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, + + /** + * The effective foreground color (override or default). + * + * Returns GHOSTTY_NO_VALUE if no foreground color is set. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND = 18, + + /** + * The effective background color (override or default). + * + * Returns GHOSTTY_NO_VALUE if no background color is set. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND = 19, + + /** + * The effective cursor color (override or default). + * + * Returns GHOSTTY_NO_VALUE if no cursor color is set. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_TERMINAL_DATA_COLOR_CURSOR = 20, + + /** + * The current 256-color palette. + * + * Output type: GhosttyColorRgb[256] * + */ + GHOSTTY_TERMINAL_DATA_COLOR_PALETTE = 21, + + /** + * The default foreground color (ignoring any OSC override). + * + * Returns GHOSTTY_NO_VALUE if no default foreground color is set. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND_DEFAULT = 22, + + /** + * The default background color (ignoring any OSC override). + * + * Returns GHOSTTY_NO_VALUE if no default background color is set. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND_DEFAULT = 23, + + /** + * The default cursor color (ignoring any OSC override). + * + * Returns GHOSTTY_NO_VALUE if no default cursor color is set. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_TERMINAL_DATA_COLOR_CURSOR_DEFAULT = 24, + + /** + * The default 256-color palette (ignoring any OSC overrides). + * + * Output type: GhosttyColorRgb[256] * + */ + GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT = 25, + + /** + * The Kitty image storage limit in bytes for the active screen. + * + * A value of zero means the Kitty graphics protocol is disabled. + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: uint64_t * + */ + GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_STORAGE_LIMIT = 26, + + /** + * Whether the file medium is enabled for Kitty image loading on the + * active screen. + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_FILE = 27, + + /** + * Whether the temporary file medium is enabled for Kitty image loading + * on the active screen. + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_TEMP_FILE = 28, + + /** + * Whether the shared memory medium is enabled for Kitty image loading + * on the active screen. + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_SHARED_MEM = 29, + + /** + * The Kitty graphics image storage for the active screen. + * + * Returns a borrowed pointer to the image storage. The pointer is valid + * until the next mutating terminal call (e.g. ghostty_terminal_vt_write() + * or ghostty_terminal_reset()). + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: GhosttyKittyGraphics * + */ + GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS = 30, + GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API GhosttyResult ghostty_terminal_mode_get(GhosttyTerminal terminal, + GhosttyMode mode, + bool* out_value); + +/** + * Set the value of a terminal mode. + * + * Sets the mode identified by the given mode to the specified value. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param mode The mode identifying the mode to set + * @param value true to set the mode, false to reset it + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal + * is NULL or the mode does not correspond to a known mode + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_mode_set(GhosttyTerminal terminal, + GhosttyMode mode, + bool value); + +/** + * Get data from a terminal instance. + * + * 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 + */ +GHOSTTY_API GhosttyResult ghostty_terminal_get(GhosttyTerminal terminal, + GhosttyTerminalData data, + void *out); + +/** + * Get multiple data fields from a terminal in a single call. + * + * This is an optimization over calling ghostty_terminal_get() + * repeatedly, particularly useful in environments with high per-call + * overhead such as FFI or Cgo. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * The type of each values[i] pointer must match the output type + * documented for keys[i]. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param terminal The terminal handle (may be NULL) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_get_multi(GhosttyTerminal terminal, + size_t count, + const GhosttyTerminalData* keys, + void** values, + size_t* out_written); + +/** + * 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 + */ +GHOSTTY_API GhosttyResult ghostty_terminal_grid_ref(GhosttyTerminal terminal, + GhosttyPoint point, + GhosttyGridRef *out_ref); + +/** + * Convert a grid reference back to a point in the given coordinate system. + * + * This is the inverse of ghostty_terminal_grid_ref(): given a grid reference, + * it returns the x/y coordinates in the requested coordinate system (active, + * viewport, screen, or history). + * + * The grid reference must have been obtained from the same terminal instance. + * Like all grid references, it is only valid until the next mutating terminal + * call. + * + * Not every grid reference is representable in every coordinate system. For + * example, a cell in scrollback history cannot be expressed in active + * coordinates, and a cell that has scrolled off the visible area cannot be + * expressed in viewport coordinates. In these cases, the function returns + * GHOSTTY_NO_VALUE. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param ref Pointer to the grid reference to convert + * @param tag The target coordinate system + * @param[out] out On success, set to the coordinate in the requested system (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal + * or ref is NULL/invalid, GHOSTTY_NO_VALUE if the ref falls outside + * the requested coordinate system + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_point_from_grid_ref( + GhosttyTerminal terminal, + const GhosttyGridRef *ref, + GhosttyPointTag tag, + GhosttyPointCoordinate *out); + +/** @} */ + +#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 00000000000..e0be0b77d13 --- /dev/null +++ b/include/ghostty/vt/types.h @@ -0,0 +1,257 @@ +/** + * @file types.h + * + * Common types, macros, and utilities for libghostty-vt. + */ + +#ifndef GHOSTTY_VT_TYPES_H +#define GHOSTTY_VT_TYPES_H + +#include +#include +#include + +// Symbol visibility for shared library builds. On Windows, functions +// are exported from the DLL when building and imported when consuming. +// On other platforms with GCC/Clang, functions are marked with default +// visibility so they remain accessible when the library is built with +// -fvisibility=hidden. For static library builds, define GHOSTTY_STATIC +// before including this header to make this a no-op. +#ifndef GHOSTTY_API +#if defined(GHOSTTY_STATIC) + #define GHOSTTY_API +#elif defined(_WIN32) || defined(_WIN64) + #ifdef GHOSTTY_BUILD_SHARED + #define GHOSTTY_API __declspec(dllexport) + #else + #define GHOSTTY_API __declspec(dllimport) + #endif +#elif defined(__GNUC__) && __GNUC__ >= 4 + #define GHOSTTY_API __attribute__((visibility("default"))) +#else + #define GHOSTTY_API +#endif +#endif + +/** + * Enum int-sizing helpers. + * + * The Zig side backs all C enums with c_int, so the C declarations + * must use int as their underlying type to maintain ABI compatibility. + * + * C23 (detected via __STDC_VERSION__ >= 202311L) supports explicit + * enum underlying types with `enum : int { ... }`. For pre-C23 + * compilers, which are free to choose any type that can represent + * all values (C11 §6.7.2.2), we add an INT_MAX sentinel as the last + * entry to force the compiler to use int. + * + * INT_MAX is used rather than a fixed constant like 0xFFFFFFFF + * because enum constants must have type int (which is signed). + * Values above INT_MAX overflow signed int and are a constraint + * violation in standard C; compilers that accept them interpret them + * as negative values via two's complement, which can collide with + * legitimate negative enum values. + * + * Usage: + * @code + * typedef enum GHOSTTY_ENUM_TYPED { + * FOO_A = 0, + * FOO_B = 1, + * FOO_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, + * } Foo; + * @endcode + */ +#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L +#define GHOSTTY_ENUM_TYPED : int +#else +#define GHOSTTY_ENUM_TYPED +#endif +#define GHOSTTY_ENUM_MAX_VALUE INT_MAX + +/** + * Result codes for libghostty-vt operations. + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** 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, + /** The requested value has no value */ + GHOSTTY_NO_VALUE = -4, + GHOSTTY_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyResult; + +/* ---- Opaque handles ---- */ + +/** + * Opaque handle to a terminal instance. + * + * @ingroup terminal + */ +typedef struct GhosttyTerminalImpl* GhosttyTerminal; + +/** + * Opaque handle to a Kitty graphics image storage. + * + * Obtained via ghostty_terminal_get() with + * GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS. The pointer is borrowed from + * the terminal and remains valid until the next mutating terminal call + * (e.g. ghostty_terminal_vt_write() or ghostty_terminal_reset()). + * + * @ingroup kitty_graphics + */ +typedef struct GhosttyKittyGraphicsImpl* GhosttyKittyGraphics; + +/** + * Opaque handle to a Kitty graphics image. + * + * Obtained via ghostty_kitty_graphics_image() with an image ID. The + * pointer is borrowed from the storage and remains valid until the next + * mutating terminal call. + * + * @ingroup kitty_graphics + */ +typedef const struct GhosttyKittyGraphicsImageImpl* GhosttyKittyGraphicsImage; + +/** + * Opaque handle to a Kitty graphics placement iterator. + * + * @ingroup kitty_graphics + */ +typedef struct GhosttyKittyGraphicsPlacementIteratorImpl* GhosttyKittyGraphicsPlacementIterator; + +/** + * Opaque handle to a render state instance. + * + * @ingroup render + */ +typedef struct GhosttyRenderStateImpl* GhosttyRenderState; + +/** + * Opaque handle to a render-state row iterator. + * + * @ingroup render + */ +typedef struct GhosttyRenderStateRowIteratorImpl* GhosttyRenderStateRowIterator; + +/** + * Opaque handle to render-state row cells. + * + * @ingroup render + */ +typedef struct GhosttyRenderStateRowCellsImpl* GhosttyRenderStateRowCells; + +/** + * Opaque handle to an SGR parser instance. + * + * This handle represents an SGR (Select Graphic Rendition) parser that can + * be used to parse SGR sequences and extract individual text attributes. + * + * @ingroup sgr + */ +typedef struct GhosttySgrParserImpl* GhosttySgrParser; + +/** + * Opaque handle to a formatter instance. + * + * @ingroup formatter + */ +typedef struct GhosttyFormatterImpl* GhosttyFormatter; + +/** + * Opaque handle to an OSC parser instance. + * + * This handle represents an OSC (Operating System Command) parser that can + * be used to parse the contents of OSC sequences. + * + * @ingroup osc + */ +typedef struct GhosttyOscParserImpl* GhosttyOscParser; + +/** + * Opaque handle to a single OSC command. + * + * This handle represents a parsed OSC (Operating System Command) command. + * The command can be queried for its type and associated data. + * + * @ingroup osc + */ +typedef struct GhosttyOscCommandImpl* GhosttyOscCommand; + +/* ---- Common value types ---- */ + +/** + * 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) }) + +/** + * Return a pointer to a null-terminated JSON string describing the + * layout of every C API struct for the current target. + * + * This is primarily useful for language bindings that can't easily + * set C struct fields and need to do so via byte offsets. For example, + * WebAssembly modules can't share struct definitions with the host. + * + * Example (abbreviated): + * @code{.json} + * { + * "GhosttyMouseEncoderSize": { + * "size": 40, + * "align": 8, + * "fields": { + * "size": { "offset": 0, "size": 8, "type": "u64" }, + * "screen_width": { "offset": 8, "size": 4, "type": "u32" }, + * "screen_height": { "offset": 12, "size": 4, "type": "u32" }, + * "cell_width": { "offset": 16, "size": 4, "type": "u32" }, + * "cell_height": { "offset": 20, "size": 4, "type": "u32" }, + * "padding_top": { "offset": 24, "size": 4, "type": "u32" }, + * "padding_bottom": { "offset": 28, "size": 4, "type": "u32" }, + * "padding_right": { "offset": 32, "size": 4, "type": "u32" }, + * "padding_left": { "offset": 36, "size": 4, "type": "u32" } + * } + * } + * } + * @endcode + * + * The returned pointer is valid for the lifetime of the process. + * + * @return Pointer to the null-terminated JSON string. + */ +GHOSTTY_API const char *ghostty_type_json(void); + +#endif /* GHOSTTY_VT_TYPES_H */ diff --git a/include/ghostty/vt/wasm.h b/include/ghostty/vt/wasm.h index 37a8263265d..e2b63e2c601 100644 --- a/include/ghostty/vt/wasm.h +++ b/include/ghostty/vt/wasm.h @@ -11,6 +11,7 @@ #include #include +#include /** @defgroup wasm WebAssembly Utilities * @@ -74,7 +75,7 @@ * @return Pointer to allocated opaque pointer, or NULL if allocation failed * @ingroup wasm */ -void** ghostty_wasm_alloc_opaque(void); +GHOSTTY_API void** ghostty_wasm_alloc_opaque(void); /** * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque(). @@ -82,7 +83,7 @@ void** ghostty_wasm_alloc_opaque(void); * @param ptr Pointer to free, or NULL (NULL is safely ignored) * @ingroup wasm */ -void ghostty_wasm_free_opaque(void **ptr); +GHOSTTY_API void ghostty_wasm_free_opaque(void **ptr); /** * Allocate an array of uint8_t values. @@ -91,7 +92,7 @@ void ghostty_wasm_free_opaque(void **ptr); * @return Pointer to allocated array, or NULL if allocation failed * @ingroup wasm */ -uint8_t* ghostty_wasm_alloc_u8_array(size_t len); +GHOSTTY_API uint8_t* ghostty_wasm_alloc_u8_array(size_t len); /** * Free an array allocated by ghostty_wasm_alloc_u8_array(). @@ -100,7 +101,7 @@ uint8_t* ghostty_wasm_alloc_u8_array(size_t len); * @param len Length of the array (must match the length passed to alloc) * @ingroup wasm */ -void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len); +GHOSTTY_API void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len); /** * Allocate an array of uint16_t values. @@ -109,7 +110,7 @@ void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len); * @return Pointer to allocated array, or NULL if allocation failed * @ingroup wasm */ -uint16_t* ghostty_wasm_alloc_u16_array(size_t len); +GHOSTTY_API uint16_t* ghostty_wasm_alloc_u16_array(size_t len); /** * Free an array allocated by ghostty_wasm_alloc_u16_array(). @@ -118,7 +119,7 @@ uint16_t* ghostty_wasm_alloc_u16_array(size_t len); * @param len Length of the array (must match the length passed to alloc) * @ingroup wasm */ -void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len); +GHOSTTY_API void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len); /** * Allocate a single uint8_t value. @@ -126,7 +127,7 @@ void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len); * @return Pointer to allocated uint8_t, or NULL if allocation failed * @ingroup wasm */ -uint8_t* ghostty_wasm_alloc_u8(void); +GHOSTTY_API uint8_t* ghostty_wasm_alloc_u8(void); /** * Free a uint8_t allocated by ghostty_wasm_alloc_u8(). @@ -134,7 +135,7 @@ uint8_t* ghostty_wasm_alloc_u8(void); * @param ptr Pointer to free, or NULL (NULL is safely ignored) * @ingroup wasm */ -void ghostty_wasm_free_u8(uint8_t *ptr); +GHOSTTY_API void ghostty_wasm_free_u8(uint8_t *ptr); /** * Allocate a single size_t value. @@ -142,7 +143,7 @@ void ghostty_wasm_free_u8(uint8_t *ptr); * @return Pointer to allocated size_t, or NULL if allocation failed * @ingroup wasm */ -size_t* ghostty_wasm_alloc_usize(void); +GHOSTTY_API size_t* ghostty_wasm_alloc_usize(void); /** * Free a size_t allocated by ghostty_wasm_alloc_usize(). @@ -150,7 +151,7 @@ size_t* ghostty_wasm_alloc_usize(void); * @param ptr Pointer to free, or NULL (NULL is safely ignored) * @ingroup wasm */ -void ghostty_wasm_free_usize(size_t *ptr); +GHOSTTY_API void ghostty_wasm_free_usize(size_t *ptr); /** @} */ diff --git a/macos/Ghostty-Info.plist b/macos/Ghostty-Info.plist index c160144c628..2ec30cf86c5 100644 --- a/macos/Ghostty-Info.plist +++ b/macos/Ghostty-Info.plist @@ -4,6 +4,8 @@ CFBundleName Ghostree + NSAutoFillRequiresTextContentTypeForOneTimeCodeOnMac + NSDockTilePlugIn DockTilePlugin.plugin CFBundleDocumentTypes diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 8d57070446a..686b8400d16 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -243,6 +243,7 @@ Ghostty/Ghostty.Error.swift, Ghostty/Ghostty.Event.swift, Ghostty/Ghostty.Input.swift, + Ghostty/Ghostty.MenuShortcutManager.swift, Ghostty/Ghostty.Surface.swift, "Ghostty/NSEvent+Extension.swift", "Ghostty/Surface View/InspectorView.swift", diff --git a/macos/GhosttyUITests/GhosttyCommandPaletteTests.swift b/macos/GhosttyUITests/GhosttyCommandPaletteTests.swift new file mode 100644 index 00000000000..428682b4f1c --- /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 41993247ab7..ca3f5667782 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 00000000000..b8f20261710 --- /dev/null +++ b/macos/GhosttyUITests/GhosttyMouseStateTests.swift @@ -0,0 +1,87 @@ +// +// 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") + } + + @MainActor func testSearchFocusState() async throws { + let app = try ghosttyApplication() + app.activate() + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 5), "New window should appear") + app.typeKey("f", modifierFlags: .command) + + let textfield = app.textFields.firstMatch + XCTAssertTrue(textfield.waitForExistence(timeout: 5), "Search field should appear") + app.typeText("a") + + XCTAssertTrue(textfield.stringValue == "a", "Search text should be `a`") + + textfield.coordinate(withNormalizedOffset: .zero) + .withOffset(.init(dx: textfield.frame.width * 0.5, dy: 0)) + .click() + + app.typeText("b") + + XCTAssertTrue(textfield.stringValue == "ab", "Search text should be `ab`") + + // resign + app.typeKey(.escape, modifierFlags: []) + + // dismiss + app.typeKey(.escape, modifierFlags: []) + + XCTAssertTrue(textfield.waitForNonExistence(timeout: 5), "Search field should disappear") + } +} + +private extension XCUIElement { + var stringValue: String? { + (value as? String) + } +} diff --git a/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift b/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift new file mode 100644 index 00000000000..399c2531a4d --- /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 dc87bacc13f..f85f7ddf2bd 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -1,5 +1,4 @@ import AppKit -import Combine import SwiftUI import UserNotifications import OSLog @@ -99,9 +98,6 @@ class AppDelegate: NSObject, /// The ghostty global state. Only one per process. let ghostty: Ghostty.App - /// Worktrunk integration state shared across windows. - let worktrunkStore = WorktrunkStore() - /// The global undo manager for app-level state such as window restoration. lazy var undoManager = ExpiringUndoManager() @@ -135,9 +131,6 @@ class AppDelegate: NSObject, } } - /// The settings window controller, lazily created on first Cmd+, press. - private var settingsWindowController: NSWindowController? - /// Manages updates let updateController = UpdateController() var updateViewModel: UpdateViewModel { @@ -155,15 +148,14 @@ class AppDelegate: NSObject, /// The observer for the app appearance. private var appearanceObserver: NSKeyValueObservation? - private var userDefaultsObserver: NSObjectProtocol? - private var agentStatusBadgeCancellable: AnyCancellable? - /// Signals private var signals: [DispatchSourceSignal] = [] /// The custom app icon image that is currently in use. @Published private(set) var appIcon: NSImage? + @MainActor private lazy var menuShortcutManager = Ghostty.MenuShortcutManager() + override init() { #if DEBUG ghostty = Ghostty.App(configPath: ProcessInfo.processInfo.environment["GHOSTTY_CONFIG_PATH"]) @@ -178,7 +170,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, @@ -192,15 +192,12 @@ class AppDelegate: NSObject, // a desirable behavior to NOT have happen for a terminal, so this is a win. // Manual autofill via the `Edit => AutoFill` menu item still work as expected. "NSAutoFillHeuristicControllerEnabled": false, - - // Ghostree-specific Worktrunk behavior. - WorktrunkPreferences.worktreeTabsKey: true, ]) } 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, ]) @@ -209,7 +206,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) } @@ -238,13 +235,6 @@ class AppDelegate: NSObject, name: NSWindow.didBecomeKeyNotification, object: nil ) - userDefaultsObserver = NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, - object: nil, - queue: .main - ) { _ in - TerminalController.all.forEach { $0.applyWorktreeTabPreferences() } - } NotificationCenter.default.addObserver( self, selector: #selector(quickTerminalDidChangeVisibility), @@ -320,13 +310,6 @@ class AppDelegate: NSObject, // Setup signal handlers setupSignals() - // Observe agent status changes to update the dock badge count - agentStatusBadgeCancellable = worktrunkStore.$agentStatusByWorktreePath - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.syncDockBadge() - } - switch Ghostty.launchSource { case .app: // Don't have to do anything. @@ -360,8 +343,6 @@ class AppDelegate: NSObject, // If we're back manually then clear the hidden state because macOS handles it. self.hiddenState = nil - // Recalculate dock badge from the full current state. - self.syncDockBadge() // First launch stuff if !applicationHasBecomeActive { applicationHasBecomeActive = true @@ -426,9 +407,9 @@ class AppDelegate: NSObject, // We have some visible window. Show an app-wide modal to confirm quitting. let alert = NSAlert() - alert.messageText = "Quit Ghostree?" + alert.messageText = "Quit Ghostty?" alert.informativeText = "All terminal sessions will be terminated." - alert.addButton(withTitle: "Close Ghostree") + alert.addButton(withTitle: "Close Ghostty") alert.addButton(withTitle: "Cancel") alert.alertStyle = .warning switch alert.runModal() { @@ -445,9 +426,6 @@ class AppDelegate: NSObject, // so remove them all now. In the future we may want to be // more selective and only remove surface-targeted notifications. UNUserNotificationCenter.current().removeAllDeliveredNotifications() - - // Record quit timestamp so agent status seeding can skip stale events on next launch. - worktrunkStore.recordAppQuit() } /// This is called when the application is already open and someone double-clicks the icon @@ -522,7 +500,7 @@ class AppDelegate: NSObject, // may want to show this as a sheet on the focused window (especially if we're // opening a tab). I'm not sure. let alert = NSAlert() - alert.messageText = "Allow Ghostree to execute \"\(filename)\"?" + alert.messageText = "Allow Ghostty to execute \"\(filename)\"?" alert.addButton(withTitle: "Allow") alert.addButton(withTitle: "Cancel") alert.alertStyle = .warning @@ -548,11 +526,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 @@ -581,134 +554,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: "doc.text") - 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 @@ -828,6 +673,22 @@ class AppDelegate: NSObject, syncDockBadge() } + private func requestBadgeAuthorizationAndSet(_ center: UNUserNotificationCenter) { + center.requestAuthorization(options: [.badge]) { granted, error in + if let error = error { + Self.logger.warning("Error requesting badge authorization: \(error)") + return + } + + // Permission granted, set the badge + if granted { + DispatchQueue.main.async { + self.setDockBadge() + } + } + } + } + private func syncDockBadge() { let center = UNUserNotificationCenter.current() center.getNotificationSettings { settings in @@ -838,23 +699,16 @@ class AppDelegate: NSObject, DispatchQueue.main.async { self.setDockBadge() } + } else if settings.badgeSetting == .notSupported { + // If badge setting is not supported, we may be in a sandbox that doesn't allow it. + // We can still attempt to set the badge and hope for the best, but we should also + // request authorization just in case it is a permissions issue. + self.requestBadgeAuthorizationAndSet(center) } case .notDetermined: // Not determined yet, request authorization for badge - center.requestAuthorization(options: [.badge]) { granted, error in - if let error = error { - Self.logger.warning("Error requesting badge authorization: \(error)") - return - } - - if granted { - // Permission granted, set the badge - DispatchQueue.main.async { - self.setDockBadge() - } - } - } + self.requestBadgeAuthorizationAndSet(center) case .denied, .provisional, .ephemeral: // In these known non-authorized states, do not attempt to set the badge. @@ -879,35 +733,22 @@ class AppDelegate: NSObject, // We only want to listen to new tabs if the focused parent is // a regular terminal controller. - guard let controller = window.windowController as? TerminalController else { return } + guard window.windowController is TerminalController else { return } let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] let config = configAny as? Ghostty.SurfaceConfiguration - // Always create a normal tab, even when worktree tabs are enabled. - // Worktree split behavior is handled separately. - _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: config) } - private func setDockBadge(_ label: String? = "•") { - NSApp.dockTile.badgeLabel = label - NSApp.dockTile.display() - } - private func setDockBadge() { - let agentCount = worktrunkStore.attentionCount - if agentCount > 0 { - setDockBadge(agentCount > 99 ? "99+" : String(agentCount)) - return - } - let bellCount = NSApp.windows .compactMap { $0.windowController as? BaseTerminalController } .reduce(0) { $0 + ($1.bell ? 1 : 0) } let wantsBadge = ghostty.config.bellFeatures.contains(.attention) && bellCount > 0 let label = wantsBadge ? (bellCount > 99 ? "99+" : String(bellCount)) : nil - setDockBadge(label) + NSApp.dockTile.badgeLabel = label + NSApp.dockTile.display() } private func ghosttyConfigDidChange(config: Ghostty.Config) { @@ -918,10 +759,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 @@ -947,7 +788,9 @@ class AppDelegate: NSObject, } // Config could change keybindings, so update everything that depends on that - syncMenuShortcuts(config) + DispatchQueue.main.async { + self.syncMenuShortcuts(config) + } TerminalController.all.forEach { $0.relabelTabs() } // Update our badge since config can change what we show. @@ -1006,9 +849,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) } @@ -1083,17 +926,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) { @@ -1109,33 +941,11 @@ 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 - @IBAction func showSettings(_ sender: Any?) { - if settingsWindowController == nil { - let window = SettingsWindow( - contentRect: NSRect(x: 0, y: 0, width: 720, height: 500), - styleMask: [.titled, .closable, .miniaturizable], - backing: .buffered, - defer: false - ) - window.title = "Settings" - window.center() - window.isReleasedWhenClosed = false - window.collectionBehavior = [.moveToActiveSpace] - - window.toolbarStyle = .unified - window.contentViewController = NSHostingController(rootView: SettingsView()) - settingsWindowController = NSWindowController(window: window) - } - settingsWindowController?.showWindow(nil) - settingsWindowController?.window?.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - } - @IBAction func openConfig(_ sender: Any?) { Ghostty.App.openConfig() } @@ -1160,28 +970,6 @@ class AppDelegate: NSObject, ) } - @objc func addWorktrunkRepository(_ sender: Any?) { - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.prompt = "Add" - panel.title = "Add Repository" - - let parent = NSApp.keyWindow ?? NSApp.mainWindow - if let parent { - panel.beginSheetModal(for: parent) { [weak self] response in - guard let self else { return } - guard response == .OK, let url = panel.url else { return } - Task { await self.worktrunkStore.addRepositoryValidated(path: url.path) } - } - } else { - let response = panel.runModal() - guard response == .OK, let url = panel.url else { return } - Task { await self.worktrunkStore.addRepositoryValidated(path: url.path) } - } - } - @IBAction func closeAllWindows(_ sender: Any?) { TerminalController.closeAllWindows() AboutController.shared.hide() @@ -1300,6 +1088,147 @@ 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. + @MainActor private func syncMenuShortcuts(_ config: Ghostty.Config) { + guard ghostty.readiness == .ready else { return } + + menuShortcutManager.reset() + + 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: "navigate_search:next", menuItem: self.menuFindNext) + syncMenuShortcut(config, action: "navigate_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() + } + + @MainActor private func syncMenuShortcut(_ config: Ghostty.Config, action: String, menuItem: NSMenuItem?) { + menuShortcutManager.syncMenuShortcut(config, action: action, menuItem: menuItem) + } + + @MainActor func performGhosttyBindingMenuKeyEquivalent(with event: NSEvent) -> Bool { + menuShortcutManager.performGhosttyBindingMenuKeyEquivalent(with: event) + } +} + // MARK: Floating Windows extension AppDelegate { @@ -1320,7 +1249,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/About/AboutView.swift b/macos/Sources/Features/About/AboutView.swift index d2c72f7ef78..32f5900cd07 100644 --- a/macos/Sources/Features/About/AboutView.swift +++ b/macos/Sources/Features/About/AboutView.swift @@ -10,6 +10,39 @@ struct AboutView: View { private var build: String? { Bundle.main.infoDictionary?["CFBundleVersion"] as? String } private var commit: String? { Bundle.main.infoDictionary?["GhosttyCommit"] as? String } private var version: String? { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String } + + private enum VersionConfig { + case stable(version: String) + case tip(commit: String?) + case other(String) + case none + + init(version: String?) { + guard let version else { self = .none; return } + if version.range(of: #"^\d+\.\d+\.\d+$"#, options: .regularExpression) != nil { + self = .stable(version: version) + return + } + if version.range(of: #"^[0-9a-f]{7,40}$"#, options: .regularExpression) != nil { + self = .tip(commit: version) + return + } + self = .other(version) + } + + var url: URL? { + switch self { + case .stable(let version): + let slug = version.replacingOccurrences(of: ".", with: "-") + return URL(string: "https://ghostty.org/docs/install/release-notes/\(slug)") + default: + return nil + } + } + } + + private var versionConfig: VersionConfig { VersionConfig(version: version) } + private var copyright: String? { Bundle.main.infoDictionary?["NSHumanReadableCopyright"] as? String } #if os(macOS) @@ -60,8 +93,15 @@ struct AboutView: View { .textSelection(.enabled) VStack(spacing: 2) { - if let version { - PropertyRow(label: "Version", text: version) + switch versionConfig { + case .stable(let version): + PropertyRow(label: "Version", text: version, url: versionConfig.url) + case .tip: + PropertyRow(label: "Version", text: "Tip Release") + case .other(let v): + PropertyRow(label: "Version", text: v) + case .none: + EmptyView() } if let build { PropertyRow(label: "Build", text: build) diff --git a/macos/Sources/Features/AppleScript/ScriptTab.swift b/macos/Sources/Features/AppleScript/ScriptTab.swift index e5715a4b004..97a5ed1e573 100644 --- a/macos/Sources/Features/AppleScript/ScriptTab.swift +++ b/macos/Sources/Features/AppleScript/ScriptTab.swift @@ -126,7 +126,6 @@ final class ScriptTab: NSObject { } tabContainerWindow.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) return nil } diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 6875698f67b..f21d9e68ab1 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -72,7 +72,6 @@ struct CommandPaletteView: View { var options: [CommandOption] @State private var selectedIndex: UInt? @State private var hoveredOptionID: UUID? - @FocusState private var isTextFieldFocused: Bool // The options that we should show, taking into account any filtering from // the query. Options with matching leadingColor are ranked higher. @@ -118,7 +117,7 @@ struct CommandPaletteView: View { } VStack(alignment: .leading, spacing: 0) { - CommandPaletteQuery(query: $query, isTextFieldFocused: _isTextFieldFocused) { event in + CommandPaletteQuery(query: $query) { event in switch event { case .exit: isPresented = false @@ -199,27 +198,13 @@ struct CommandPaletteView: View { .padding() .environment(\.colorScheme, scheme) .onChange(of: isPresented) { newValue in - // Reset focus when quickly showing and hiding. - // macOS will destroy this view after a while, - // so task/onAppear will not be called again. - // If you toggle it rather quickly, we reset - // it here when dismissing. - isTextFieldFocused = newValue - if !isPresented { + if !newValue { // This is optional, since most of the time // there will be a delay before the next use. // To keep behavior the same as before, we reset it. query = "" } } - .task { - // Grab focus on the first appearance. - // This happens right after onAppear, - // so we don’t need to dispatch it again. - // Fixes: https://github.com/ghostty-org/ghostty/issues/8497 - // Also fixes initial focus while animating. - isTextFieldFocused = isPresented - } } /// Returns a score (0.0 to 1.0) indicating how well a color matches a search query color name. @@ -255,10 +240,9 @@ private struct CommandPaletteQuery: View { var onEvent: ((KeyboardEvent) -> Void)? @FocusState private var isTextFieldFocused: Bool - init(query: Binding, isTextFieldFocused: FocusState, onEvent: ((KeyboardEvent) -> Void)? = nil) { + init(query: Binding, onEvent: ((KeyboardEvent) -> Void)? = nil) { _query = query self.onEvent = onEvent - _isTextFieldFocused = isTextFieldFocused } enum KeyboardEvent { @@ -301,6 +285,17 @@ private struct CommandPaletteQuery: View { .onExitCommand { onEvent?(.exit) } .onMoveCommand { onEvent?(.move($0)) } .onSubmit { onEvent?(.submit) } + .onAppear { + // Grab focus on the first appearance. + // Debug and Release build using Xcode 26.4, + // has same issue again + // Fixes: https://github.com/ghostty-org/ghostty/issues/8497 + // SearchOverlay works magically as expected, I don't know + // why it's different here, but dispatching to next loop fixes it + DispatchQueue.main.async { + isTextFieldFocused = true + } + } } } } diff --git a/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift index c394e5fc368..de0661cb2c3 100644 --- a/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift +++ b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift @@ -7,17 +7,13 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { private let pluginBundle = Bundle(for: DockTilePlugin.self) - // Read icon state from the host app's defaults domain so fork bundle IDs - // (and debug/release IDs) stay in sync automatically. - private var ghosttyUserDefaults: UserDefaults? { - guard - let appBundleURL = ghosttyAppURL, - let appBundle = Bundle(path: appBundleURL.path), - let bundleIdentifier = appBundle.bundleIdentifier - else { return nil } - - return UserDefaults(suiteName: bundleIdentifier) - } + // Separate defaults based on debug vs release builds so we can test icons + // without messing up releases. + #if DEBUG + private let ghosttyUserDefaults = UserDefaults(suiteName: "com.mitchellh.ghostty.debug") + #else + private let ghosttyUserDefaults = UserDefaults(suiteName: "com.mitchellh.ghostty") + #endif private var iconChangeObserver: Any? @@ -86,7 +82,7 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { /// Reset the application icon and dock tile icon to the default. private func resetIcon(dockTile: NSDockTile) { let appBundlePath = self.ghosttyAppURL?.path - let appIcon: NSImage + let appIcon: NSImage? if #available(macOS 26.0, *) { // Reset to the default (glassy) icon. if let appBundlePath { @@ -97,21 +93,8 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { // Use the `Blueprint` icon to distinguish Debug from Release builds. appIcon = pluginBundle.image(forResource: "BlueprintImage")! #else - // Get the composed icon from the app bundle. - if let appBundlePath, - let iconRep = NSWorkspace.shared.icon(forFile: appBundlePath) - .bestRepresentation( - for: CGRect(origin: .zero, size: dockTile.size), - context: nil, - hints: nil - ) { - appIcon = NSImage(size: dockTile.size) - appIcon.addRepresentation(iconRep) - } else { - // If something unexpected happens on macOS 26, - // fall back to a bundled icon. - appIcon = pluginBundle.image(forResource: "AppIconImage")! - } + // Reset to Ghostty.icon + appIcon = nil #endif } else { // Use the bundled icon to keep the corner radius consistent with pre-Tahoe apps. @@ -130,9 +113,14 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { } private extension NSDockTile { - func setIcon(_ newIcon: NSImage) { + func setIcon(_ newIcon: NSImage?) { // Update the Dock tile on the main thread. DispatchQueue.main.async { + guard let newIcon else { + self.contentView = nil + self.display() + return + } let iconView = NSImageView(frame: CGRect(origin: .zero, size: self.size)) iconView.wantsLayer = true iconView.image = newIcon diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 99a77489c0b..214ff08d3d3 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -159,7 +159,7 @@ class QuickTerminalController: BaseTerminalController { // applies if we can be seen. guard visible else { return } - terminalGlassContainer?.updateGlassTintOverlay(isKeyWindow: true) + terminalViewContainer?.updateGlassTintOverlay(isKeyWindow: true) // Re-hide the dock if we were hiding it before. hiddenDock?.hide() @@ -174,7 +174,7 @@ class QuickTerminalController: BaseTerminalController { // ensures we don't run logic twice. guard visible else { return } - terminalGlassContainer?.updateGlassTintOverlay(isKeyWindow: false) + terminalViewContainer?.updateGlassTintOverlay(isKeyWindow: false) // We don't animate out if there is a modal sheet being shown currently. // This lets us show alerts without causing the window to disappear. @@ -626,7 +626,7 @@ class QuickTerminalController: BaseTerminalController { window.backgroundColor = .windowBackgroundColor } - terminalGlassContainer?.ghosttyConfigDidChange(ghostty.config, preferredBackgroundColor: nil) + terminalViewContainer?.ghosttyConfigDidChange(ghostty.config, preferredBackgroundColor: nil) } private func showNoNewTabAlert() { @@ -711,7 +711,7 @@ class QuickTerminalController: BaseTerminalController { syncAppearance() - terminalGlassContainer?.ghosttyConfigDidChange(config, preferredBackgroundColor: nil) + terminalViewContainer?.ghosttyConfigDidChange(config, preferredBackgroundColor: nil) } @objc private func onNewTab(notification: SwiftUI.Notification) { diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 77b164ec922..6e54d1601d1 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -307,9 +307,8 @@ class BaseTerminalController: NSWindowController, // Our focus state requires that this window is key and our currently // focused surface is the surface in this view. let focused: Bool = (window?.isKeyWindow ?? false) && - !commandPaletteIsShowing && - focusedSurface != nil && - surfaceView == focusedSurface! + surfaceView == focusedSurface && + surfaceView.isFirstResponder surfaceView.focusDidChange(focused) } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index f40e42a98a4..7e9b91b19bd 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -4,174 +4,8 @@ import SwiftUI import Combine import GhosttyKit -final class WorktrunkSidebarState: ObservableObject { - @Published var columnVisibility: NavigationSplitViewVisibility - @Published var expandedRepoIDs: Set = [] - @Published var expandedWorktreePaths: Set = [] - @Published var selection: SidebarSelection? - @Published var isApplyingRemoteUpdate: Bool = false - - init( - columnVisibility: NavigationSplitViewVisibility = .all, - expandedRepoIDs: Set = [], - expandedWorktreePaths: Set = [], - selection: SidebarSelection? = nil - ) { - self.columnVisibility = columnVisibility - self.expandedRepoIDs = expandedRepoIDs - self.expandedWorktreePaths = expandedWorktreePaths - self.selection = selection - } - - func applyExpandedRepoIDs( - _ next: Set, - listMode: WorktrunkSidebarListMode, - alwaysVisibleWorktreePaths: Set = [] - ) { - guard next != expandedRepoIDs else { return } - expandedRepoIDs = next - pruneSelectionForVisibility( - listMode: listMode, - expandedRepoIDs: next, - expandedWorktreePaths: expandedWorktreePaths, - alwaysVisibleWorktreePaths: alwaysVisibleWorktreePaths - ) - } - - func applyExpandedWorktreePaths( - _ next: Set, - listMode: WorktrunkSidebarListMode, - alwaysVisibleWorktreePaths: Set = [] - ) { - guard next != expandedWorktreePaths else { return } - expandedWorktreePaths = next - pruneSelectionForVisibility( - listMode: listMode, - expandedRepoIDs: expandedRepoIDs, - expandedWorktreePaths: next, - alwaysVisibleWorktreePaths: alwaysVisibleWorktreePaths - ) - } - - func didCollapseRepo(id: UUID) { - guard let selection else { return } - switch selection { - case .repo(let repoID): - if repoID == id { return } - case .worktree(let repoID, _): - if repoID == id { self.selection = .repo(id: id) } - case .session(_, let repoID, _): - if repoID == id { self.selection = .repo(id: id) } - } - } - - func didCollapseWorktree(repoID: UUID, path: String) { - guard let selection else { return } - switch selection { - case .session(_, let selectedRepoID, let worktreePath): - if selectedRepoID == repoID, worktreePath == path { - self.selection = .worktree(repoID: repoID, path: path) - } - default: - return - } - } - - func reconcile(with store: any WorktrunkSidebarReconcilingStore, listMode: WorktrunkSidebarListMode) { - let validRepoIDs = store.sidebarRepoIDs - let validWorktreePaths = store.sidebarWorktreePaths - - let nextExpandedRepoIDs = expandedRepoIDs.intersection(validRepoIDs) - if nextExpandedRepoIDs != expandedRepoIDs { - expandedRepoIDs = nextExpandedRepoIDs - } - - let nextExpandedWorktreePaths = expandedWorktreePaths.intersection(validWorktreePaths) - if nextExpandedWorktreePaths != expandedWorktreePaths { - expandedWorktreePaths = nextExpandedWorktreePaths - } - - guard let selection else { return } - - let nextSelection: SidebarSelection? - switch selection { - case .repo(let id): - nextSelection = validRepoIDs.contains(id) ? selection : nil - case .worktree(let repoID, let path): - if !validRepoIDs.contains(repoID) { - nextSelection = nil - } else if validWorktreePaths.contains(path) { - nextSelection = selection - } else { - nextSelection = .repo(id: repoID) - } - case .session(let id, let repoID, let worktreePath): - if !validRepoIDs.contains(repoID) { - nextSelection = nil - } else if !validWorktreePaths.contains(worktreePath) { - nextSelection = .repo(id: repoID) - } else if store.sessions(for: worktreePath).contains(where: { $0.id == id }) { - nextSelection = selection - } else { - nextSelection = .worktree(repoID: repoID, path: worktreePath) - } - } - - if nextSelection != selection { - self.selection = nextSelection - } - } - - private func pruneSelectionForVisibility( - listMode: WorktrunkSidebarListMode, - expandedRepoIDs: Set, - expandedWorktreePaths: Set, - alwaysVisibleWorktreePaths: Set - ) { - guard let selection else { return } - switch selection { - case .repo: - return - case .worktree(let repoID, let path): - if listMode == .nestedByRepo, - !expandedRepoIDs.contains(repoID), - !alwaysVisibleWorktreePaths.contains(path) { - self.selection = .repo(id: repoID) - } - case .session(_, let repoID, let worktreePath): - if listMode == .nestedByRepo, - !expandedRepoIDs.contains(repoID), - !alwaysVisibleWorktreePaths.contains(worktreePath) { - self.selection = .repo(id: repoID) - return - } - if !expandedWorktreePaths.contains(worktreePath) { - self.selection = .worktree(repoID: repoID, path: worktreePath) - } - } - } -} - -protocol WorktrunkSidebarReconcilingStore { - var repositories: [WorktrunkStore.Repository] { get } - var sidebarRepoIDs: Set { get } - var sidebarWorktreePaths: Set { get } - func worktrees(for repositoryID: UUID) -> [WorktrunkStore.Worktree] - func sessions(for worktreePath: String) -> [AISession] -} - -enum SidebarSelection: Hashable { - case repo(id: UUID) - case worktree(repoID: UUID, path: String) - case session(id: String, repoID: UUID, worktreePath: String) -} /// A classic, tabbed terminal experience. class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Controller { - private static let worktreeTabControllers = NSMapTable( - keyOptions: .copyIn, - valueOptions: .weakMemory - ) - override var windowNibName: NSNib.Name? { let defaultValue = "Terminal" @@ -184,14 +18,20 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr return defaultValue } - // Ghostree: The "tabs" and "hidden" titlebar styles don't work with the - // Worktrunk sidebar. "tabs" installs its own toolbar without a sidebar tracking - // separator, causing the sidebar to be truncated. "hidden" hides the titlebar - // entirely, removing the sidebar toggle. Override both to "transparent". let nib = switch config.macosTitlebarStyle { - case "native": "Terminal" - case "hidden", "tabs", "transparent": "TerminalTransparentTitlebar" - default: defaultValue + case .native: "Terminal" + case .hidden: "TerminalHiddenTitlebar" + case .transparent: "TerminalTransparentTitlebar" + case .tabs: +#if compiler(>=6.2) + if #available(macOS 26.0, *) { + "TerminalTabsTitlebarTahoe" + } else { + "TerminalTabsTitlebarVentura" + } +#else + "TerminalTabsTitlebarVentura" +#endif } return nib @@ -206,34 +46,21 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr /// changes in the list. private var tabWindowsHash: Int = 0 + /// The initial window presentation is deferred by one runloop turn in a few places so + /// AppKit can settle tab/window state first. Close actions must cancel it to avoid + /// re-showing a tab that was already closed. + private var pendingInitialPresentation: DispatchWorkItem? + /// This is set to false by init if the window managed by this controller should not be restorable. /// For example, terminals executing custom scripts are not restorable. private var restorable: Bool = true /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig - private var lastTitlebarFont: NSFont? - - private let worktrunkSidebarState: WorktrunkSidebarState - private let openTabsModel = WorktrunkOpenTabsModel() - private var worktrunkSidebarSyncCancellables: Set = [] - private var worktrunkSidebarSyncApplyingRemoteUpdate: Bool = false - private let gitDiffSidebarState = GitDiffSidebarState() - private var lastTabSwitchRefreshAt: Date? - private let tabSwitchRefreshThrottle: TimeInterval = 0.15 - private var pendingTabSwitchRefresh: DispatchWorkItem? - private var lastTabSwitchSurfaceID: UUID? - - private(set) var worktreeTabRootPath: String? { - didSet { syncWorktreeTabTitle() } - } /// The notification cancellable for focused surface property changes. private var surfaceAppearanceCancellables: Set = [] - /// 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? = nil, @@ -246,24 +73,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // restoration. self.restorable = (base?.command ?? "") == "" - if let parent, - let parentController = parent.windowController as? TerminalController { - self.worktrunkSidebarState = WorktrunkSidebarState( - columnVisibility: parentController.worktrunkSidebarState.columnVisibility, - expandedRepoIDs: parentController.worktrunkSidebarState.expandedRepoIDs, - expandedWorktreePaths: parentController.worktrunkSidebarState.expandedWorktreePaths, - selection: parentController.worktrunkSidebarState.selection - ) - } else { - self.worktrunkSidebarState = WorktrunkSidebarState(columnVisibility: .all) - } - // Setup our initial derived config based on the current app config self.derivedConfig = DerivedConfig(ghostty.config) - var baseWithHooks = base ?? Ghostty.SurfaceConfiguration() - TerminalAgentHooks.apply(to: &baseWithHooks) - super.init(ghostty, baseConfig: baseWithHooks, surfaceTree: tree) + super.init(ghostty, baseConfig: base, surfaceTree: tree) + // Setup our notifications for behaviors let center = NotificationCenter.default center.addObserver( @@ -326,164 +140,30 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } deinit { - if let root = worktreeTabRootPath { - Self.worktreeTabControllers.removeObject(forKey: root as NSString) - } - // Remove all of our notificationcenter subscriptions let center = NotificationCenter.default center.removeObserver(self) } - private static func standardizedPath(_ path: String) -> String { - URL(fileURLWithPath: path).standardizedFileURL.path - } - - private func setWorktreeTabRootPath(_ path: String?) { - let standardized = path.map(Self.standardizedPath) - - if let old = worktreeTabRootPath { - Self.worktreeTabControllers.removeObject(forKey: old as NSString) - } - - worktreeTabRootPath = standardized - - if let standardized { - Self.worktreeTabControllers.setObject(self, forKey: standardized as NSString) - } - - openTabsModel.refresh(for: window) - } - - private func syncWorktreeTabTitle() { - guard WorktrunkPreferences.worktreeTabsEnabled, let root = worktreeTabRootPath else { - managedTitleOverride = nil - return - } - - managedTitleOverride = (root as NSString).abbreviatingWithTildeInPath - } - - func applyWorktreeTabPreferences() { - syncWorktreeTabTitle() - } - - func restoreWorktreeTabRootPath(_ path: String?) { - let sanitized = RestorablePath.normalizedExistingDirectoryPath(path) - setWorktreeTabRootPath(sanitized) - } - - private static func existingWorktreeTabController(forWorktreePath path: String) -> TerminalController? { - let root = standardizedPath(path) - if let existing = worktreeTabControllers.object(forKey: root as NSString) { - return existing - } - - if let controller = TerminalController.all.first(where: { $0.worktreeTabRootPath == root }) { - worktreeTabControllers.setObject(controller, forKey: root as NSString) - return controller - } - - for controller in TerminalController.all { - for surfaceView in controller.surfaceTree { - guard let pwd = surfaceView.pwd else { continue } - let pwdPath = standardizedPath(pwd) - let rootPrefix = root.hasSuffix("/") ? root : (root + "/") - if pwdPath == root || pwdPath.hasPrefix(rootPrefix) { - controller.setWorktreeTabRootPath(root) - worktreeTabControllers.setObject(controller, forKey: root as NSString) - return controller - } - } - } - - return nil - } - - private static func ensureWorktreeTabController( - ghostty: Ghostty.App, - parentWindow: NSWindow?, - worktreePath: String, - initialBaseConfig: Ghostty.SurfaceConfiguration - ) -> (controller: TerminalController, isNew: Bool) { - if let existing = existingWorktreeTabController(forWorktreePath: worktreePath) { - return (existing, false) - } - - let parent = parentWindow ?? preferredParent?.window - let controller: TerminalController = { - if let created = TerminalController.newTab(ghostty, from: parent, withBaseConfig: initialBaseConfig) { - return created - } - return TerminalController.newWindow(ghostty, withBaseConfig: initialBaseConfig, withParent: parent) - }() - - controller.setWorktreeTabRootPath(worktreePath) - controller.syncWorktreeTabTitle() - return (controller, true) - } - - private func worktreeTabNextSplitPlacement() -> (anchor: Ghostty.SurfaceView, direction: SplitTree.NewDirection)? { - guard let root = surfaceTree.root else { return nil } - let allLeaves = root.leaves() - guard let first = allLeaves.first else { return nil } - - // Start with two columns. - if allLeaves.count == 1 { - return (first, .right) - } - - // After two columns exist, we fill rows in a 2-column grid: - // 3rd -> split left column down - // 4th -> split right column down - // 5th+ -> keep adding rows to the shorter column, bottom-first (ties -> left). - if case .split(let split) = root, split.direction == .horizontal { - let leftLeaves = split.left.leaves() - let rightLeaves = split.right.leaves() - - if leftLeaves.count <= rightLeaves.count { - return (leftLeaves.last ?? first, .down) - } else { - return (rightLeaves.last ?? first, .down) - } - } - - // If the tree doesn't match the expected layout (e.g. user-made splits), - // fall back to adding a row at the end. - return (allLeaves.last ?? first, .down) - } - - func openWorktreeTabNewSession(baseConfig: Ghostty.SurfaceConfiguration) { - guard WorktrunkPreferences.worktreeTabsEnabled, worktreeTabRootPath != nil else { return } - guard let (anchor, direction) = worktreeTabNextSplitPlacement() else { return } - _ = newSplit(at: anchor, direction: direction, baseConfig: baseConfig) + private func cancelPendingInitialPresentation() { + pendingInitialPresentation?.cancel() + pendingInitialPresentation = nil } - private func openWorktreeTabSession(worktreePath: String, baseConfig: Ghostty.SurfaceConfiguration) { - var initialConfig = baseConfig - initialConfig.workingDirectory = initialConfig.workingDirectory ?? worktreePath - TerminalAgentHooks.apply(to: &initialConfig) + private func scheduleInitialPresentation(_ block: @escaping () -> Void) { + cancelPendingInitialPresentation() - let (controller, isNew) = Self.ensureWorktreeTabController( - ghostty: ghostty, - parentWindow: window, - worktreePath: worktreePath, - initialBaseConfig: initialConfig - ) - - controller.window?.makeKeyAndOrderFront(nil) - if !NSApp.isActive { - NSApp.activate(ignoringOtherApps: true) - } - - if controller.worktreeTabRootPath == nil { - controller.setWorktreeTabRootPath(worktreePath) + var scheduledWorkItem: DispatchWorkItem? + scheduledWorkItem = DispatchWorkItem { [weak self] in + guard let self else { return } + defer { self.pendingInitialPresentation = nil } + guard pendingInitialPresentation?.isCancelled == false else { return } + block() } - // If this is the first terminal in the tab, the tab creation already created the surface. - // Otherwise, open a new split in the worktree tab. - guard !isNew else { return } - controller.openWorktreeTabNewSession(baseConfig: initialConfig) + let workItem = scheduledWorkItem! + pendingInitialPresentation = workItem + DispatchQueue.main.async(execute: workItem) } // MARK: Base Controller Overrides @@ -546,7 +226,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) } } @@ -569,10 +251,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil, withParent explicitParent: NSWindow? = nil ) -> TerminalController { + let c = TerminalController.init(ghostty, withBaseConfig: baseConfig) + // Get our parent. Our parent is the one explicitly given to us, // otherwise the focused terminal, otherwise an arbitrary one. let parent: NSWindow? = explicitParent ?? preferredParent?.window - let c = TerminalController.init(ghostty, withBaseConfig: baseConfig, parent: parent) if let parent, parent.styleMask.contains(.fullScreen) { // If our previous window was fullscreen then we want our new window to @@ -600,7 +283,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // We're dispatching this async because otherwise the lastCascadePoint doesn't // 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.scheduleInitialPresentation { + c.showWindow(self) + // Only cascade if we aren't fullscreen. if let window = c.window { if !window.styleMask.contains(.fullScreen) { @@ -609,8 +294,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) @@ -662,7 +345,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Calculate the target frame based on the tree's view bounds let treeSize: CGSize? = tree.root?.viewBounds() - DispatchQueue.main.async { + c.scheduleInitialPresentation { + 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 { @@ -680,8 +364,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } } - - c.showWindow(self) } // Setup our undo @@ -737,7 +419,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } // Create a new window and add it to the parent - let controller = TerminalController.init(ghostty, withBaseConfig: baseConfig, parent: parent) + let controller = TerminalController.init(ghostty, withBaseConfig: baseConfig) guard let window = controller.window else { return controller } // If the parent is miniaturized, then macOS exhibits really strange behaviors @@ -778,7 +460,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // We're dispatching this async because otherwise the lastCascadePoint doesn't // 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 { + controller.scheduleInitialPresentation { // Only cascade if we aren't fullscreen and are alone in the tab group. if !window.styleMask.contains(.fullScreen) && window.tabGroup?.windows.count ?? 1 == 1 { @@ -887,76 +569,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } } - - openTabsModel.refresh(for: window) - } - - private func focusNativeTab(windowNumber: Int) { - guard let window else { return } - guard let tabGroup = window.tabGroup else { return } - guard let target = tabGroup.windows.first(where: { $0.windowNumber == windowNumber }) else { return } - target.makeKeyAndOrderFront(nil) - if !NSApp.isActive { - NSApp.activate(ignoringOtherApps: true) - } - } - - private func closeNativeTab(windowNumber: Int) { - guard let window else { return } - let candidateWindows = window.tabGroup?.windows ?? [window] - guard let targetWindow = candidateWindows.first(where: { $0.windowNumber == windowNumber }) else { return } - guard let targetController = targetWindow.windowController as? TerminalController else { return } - targetController.closeTab(nil) - } - - private func moveNativeTabBefore(movingWindowNumber: Int, targetWindowNumber: Int) { - guard movingWindowNumber != targetWindowNumber else { return } - guard let window else { return } - guard let tabGroup = window.tabGroup, tabGroup.windows.count > 1 else { return } - guard let movingWindow = tabGroup.windows.first(where: { $0.windowNumber == movingWindowNumber }) else { return } - guard let targetWindow = tabGroup.windows.first(where: { $0.windowNumber == targetWindowNumber }) else { return } - - if #available(macOS 26, *) { - if window is TitlebarTabsTahoeTerminalWindow { - tabGroup.removeWindow(movingWindow) - targetWindow.addTabbedWindowSafely(movingWindow, ordered: .below) - relabelTabs() - return - } - } - - NSAnimationContext.beginGrouping() - NSAnimationContext.current.duration = 0 - tabGroup.removeWindow(movingWindow) - targetWindow.addTabbedWindowSafely(movingWindow, ordered: .below) - NSAnimationContext.endGrouping() - - relabelTabs() - } - - private func moveNativeTabAfter(movingWindowNumber: Int, targetWindowNumber: Int) { - guard movingWindowNumber != targetWindowNumber else { return } - guard let window else { return } - guard let tabGroup = window.tabGroup, tabGroup.windows.count > 1 else { return } - guard let movingWindow = tabGroup.windows.first(where: { $0.windowNumber == movingWindowNumber }) else { return } - guard let targetWindow = tabGroup.windows.first(where: { $0.windowNumber == targetWindowNumber }) else { return } - - if #available(macOS 26, *) { - if window is TitlebarTabsTahoeTerminalWindow { - tabGroup.removeWindow(movingWindow) - targetWindow.addTabbedWindowSafely(movingWindow, ordered: .above) - relabelTabs() - return - } - } - - NSAnimationContext.beginGrouping() - NSAnimationContext.current.duration = 0 - tabGroup.removeWindow(movingWindow) - targetWindow.addTabbedWindowSafely(movingWindow, ordered: .above) - NSAnimationContext.endGrouping() - - relabelTabs() } private func fixTabBar() { @@ -1002,17 +614,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Set the font for the window and tab titles. if let titleFontName = surfaceConfig.windowTitleFontFamily { - let font = NSFont(name: titleFontName, size: NSFont.systemFontSize) - window.titlebarFont = font - lastTitlebarFont = font + window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize) } else { window.titlebarFont = nil - lastTitlebarFont = nil } // Call this last in case it uses any of the properties above. window.syncAppearance(surfaceConfig) - terminalGlassContainer?.ghosttyConfigDidChange(ghostty.config, preferredBackgroundColor: window.preferredBackgroundColor) + terminalViewContainer?.ghosttyConfigDidChange(ghostty.config, preferredBackgroundColor: window.preferredBackgroundColor) } /// Adjusts the given frame for the configured window position. @@ -1067,6 +676,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr return } + cancelPendingInitialPresentation() + // Undo if let undoManager, let undoState { // Register undo action to restore the tab @@ -1185,6 +796,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr func closeWindowImmediately() { guard let window = window else { return } + cancelPendingInitialPresentation() + registerUndoForCloseWindow() if let tabGroup = window.tabGroup, tabGroup.windows.count > 1 { @@ -1193,6 +806,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // This prevents unnecessary undos registered since AppKit may // process them on later ticks so we can't just disable undo registration. if let controller = window.windowController as? TerminalController { + controller.cancelPendingInitialPresentation() controller.surfaceTree = .init() } @@ -1452,73 +1066,30 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } // Initialize our content view to the SwiftUI root - let worktrunkStore = (NSApp.delegate as? AppDelegate)?.worktrunkStore ?? WorktrunkStore() - window.contentView = TerminalWorkspaceViewContainer( - ghostty: self.ghostty, - viewModel: self, - delegate: self, - worktrunkStore: worktrunkStore, - worktrunkSidebarState: worktrunkSidebarState, - openTabsModel: openTabsModel, - gitDiffSidebarState: gitDiffSidebarState, - openWorktree: { [weak self] path in - self?.openWorktree(atPath: path) - }, - openWorktreeAgent: { [weak self] path, agent in - self?.openWorktreeAgentSession(atPath: path, agent: agent) - }, - resumeSession: { [weak self] session in - self?.resumeAISession(session) - }, - focusNativeTab: { [weak self] windowNumber in - self?.focusNativeTab(windowNumber: windowNumber) - }, - closeNativeTab: { [weak self] windowNumber in - self?.closeNativeTab(windowNumber: windowNumber) - }, - moveNativeTabBefore: { [weak self] moving, target in - self?.moveNativeTabBefore(movingWindowNumber: moving, targetWindowNumber: target) - }, - moveNativeTabAfter: { [weak self] moving, target in - self?.moveNativeTabAfter(movingWindowNumber: moving, targetWindowNumber: target) - }, - onSidebarWidthChange: { [weak self] width in - self?.updateWorktrunkTitlebarWidth(width) - }, - onGitDiffWorktreeSelect: { [weak self] path in - self?.onWorktrunkSelectionChange(path) - } - ) - installWorktrunkTitlebar() - installWorktrunkSidebarSync() + 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() + .milliseconds(40)) { [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. @@ -1543,472 +1114,34 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // apply this based on the root config but change it later based on surface // config (see focused surface change callback). syncAppearance(.init(config)) - - openTabsModel.refresh(for: window) } - private func installWorktrunkSidebarSync() { - guard worktrunkSidebarSyncCancellables.isEmpty else { return } - - worktrunkSidebarState.$columnVisibility - .removeDuplicates() - .sink { [weak self] visibility in - if visibility == .detailOnly { - self?.updateWorktrunkTitlebarWidth(0) - } - self?.syncWorktrunkSidebarVisibilityToTabGroup(visibility) - } - .store(in: &worktrunkSidebarSyncCancellables) - - worktrunkSidebarState.$expandedRepoIDs - .removeDuplicates() - .debounce(for: .milliseconds(75), scheduler: RunLoop.main) - .sink { [weak self] expandedRepoIDs in - self?.syncWorktrunkSidebarExpandedRepoIDsToTabGroup(expandedRepoIDs) - } - .store(in: &worktrunkSidebarSyncCancellables) - - worktrunkSidebarState.$expandedWorktreePaths - .removeDuplicates() - .debounce(for: .milliseconds(75), scheduler: RunLoop.main) - .sink { [weak self] expandedWorktreePaths in - self?.syncWorktrunkSidebarExpandedWorktreePathsToTabGroup(expandedWorktreePaths) - } - .store(in: &worktrunkSidebarSyncCancellables) - - worktrunkSidebarState.$selection - .removeDuplicates() - .sink { [weak self] selection in - self?.syncWorktrunkSidebarSelectionToTabGroup(selection) - } - .store(in: &worktrunkSidebarSyncCancellables) - } - - private func syncWorktrunkSidebarVisibilityToTabGroup(_ visibility: NavigationSplitViewVisibility) { - guard !worktrunkSidebarSyncApplyingRemoteUpdate else { return } - guard let window else { return } - guard let tabGroup = window.tabGroup, tabGroup.windows.count > 1 else { return } - - for sibling in tabGroup.windows where sibling != window { - guard let controller = sibling.windowController as? TerminalController else { continue } - controller.applySyncedWorktrunkSidebarVisibility(visibility) - } - } - - private func syncWorktrunkSidebarExpandedRepoIDsToTabGroup(_ expandedRepoIDs: Set) { - guard !worktrunkSidebarSyncApplyingRemoteUpdate else { return } - guard let window else { return } - guard let tabGroup = window.tabGroup, tabGroup.windows.count > 1 else { return } - - for sibling in tabGroup.windows where sibling != window { - guard let controller = sibling.windowController as? TerminalController else { continue } - controller.applySyncedWorktrunkSidebarExpandedRepoIDs(expandedRepoIDs) - } - } + /// Setup correct window frame before showing the window + override func showWindow(_ sender: Any?) { + guard let terminalWindow = window as? TerminalWindow else { return } - private func syncWorktrunkSidebarExpandedWorktreePathsToTabGroup(_ expandedWorktreePaths: Set) { - guard !worktrunkSidebarSyncApplyingRemoteUpdate else { return } - guard let window else { return } - guard let tabGroup = window.tabGroup, tabGroup.windows.count > 1 else { return } - - for sibling in tabGroup.windows where sibling != window { - guard let controller = sibling.windowController as? TerminalController else { continue } - controller.applySyncedWorktrunkSidebarExpandedWorktreePaths(expandedWorktreePaths) - } - } - - private func syncWorktrunkSidebarSelectionToTabGroup(_ selection: SidebarSelection?) { - guard !worktrunkSidebarSyncApplyingRemoteUpdate else { return } - guard let window else { return } - guard let tabGroup = window.tabGroup, tabGroup.windows.count > 1 else { return } - - for sibling in tabGroup.windows where sibling != window { - guard let controller = sibling.windowController as? TerminalController else { continue } - controller.applySyncedWorktrunkSidebarSelection(selection) - } - } - - private func currentWorktrunkSidebarListMode() -> WorktrunkSidebarListMode { - (NSApp.delegate as? AppDelegate)?.worktrunkStore.sidebarListMode ?? .flatWorktrees - } - - private func currentWorktrunkAlwaysVisibleWorktreePaths() -> Set { - guard WorktrunkPreferences.sidebarTabsEnabled else { return [] } - guard currentWorktrunkSidebarListMode() == .nestedByRepo else { return [] } - return Set(openTabsModel.tabs.compactMap(\.worktreeRootPath).map(Self.standardizedPath)) - } - - private func applySyncedWorktrunkSidebarVisibility(_ visibility: NavigationSplitViewVisibility) { - guard worktrunkSidebarState.columnVisibility != visibility else { return } - worktrunkSidebarState.isApplyingRemoteUpdate = true - worktrunkSidebarSyncApplyingRemoteUpdate = true - defer { - worktrunkSidebarSyncApplyingRemoteUpdate = false - worktrunkSidebarState.isApplyingRemoteUpdate = false - } - worktrunkSidebarState.columnVisibility = visibility - } - - private func applySyncedWorktrunkSidebarExpandedRepoIDs(_ expandedRepoIDs: Set) { - guard worktrunkSidebarState.expandedRepoIDs != expandedRepoIDs else { return } - worktrunkSidebarState.isApplyingRemoteUpdate = true - worktrunkSidebarSyncApplyingRemoteUpdate = true - defer { - worktrunkSidebarSyncApplyingRemoteUpdate = false - worktrunkSidebarState.isApplyingRemoteUpdate = false - } - worktrunkSidebarState.applyExpandedRepoIDs( - expandedRepoIDs, - listMode: currentWorktrunkSidebarListMode(), - alwaysVisibleWorktreePaths: currentWorktrunkAlwaysVisibleWorktreePaths() + // 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, ) - } - - private func applySyncedWorktrunkSidebarExpandedWorktreePaths(_ expandedWorktreePaths: Set) { - guard worktrunkSidebarState.expandedWorktreePaths != expandedWorktreePaths else { return } - worktrunkSidebarState.isApplyingRemoteUpdate = true - worktrunkSidebarSyncApplyingRemoteUpdate = true - defer { - worktrunkSidebarSyncApplyingRemoteUpdate = false - worktrunkSidebarState.isApplyingRemoteUpdate = false - } - worktrunkSidebarState.applyExpandedWorktreePaths( - expandedWorktreePaths, - listMode: currentWorktrunkSidebarListMode(), - alwaysVisibleWorktreePaths: currentWorktrunkAlwaysVisibleWorktreePaths() + let restored = LastWindowPosition.shared.restore( + terminalWindow, + origin: !originChanged, + size: defaultSize == nil, ) - } - - private func applySyncedWorktrunkSidebarSelection(_ selection: SidebarSelection?) { - guard worktrunkSidebarState.selection != selection else { return } - worktrunkSidebarState.isApplyingRemoteUpdate = true - worktrunkSidebarSyncApplyingRemoteUpdate = true - defer { - worktrunkSidebarSyncApplyingRemoteUpdate = false - worktrunkSidebarState.isApplyingRemoteUpdate = false - } - worktrunkSidebarState.selection = selection - } - - private func syncWorktrunkSidebarStateToTabGroup() { - syncWorktrunkSidebarVisibilityToTabGroup(worktrunkSidebarState.columnVisibility) - syncWorktrunkSidebarExpandedRepoIDsToTabGroup(worktrunkSidebarState.expandedRepoIDs) - syncWorktrunkSidebarExpandedWorktreePathsToTabGroup(worktrunkSidebarState.expandedWorktreePaths) - syncWorktrunkSidebarSelectionToTabGroup(worktrunkSidebarState.selection) - } - - private func syncSidebarSelectionToActiveTab() { - guard WorktrunkPreferences.worktreeTabsEnabled, - let rootPath = worktreeTabRootPath, - let appDelegate = NSApp.delegate as? AppDelegate else { return } - - let store = appDelegate.worktrunkStore - for repo in store.repositories - where store.worktrees(for: repo.id).contains(where: { $0.path == rootPath }) { - let newSelection = SidebarSelection.worktree(repoID: repo.id, path: rootPath) - guard worktrunkSidebarState.selection != newSelection else { return } - applySyncedWorktrunkSidebarSelection(newSelection) - syncWorktrunkSidebarSelectionToTabGroup(newSelection) - return - } - } - private func installWorktrunkTitlebar() { - guard let window else { return } - guard window.styleMask.contains(.titled) else { return } - - // TitlebarTabs windows have their own toolbar - don't override - if window is TitlebarTabsTahoeTerminalWindow || window is TitlebarTabsVenturaTerminalWindow { - return - } - - // Create a WorktrunkToolbar with sidebar toggle button - if window.toolbar == nil { - // Add fullSizeContentView for sidebarTrackingSeparator to work - window.styleMask.insert(.fullSizeContentView) - let toolbar = WorktrunkToolbar(target: self) - window.toolbar = toolbar - } - - window.titleVisibility = .hidden - if let terminalWindow = window as? TerminalWindow { - terminalWindow.toolbarStyle = .unified - if let toolbar = window.toolbar as? WorktrunkToolbar { - toolbar.titleText = window.title - toolbar.titleTextFont = terminalWindow.titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) - toolbar.titleTextColor = window.isKeyWindow ? .labelColor : .secondaryLabelColor - } + // If nothing is changed for the frame, + // we should center the window + if !originChanged, !restored { + // This doesn't work in `windowDidLoad` somehow + terminalWindow.center() } - } - @objc func toggleWorktrunkSidebar(_ sender: Any?) { - switch worktrunkSidebarState.columnVisibility { - case .detailOnly: - worktrunkSidebarState.columnVisibility = .all - default: - worktrunkSidebarState.columnVisibility = .detailOnly - } - } - - @objc func toggleSidebar(_ sender: Any?) { - toggleWorktrunkSidebar(sender) - } - - private func updateWorktrunkTitlebarWidth(_ newValue: CGFloat) { - if let window = window as? TitlebarTabsTahoeTerminalWindow { - window.updateWorktrunkSidebarWidth(max(0, newValue)) - return - } - if let window = window as? TitlebarTabsVenturaTerminalWindow { - window.updateWorktrunkSidebarWidth(max(0, newValue)) - return - } - } - - private func openWorktree(atPath path: String) { - if WorktrunkPreferences.worktreeTabsEnabled { - openWorktreeTabSession(worktreePath: path, baseConfig: Ghostty.SurfaceConfiguration()) - return - } - - if focusOpenWorktree(atPath: path) { - return - } - - let behavior: WorktrunkOpenBehavior = { - let raw = UserDefaults.standard.string(forKey: WorktrunkPreferences.openBehaviorKey) ?? "" - return WorktrunkOpenBehavior(rawValue: raw) ?? .newTab - }() - - var base = Ghostty.SurfaceConfiguration() - base.workingDirectory = path - - switch behavior { - case .newTab: - _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: base) - - case .splitRight: - if let focusedSurface { - if newSplit(at: focusedSurface, direction: .right, baseConfig: base) == nil { - _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: base) - } - } else { - _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: base) - } - - case .splitDown: - if let focusedSurface { - if newSplit(at: focusedSurface, direction: .down, baseConfig: base) == nil { - _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: base) - } - } else { - _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: base) - } - } - } - - private func openWorktreeAgentSession(atPath path: String, agent: WorktrunkAgent) { - guard agent.isAvailable else { return } - - var base = Ghostty.SurfaceConfiguration() - base.workingDirectory = path - base.command = agent.command - TerminalAgentHooks.apply(to: &base) - - if WorktrunkPreferences.worktreeTabsEnabled { - openWorktreeTabSession(worktreePath: path, baseConfig: base) - return - } - - let behavior: WorktrunkOpenBehavior = { - let raw = UserDefaults.standard.string(forKey: WorktrunkPreferences.openBehaviorKey) ?? "" - return WorktrunkOpenBehavior(rawValue: raw) ?? .newTab - }() - - switch behavior { - case .newTab: - _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: base) - - case .splitRight: - if let focusedSurface { - if newSplit(at: focusedSurface, direction: .right, baseConfig: base) == nil { - _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: base) - } - } else { - _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: base) - } - - case .splitDown: - if let focusedSurface { - if newSplit(at: focusedSurface, direction: .down, baseConfig: base) == nil { - _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: base) - } - } else { - _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: base) - } - } - } - - private func onWorktrunkSelectionChange(_ path: String?) { - if #available(macOS 26.0, *) { - Task { await gitDiffSidebarState.setSelectedWorktreePath(path) } - } - - guard let path else { return } - - if WorktrunkPreferences.worktreeTabsEnabled { - // Worktree-tabs mode: use the controller registry for direct lookup - if let controller = Self.existingWorktreeTabController(forWorktreePath: path) { - controller.window?.makeKeyAndOrderFront(nil) - return - } - } - - // Fallback / non-worktree-tabs mode: scan surfaces by pwd - focusOpenWorktree(atPath: path) - } - - @objc func toggleGitDiffSidebar(_ sender: Any?) { - guard #available(macOS 26.0, *) else { return } - let willShow = !gitDiffSidebarState.isVisible - Task { - let selectedWorktreePath: String? = { - switch worktrunkSidebarState.selection { - case .worktree(_, let path): - return path - case .session(_, _, let worktreePath): - return worktreePath - default: - return nil - } - }() - - if willShow { - gitDiffSidebarState.selectedWorktreePath = selectedWorktreePath - } - - await gitDiffSidebarState.setVisible( - willShow, - cwd: selectedWorktreePath ?? focusedSurface?.pwd - ) - if let window = window as? TerminalWindow { - window.titlebarFont = lastTitlebarFont - window.setDiffSidebarButtonState(willShow) - } - } - } - - @objc func closeGitDiff(_ sender: Any?) { - guard #available(macOS 26.0, *) else { return } - Task { - await gitDiffSidebarState.setVisible(false, cwd: nil) - if let window = window as? TerminalWindow { - window.setDiffSidebarButtonState(false) - } - } - } - - @objc func openInEditor(_ sender: Any?) { - // If the dropdown segment (1) was clicked, show the editor menu - if let segmented = sender as? NSSegmentedControl, segmented.selectedSegment == 1 { - if let menu = segmented.menu(forSegment: 1) { - let screenRect = segmented.window?.convertToScreen( - segmented.convert(segmented.bounds, to: nil) - ) ?? .zero - let origin = NSPoint(x: screenRect.minX, y: screenRect.minY) - menu.popUp(positioning: nil, at: origin, in: nil) - } - return - } - - guard let editor = WorktrunkPreferences.preferredEditor else { return } - openIn(editor: editor) - } - - @objc func openInSpecificEditor(_ sender: Any?) { - guard let menuItem = sender as? NSMenuItem, - let editor = menuItem.representedObject as? ExternalEditor else { return } - openIn(editor: editor) - } - - private func openIn(editor: ExternalEditor) { - guard let appURL = editor.appURL else { return } - let cwd = currentEditorPath() - guard let cwd else { return } - - WorktrunkPreferences.lastEditor = editor - refreshEditorToolbarIcon(for: editor) - - let url = URL(fileURLWithPath: cwd) - let config = NSWorkspace.OpenConfiguration() - NSWorkspace.shared.open([url], withApplicationAt: appURL, configuration: config) - } - - private func refreshEditorToolbarIcon(for editor: ExternalEditor) { - guard let toolbar = window?.toolbar else { return } - for item in toolbar.items { - guard item.itemIdentifier == .openInEditor, - let segmented = item.view as? NSSegmentedControl else { continue } - EditorSplitButton.updateIcon(segmented, editor: editor) - break - } - } - - private func currentEditorPath() -> String? { - // Prefer selected worktree path from sidebar, fall back to focused surface pwd - switch worktrunkSidebarState.selection { - case .worktree(_, let path): - return path - case .session(_, _, let worktreePath): - return worktreePath - default: - return focusedSurface?.pwd - } - } - - private func resumeAISession(_ session: AISession) { - var base = Ghostty.SurfaceConfiguration() - base.workingDirectory = session.cwd - - switch session.source { - case .claude: - // Claude needs to run from the cwd (set via workingDirectory) - base.command = "claude --resume \(session.id)" - case .codex: - // Codex handles cwd internally - base.command = "codex resume \(session.id)" - case .opencode: - base.command = "opencode --session \(session.id)" - case .copilotCli: - base.command = "copilot --resume \(session.id)" - } - - if WorktrunkPreferences.worktreeTabsEnabled { - openWorktreeTabSession(worktreePath: session.worktreePath, baseConfig: base) - return - } - - _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: base) - } - - private func focusOpenWorktree(atPath path: String) -> Bool { - let target = URL(fileURLWithPath: path).standardizedFileURL.path - let targetPrefix = target.hasSuffix("/") ? target : (target + "/") - - for window in NSApp.windows { - guard let controller = window.windowController as? TerminalController else { continue } - - for surfaceView in controller.surfaceTree { - guard let pwd = surfaceView.pwd else { continue } - let pwdPath = URL(fileURLWithPath: pwd).standardizedFileURL.path - if pwdPath == target || pwdPath.hasPrefix(targetPrefix) { - controller.focusSurface(surfaceView) - return true - } - } - } - - return false + super.showWindow(sender) } // Shows the "+" button in the tab bar, responds to that click. @@ -2040,6 +1173,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr override func windowWillClose(_ notification: Notification) { super.windowWillClose(notification) + cancelPendingInitialPresentation() self.relabelTabs() // If we remove a window, we reset the cascade point to the key window so that @@ -2076,13 +1210,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr super.windowDidBecomeKey(notification) self.relabelTabs() self.fixTabBar() - requestTabSwitchRefresh() - terminalGlassContainer?.updateGlassTintOverlay(isKeyWindow: true) + terminalViewContainer?.updateGlassTintOverlay(isKeyWindow: true) } override func windowDidResignKey(_ notification: Notification) { super.windowDidResignKey(notification) - terminalGlassContainer?.updateGlassTintOverlay(isKeyWindow: false) + terminalViewContainer?.updateGlassTintOverlay(isKeyWindow: false) } override func windowDidMove(_ notification: Notification) { @@ -2090,27 +1223,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 @@ -2119,10 +1246,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Called when the window will be encoded. We handle the data encoding here in the // window controller. func window(_ window: NSWindow, willEncodeRestorableState state: NSCoder) { - if RestorablePath.existingDirectoryURL(window.representedURL) == nil { - window.representedURL = nil - } - let data = TerminalRestorableState(from: self) data.encode(with: state) } @@ -2136,16 +1259,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr @IBAction func newTab(_ sender: Any?) { guard let surface = focusedSurface?.surface else { return } - if WorktrunkPreferences.sidebarTabsEnabled { - // Sidebar tabs mode owns tab creation. Do not allow native titlebar tabs. - if WorktrunkPreferences.worktreeTabsEnabled, worktreeTabRootPath != nil { - openWorktreeTabNewSession(baseConfig: Ghostty.SurfaceConfiguration()) - } else { - ghostty.newWindow(surface: surface) - } - return - } - ghostty.newTab(surface: surface) } @@ -2283,8 +1396,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr guard let focusedSurface else { return } syncAppearance(focusedSurface.derivedConfig) - requestTabSwitchRefresh() - // We also want to get notified of certain changes to update our appearance. focusedSurface.$derivedConfig .sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) } @@ -2292,17 +1403,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr focusedSurface.$backgroundColor .sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) } .store(in: &surfaceAppearanceCancellables) - - } - - override func pwdDidChange(to: URL?) { - super.pwdDidChange(to: to) - if #available(macOS 26.0, *) { - guard let to else { return } - Task { @MainActor in - gitDiffSidebarState.requestRefresh(cwd: to, force: false) - } - } } private func syncAppearanceOnPropertyChange(_ surface: Ghostty.SurfaceView?) { @@ -2315,55 +1415,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } - private func requestTabSwitchRefresh() { - let now = Date() - let currentSurfaceID = focusedSurface?.id - if let lastTabSwitchRefreshAt, - now.timeIntervalSince(lastTabSwitchRefreshAt) < tabSwitchRefreshThrottle { - if currentSurfaceID == lastTabSwitchSurfaceID { - return - } - scheduleTabSwitchRefresh() - return - } - - lastTabSwitchRefreshAt = now - pendingTabSwitchRefresh?.cancel() - pendingTabSwitchRefresh = nil - performTabSwitchRefresh() - } - - private func scheduleTabSwitchRefresh() { - pendingTabSwitchRefresh?.cancel() - let item = DispatchWorkItem { [weak self] in - guard let self else { return } - self.lastTabSwitchRefreshAt = Date() - self.performTabSwitchRefresh() - } - pendingTabSwitchRefresh = item - DispatchQueue.main.asyncAfter(deadline: .now() + tabSwitchRefreshThrottle, execute: item) - } - - private func performTabSwitchRefresh() { - lastTabSwitchSurfaceID = focusedSurface?.id - syncSidebarSelectionToActiveTab() - syncWorktrunkSidebarStateToTabGroup() - if let appDelegate = NSApp.delegate as? AppDelegate, - let pwd = focusedSurface?.pwd { - appDelegate.worktrunkStore.clearAgentReviewIfViewing(cwd: pwd) - } - - if #available(macOS 26.0, *) { - // Git diff should be ready for the active tab without requiring any Worktrunk interaction. - if gitDiffSidebarState.isVisible, - let pwd = focusedSurface?.pwd { - Task { @MainActor in - gitDiffSidebarState.requestRefresh(cwd: URL(fileURLWithPath: pwd), force: false) - } - } - } - } - // MARK: - Notifications @objc private func onMoveTab(notification: SwiftUI.Notification) { @@ -2531,7 +1582,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? @@ -2539,7 +1590,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 @@ -2638,17 +1689,8 @@ 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 } } } - -extension TerminalController { - func openWorktreeFromPalette(atPath path: String) { - openWorktree(atPath: path) - } -} diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 398f9647289..5921837b507 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -105,7 +105,7 @@ struct TerminalView: 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 fcd71438d9c..dd0190c4c61 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() { @@ -70,23 +82,12 @@ class TerminalViewContainer: NSView { } } -protocol TerminalGlassContainer: AnyObject { - func ghosttyConfigDidChange(_ config: Ghostty.Config, preferredBackgroundColor: NSColor?) - func updateGlassTintOverlay(isKeyWindow: Bool) -} - -extension TerminalViewContainer: TerminalGlassContainer {} - // MARK: - BaseTerminalController + terminalViewContainer extension BaseTerminalController { var terminalViewContainer: TerminalViewContainer? { window?.contentView as? TerminalViewContainer } - - var terminalGlassContainer: (any TerminalGlassContainer)? { - window?.contentView as? any TerminalGlassContainer - } } // MARK: Glass diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 974956493f1..ac1d2b8814d 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -17,15 +17,12 @@ class TerminalWindow: NSWindow { /// The view model for SwiftUI views private var viewModel = ViewModel() - private var enforcedTitlebarFont: NSFont = NSFont.titleBarFont(ofSize: NSFont.systemFontSize) /// Reset split zoom button in titlebar private let resetZoomAccessory = NSTitlebarAccessoryViewController() /// Update notification UI in titlebar private let updateAccessory = NSTitlebarAccessoryViewController() - private let diffSidebarAccessory = NSTitlebarAccessoryViewController() - private weak var diffSidebarButton: NSButton? /// Visual indicator that mirrors the selected tab color. private lazy var tabColorIndicator: NSHostingView = { @@ -39,10 +36,7 @@ class TerminalWindow: NSWindow { /// Sets up our tab context menu private var tabMenuObserver: NSObjectProtocol? - private var titlebarFontTabGroupObservation: NSKeyValueObservation? - private var titlebarFontTabBarObservation: NSKeyValueObservation? - private var lastTitlebarFontState: TitlebarFontState? - private var lastAppliedAppearance: AppearanceState? + /// Handles inline tab title editing for this host window. private(set) lazy var tabTitleEditor = TabTitleEditor( hostWindow: self, @@ -114,8 +108,6 @@ class TerminalWindow: NSWindow { // Setup our initial config derivedConfig = .init(config) - enforcedTitlebarFont = NSFont.titleBarFont(ofSize: NSFont.systemFontSize) - setupTitlebarFontKVO() // If there is a hardcoded title in the configuration, we set that // immediately. Future `set_title` apprt actions will override this @@ -128,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 { @@ -162,41 +153,6 @@ class TerminalWindow: NSWindow { addTitlebarAccessoryViewController(updateAccessory) updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false } - - diffSidebarAccessory.layoutAttribute = .right - let container = NonDraggableAccessoryContainer() - container.translatesAutoresizingMaskIntoConstraints = false - container.widthAnchor.constraint(equalToConstant: 40).isActive = true - container.heightAnchor.constraint(equalToConstant: 40).isActive = true - - let button = NonDraggableToolbarButton(frame: .zero) - button.translatesAutoresizingMaskIntoConstraints = false - if #available(macOS 26.0, *) { - button.bezelStyle = .glass - } else { - button.bezelStyle = .toolbar - } - let symbolConfig = NSImage.SymbolConfiguration(pointSize: 15, weight: .regular) - button.image = NSImage(systemSymbolName: "plusminus", accessibilityDescription: "Toggle Diff Sidebar")? - .withSymbolConfiguration(symbolConfig) - button.imagePosition = .imageOnly - button.controlSize = .large - button.target = terminalController - button.action = #selector(TerminalController.toggleGitDiffSidebar(_:)) - button.setButtonType(.pushOnPushOff) - - container.addSubview(button) - NSLayoutConstraint.activate([ - button.centerXAnchor.constraint(equalTo: container.centerXAnchor), - button.centerYAnchor.constraint(equalTo: container.centerYAnchor, constant: -6), - button.widthAnchor.constraint(equalToConstant: 36), - button.heightAnchor.constraint(equalToConstant: 36), - ]) - - diffSidebarAccessory.view = container - diffSidebarButton = button - addTitlebarAccessoryViewController(diffSidebarAccessory) - diffSidebarAccessory.view.translatesAutoresizingMaskIntoConstraints = false } // Setup the accessory view for tabs that shows our keyboard shortcuts, @@ -215,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 @@ -228,8 +184,13 @@ class TerminalWindow: NSWindow { return } + if tabTitleEditor.handleRightMouseDown(event) { + return + } + super.sendEvent(event) } + override func close() { tabTitleEditor.finishEditing(commit: true) NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self) @@ -239,13 +200,11 @@ class TerminalWindow: NSWindow { override func becomeKey() { super.becomeKey() resetZoomTabButton.contentTintColor = .controlAccentColor - enforceTitlebarFont() } override func resignKey() { super.resignKey() resetZoomTabButton.contentTintColor = .secondaryLabelColor - updateWorktrunkToolbarTitle() tabTitleEditor.finishEditing(commit: true) } @@ -254,19 +213,12 @@ class TerminalWindow: NSWindow { // Its possible we miss the accessory titlebar call so we check again // whenever the window becomes main. Both of these are idempotent. - if WorktrunkPreferences.sidebarTabsEnabled { - collapseNativeTabBarRegionIfPresent() - } else if tabBarView != nil { + if tabBarView != nil { tabBarDidAppear() } else { tabBarDidDisappear() } viewModel.isMainWindow = true - if diffSidebarButton?.target == nil { - diffSidebarButton?.target = terminalController - } - enforceTitlebarFont() - setupTitlebarFontKVO() } override func resignMain() { @@ -274,11 +226,6 @@ class TerminalWindow: NSWindow { viewModel.isMainWindow = false } - override func update() { - super.update() - enforceTitlebarFont() - } - @discardableResult func beginInlineTabTitleEdit(for targetWindow: NSWindow) -> Bool { tabTitleEditor.beginEditing(for: targetWindow) @@ -305,49 +252,23 @@ class TerminalWindow: NSWindow { } override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) { - let isTabBarCandidate = isTabBar(childViewController) - if isTabBarCandidate, WorktrunkPreferences.sidebarTabsEnabled { - // Prevent a one-frame flash of the native tab strip by collapsing its reserved region - // before AppKit lays it out. - childViewController.identifier = Self.tabBarIdentifier - childViewController.view.isHidden = true - childViewController.view.alphaValue = 0 - let c = childViewController.view.heightAnchor.constraint(equalToConstant: 0) - c.priority = .required - c.isActive = true - } - super.addTitlebarAccessoryViewController(childViewController) // Tab bar is attached as a titlebar accessory view controller (layout bottom). We // can detect when it is shown or hidden by overriding add/remove and searching for // it. This has been verified to work on macOS 12 to 26 - if isTabBarCandidate || isTabBar(childViewController) { + if isTabBar(childViewController) { childViewController.identifier = Self.tabBarIdentifier - if WorktrunkPreferences.sidebarTabsEnabled { - // In "Sidebar tabs" mode we keep native tab groups but hide the native - // tab bar UI so switching happens via the sidebar. - collapseTitlebarAccessoryClipViewIfPresent(containing: childViewController.view) - } else { - tabBarDidAppear() - } - } - DispatchQueue.main.async { [weak self] in - self?.enforceTitlebarFont() + tabBarDidAppear() } } override func removeTitlebarAccessoryViewController(at index: Int) { if let childViewController = titlebarAccessoryViewControllers[safe: index], isTabBar(childViewController) { - if !WorktrunkPreferences.sidebarTabsEnabled { - tabBarDidDisappear() - } + tabBarDidDisappear() } super.removeTitlebarAccessoryViewController(at: index) - DispatchQueue.main.async { [weak self] in - self?.enforceTitlebarFont() - } } // MARK: Tab Bar @@ -396,12 +317,6 @@ class TerminalWindow: NSWindow { // We don't need to do this with the update accessory. I don't know why but // everything works fine. - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { [weak self] in - self?.enforceTitlebarFont() - } - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(60)) { [weak self] in - self?.enforceTitlebarFont() - } } private func tabBarDidDisappear() { @@ -410,48 +325,6 @@ class TerminalWindow: NSWindow { addTitlebarAccessoryViewController(resetZoomAccessory) } } - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { [weak self] in - self?.enforceTitlebarFont() - } - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(60)) { [weak self] in - self?.enforceTitlebarFont() - } - } - - private func removeNativeTabBarIfPresent() { - // Tab bars can appear via multiple accessory view controller arrangements depending on - // macOS + window state. Remove any tab bar accessory view controllers we detect. - while let idx = titlebarAccessoryViewControllers.firstIndex(where: { isTabBar($0) }) { - removeTitlebarAccessoryViewController(at: idx) - } - } - - private func collapseNativeTabBarRegionIfPresent() { - guard WorktrunkPreferences.sidebarTabsEnabled else { return } - for tabBarVC in titlebarAccessoryViewControllers where isTabBar(tabBarVC) { - collapseTitlebarAccessoryClipViewIfPresent(containing: tabBarVC.view) - } - } - - private func collapseTitlebarAccessoryClipViewIfPresent(containing view: NSView) { - var v: NSView? = view - while let cur = v, cur.className != "NSTitlebarAccessoryClipView" { - v = cur.superview - } - guard let clip = v else { return } - - clip.isHidden = true - clip.translatesAutoresizingMaskIntoConstraints = false - if clip.constraints.first(where: { c in - c.firstAttribute == .height && - c.relation == .equal && - c.constant == 0 && - c.priority == .required - }) == nil { - let c = clip.heightAnchor.constraint(equalToConstant: 0) - c.priority = .required - c.isActive = true - } } // MARK: Tab Key Equivalents @@ -481,13 +354,6 @@ class TerminalWindow: NSWindow { return label }() - // MARK: Diff Sidebar Toggle - - /// Update the diff sidebar toggle button to reflect visibility state. - func setDiffSidebarButtonState(_ isOn: Bool) { - diffSidebarButton?.state = isOn ? .on : .off - } - // MARK: Surface Zoom /// Set to true if a surface is currently zoomed to show the reset zoom button. @@ -525,29 +391,17 @@ class TerminalWindow: NSWindow { // MARK: Title Text - private struct TitlebarFontState: Equatable { - let title: String - let fontName: String - let fontSize: CGFloat - let isKeyWindow: Bool - let macosTitlebarStyle: String - let tabCount: Int - let toolbarIdentifier: ObjectIdentifier? - } - override var title: String { didSet { - // Only manage tab titles for custom tab styles. - if derivedConfig.macosTitlebarStyle == "tabs" { - tab.title = title - tab.attributedTitle = attributedTitle - } + // Whenever we change the window title we must also update our + // tab title if we're using custom fonts. + tab.attributedTitle = attributedTitle /// We also needs to update this here, just in case /// the value is not what we want /// /// Check ``titlebarFont`` down below /// to see why we need to check `hasMoreThanOneTabs` here - enforceTitlebarFont() + titlebarTextField?.usesSingleLineMode = !hasMoreThanOneTabs } } @@ -555,9 +409,15 @@ class TerminalWindow: NSWindow { var titlebarFont: NSFont? { didSet { let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) - enforcedTitlebarFont = font - enforceTitlebarFont() + titlebarTextField?.font = font + /// We check `hasMoreThanOneTabs` here because the system + /// may copy this setting to the tab’s text field at some point(e.g. entering/exiting fullscreen), + /// which can cause the title to be vertically misaligned (shifted downward). + /// + /// This behaviour is the opposite of what happens in the title bar’s text field, which is quite odd... + titlebarTextField?.usesSingleLineMode = !hasMoreThanOneTabs + tab.attributedTitle = attributedTitle } } @@ -570,73 +430,15 @@ class TerminalWindow: NSWindow { // Return a styled representation of our title property. var attributedTitle: NSAttributedString? { + guard let titlebarFont = titlebarFont else { return nil } + let attributes: [NSAttributedString.Key: Any] = [ - .font: enforcedTitlebarFont, + .font: titlebarFont, .foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, ] return NSAttributedString(string: title, attributes: attributes) } - func enforceTitlebarFont() { - let tabCount = tabGroup?.windows.count ?? 0 - let font = enforcedTitlebarFont - let state = TitlebarFontState( - title: title, - fontName: font.fontName, - fontSize: font.pointSize, - isKeyWindow: isKeyWindow, - macosTitlebarStyle: derivedConfig.macosTitlebarStyle, - tabCount: tabCount, - toolbarIdentifier: toolbar.map(ObjectIdentifier.init) - ) - if state == lastTitlebarFontState { - return - } - lastTitlebarFontState = state - - if derivedConfig.macosTitlebarStyle != "tabs", - tabCount > 1 { - updateWorktrunkToolbarTitle() - return - } - if let titlebarTextField { - titlebarTextField.font = enforcedTitlebarFont - titlebarTextField.usesSingleLineMode = true - titlebarTextField.attributedStringValue = attributedTitle ?? NSAttributedString(string: title) - if derivedConfig.macosTitlebarStyle == "tabs" { - tab.title = title - tab.attributedTitle = attributedTitle - } - } - updateWorktrunkToolbarTitle() - } - - private func updateWorktrunkToolbarTitle() { - guard let toolbar = toolbar as? WorktrunkToolbar else { return } - toolbar.titleText = title - toolbar.titleTextFont = enforcedTitlebarFont - toolbar.titleTextColor = isKeyWindow ? .labelColor : .secondaryLabelColor - } - - private func setupTitlebarFontKVO() { - titlebarFontTabGroupObservation?.invalidate() - titlebarFontTabGroupObservation = nil - titlebarFontTabBarObservation?.invalidate() - titlebarFontTabBarObservation = nil - - guard let tabGroup else { return } - titlebarFontTabGroupObservation = tabGroup.observe(\.windows, options: [.new]) { [weak self] _, _ in - DispatchQueue.main.async { [weak self] in - self?.enforceTitlebarFont() - } - } - titlebarFontTabBarObservation = tabGroup.observe(\.isTabBarVisible, options: [.new]) { [weak self] _, _ in - DispatchQueue.main.async { [weak self] in - self?.enforceTitlebarFont() - } - } - } - var titlebarContainer: NSView? { // If we aren't fullscreen then the titlebar container is part of our window. if !styleMask.contains(.fullScreen) { @@ -661,64 +463,14 @@ class TerminalWindow: NSWindow { // MARK: Positioning And Styling - private struct ColorRGBA: Equatable { - let r: CGFloat - let g: CGFloat - let b: CGFloat - let a: CGFloat - } - - private struct AppearanceState: Equatable { - let isFullScreen: Bool - let forceOpaque: Bool - let backgroundOpacity: Double - let backgroundBlur: Ghostty.Config.BackgroundBlur - let macosWindowShadow: Bool - let windowTheme: String - let windowAppearanceName: String? - let preferredBackground: ColorRGBA? - } - - private func rgba(from color: NSColor?) -> ColorRGBA? { - guard let color else { return nil } - guard let rgb = color.usingColorSpace(.deviceRGB) else { return nil } - return ColorRGBA( - r: rgb.redComponent, - g: rgb.greenComponent, - b: rgb.blueComponent, - a: rgb.alphaComponent - ) - } - /// This is called by the controller when there is a need to reset the window appearance. func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { // If our window is not visible, then we do nothing. Some things such as blurring // have no effect if the window is not visible. Ultimately, we'll have this called // at some point when a surface becomes focused. - guard isVisible else { - lastAppliedAppearance = nil - return - } + guard isVisible else { return } defer { updateColorSchemeForSurfaceTree() } - let isFullScreen = styleMask.contains(.fullScreen) - let forceOpaque = terminalController?.isBackgroundOpaque ?? false - let windowTheme = surfaceConfig.windowTheme.trimmingCharacters(in: .whitespacesAndNewlines) - let preferredBackground = preferredBackgroundColor - let appearanceState = AppearanceState( - isFullScreen: isFullScreen, - forceOpaque: forceOpaque, - backgroundOpacity: surfaceConfig.backgroundOpacity, - backgroundBlur: surfaceConfig.backgroundBlur, - macosWindowShadow: surfaceConfig.macosWindowShadow, - windowTheme: windowTheme, - windowAppearanceName: surfaceConfig.windowAppearance?.name.rawValue, - preferredBackground: rgba(from: preferredBackground) - ) - if appearanceState == lastAppliedAppearance { - return - } - // Basic properties appearance = surfaceConfig.windowAppearance hasShadow = surfaceConfig.macosWindowShadow @@ -728,7 +480,8 @@ class TerminalWindow: NSWindow { // becomes gray and widgets show through. // // Also check if the user has overridden transparency to be fully opaque. - if !isFullScreen && + let forceOpaque = terminalController?.isBackgroundOpaque ?? false + if !styleMask.contains(.fullScreen) && !forceOpaque && (surfaceConfig.backgroundOpacity < 1 || surfaceConfig.backgroundBlur.isGlassStyle) { isOpaque = false @@ -747,16 +500,9 @@ class TerminalWindow: NSWindow { } else { isOpaque = true - let usesTerminalBackgroundForWindow = windowTheme == "auto" || windowTheme == "ghostty" - if usesTerminalBackgroundForWindow { - let backgroundColor = preferredBackground ?? NSColor(surfaceConfig.backgroundColor) - self.backgroundColor = backgroundColor.withAlphaComponent(1) - } else { - self.backgroundColor = NSColor.windowBackgroundColor - } + let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor) + self.backgroundColor = backgroundColor.withAlphaComponent(1) } - - lastAppliedAppearance = appearanceState } /// The preferred window background color. The current window background color may not be set @@ -794,20 +540,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 @@ -823,6 +564,7 @@ class TerminalWindow: NSWindow { safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.height) setFrameOrigin(safeOrigin) + return true } private func hideWindowButtons() { @@ -845,7 +587,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() { @@ -854,7 +596,7 @@ class TerminalWindow: NSWindow { self.backgroundOpacity = 1 self.macosWindowButtons = .visible self.backgroundBlur = .disabled - self.macosTitlebarStyle = "transparent" + self.macosTitlebarStyle = .default self.windowCornerRadius = 16 } @@ -870,7 +612,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 @@ -937,14 +679,6 @@ extension TerminalWindow { } -private final class NonDraggableToolbarButton: NSButton { - override var mouseDownCanMoveWindow: Bool { false } -} - -private final class NonDraggableAccessoryContainer: NSView { - override var mouseDownCanMoveWindow: Bool { false } -} - /// A small circle indicator displayed in the tab accessory view that shows /// the user-assigned tab color. When no color is set, the view is hidden. private struct TabColorIndicatorView: View { @@ -968,11 +702,11 @@ private struct TabColorIndicatorView: View { // MARK: - Tab Context Menu extension TerminalWindow { - private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("dev.sidequery.Ghostree.closeTabsOnTheRightMenuItem") - private static let changeTitleMenuItemIdentifier = NSUserInterfaceItemIdentifier("dev.sidequery.Ghostree.changeTitleMenuItem") - private static let tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("dev.sidequery.Ghostree.tabColorSeparator") + private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") + private static let changeTitleMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.changeTitleMenuItem") + private static let tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorSeparator") - private static let tabColorPaletteIdentifier = NSUserInterfaceItemIdentifier("dev.sidequery.Ghostree.tabColorPalette") + private static let tabColorPaletteIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorPalette") func configureTabContextMenuIfNeeded(_ menu: NSMenu) { guard isTabContextMenu(menu) else { return } @@ -1100,4 +834,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/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index a18309380d6..6cbd891bf3a 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -1,12 +1,6 @@ import AppKit import SwiftUI -/// Default width for the worktrunk sidebar. -private let defaultSidebarWidth: CGFloat = 280 - -/// Padding to accommodate window control buttons (close/minimize/zoom). -private let windowControlButtonsWidth: CGFloat = 70 - /// `macos-titlebar-style = tabs` for macOS 26 (Tahoe) and later. /// /// This inherits from transparent styling so that the titlebar matches the background color @@ -15,9 +9,6 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool /// The view model for SwiftUI views private var viewModel = ViewModel() - private var worktrunkSidebarWidth: CGFloat = defaultSidebarWidth - private var tabBarLeftConstraint: NSLayoutConstraint? - private var displayTitle: String = "👻 Ghostree" /// Titlebar tabs can't support the update accessory because of the way we layout /// the native tabs back into the menu bar. override var supportsUpdateAccessory: Bool { false } @@ -32,7 +23,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool didSet { DispatchQueue.main.async { [weak self] in guard let self else { return } - self.viewModel.titleFont = self.titlebarFont ?? .titleBarFont(ofSize: NSFont.systemFontSize) + self.viewModel.titleFont = self.titlebarFont } } } @@ -41,8 +32,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool didSet { DispatchQueue.main.async { [weak self] in guard let self else { return } - self.displayTitle = self.title - self.viewModel.title = self.displayTitle + self.viewModel.title = self.title } } } @@ -57,6 +47,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // Create a toolbar let toolbar = NSToolbar(identifier: "TerminalToolbar") toolbar.delegate = self + toolbar.centeredItemIdentifiers.insert(.title) self.toolbar = toolbar toolbarStyle = .unifiedCompact } @@ -76,42 +67,6 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool viewModel.isMainWindow = false } - - /// On our Tahoe titlebar tabs, we need to fix up right click events because they don't work - /// naturally due to whatever mess we made. - override func sendEvent(_ event: NSEvent) { - guard viewModel.hasTabBar else { - super.sendEvent(event) - return - } - - let isRightClick = - event.type == .rightMouseDown || - (event.type == .otherMouseDown && event.buttonNumber == 2) || - (event.type == .leftMouseDown && event.modifierFlags.contains(.control)) - guard isRightClick else { - super.sendEvent(event) - return - } - - guard let tabBarView else { - super.sendEvent(event) - return - } - - guard !tabTitleEditor.handleRightMouseDown(event) else { - return - } - - let locationInTabBar = tabBarView.convert(event.locationInWindow, from: nil) - guard tabBarView.bounds.contains(locationInTabBar) else { - super.sendEvent(event) - return - } - - tabBarView.rightMouseDown(with: event) - } - // This is called by macOS for native tabbing in order to add the tab bar. We hook into // this, detect the tab bar being added, and override its behavior. override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) { @@ -120,6 +75,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // After dragging a tab into a new window, `hasTabBar` needs to be // updated to properly review window title viewModel.hasTabBar = false + super.addTitlebarAccessoryViewController(childViewController) return } @@ -211,11 +167,10 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // The padding for the tab bar. If we're showing window buttons then // we need to offset the window buttons. - let windowButtonsPadding: CGFloat = switch self.derivedConfig.macosWindowButtons { + let leftPadding: CGFloat = switch self.derivedConfig.macosWindowButtons { case .hidden: 0 - case .visible: windowControlButtonsWidth + case .visible: 70 } - let leftPadding = max(windowButtonsPadding, worktrunkSidebarWidth) // Constrain the accessory clip view (the parent of the accessory view // usually that clips the children) to the container view. @@ -223,10 +178,8 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool accessoryView.translatesAutoresizingMaskIntoConstraints = false // Setup all our constraints - tabBarLeftConstraint = clipView.leftAnchor.constraint(equalTo: container.leftAnchor, constant: leftPadding) - if let tabBarLeftConstraint { - NSLayoutConstraint.activate([ - tabBarLeftConstraint, + NSLayoutConstraint.activate([ + clipView.leftAnchor.constraint(equalTo: container.leftAnchor, constant: leftPadding), clipView.rightAnchor.constraint(equalTo: container.rightAnchor), clipView.topAnchor.constraint(equalTo: container.topAnchor, constant: 2), clipView.heightAnchor.constraint(equalTo: container.heightAnchor), @@ -234,8 +187,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool accessoryView.rightAnchor.constraint(equalTo: clipView.rightAnchor), accessoryView.topAnchor.constraint(equalTo: clipView.topAnchor), accessoryView.heightAnchor.constraint(equalTo: clipView.heightAnchor), - ]) - } + ]) clipView.needsLayout = true accessoryView.needsLayout = true @@ -273,42 +225,14 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool self.tabBarObserver = nil } - func updateWorktrunkSidebarWidth(_ width: CGFloat) { - worktrunkSidebarWidth = max(0, width) - - let windowButtonsPadding: CGFloat = switch self.derivedConfig.macosWindowButtons { - case .hidden: 0 - case .visible: windowControlButtonsWidth - } - let tabBarView = self.tabBarView - let originalPostsFrameChangedNotifications = tabBarView?.postsFrameChangedNotifications ?? false - tabBarView?.postsFrameChangedNotifications = false - defer { tabBarView?.postsFrameChangedNotifications = originalPostsFrameChangedNotifications } - tabBarLeftConstraint?.constant = max(windowButtonsPadding, worktrunkSidebarWidth) - } - // MARK: NSToolbarDelegate func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - [ - .toggleSidebar, - .sidebarTrackingSeparator, - .title, - .flexibleSpace, - .space, - .openInEditor, - ] + return [.title, .flexibleSpace, .space] } func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - [ - .toggleSidebar, - .sidebarTrackingSeparator, - .flexibleSpace, - .title, - .flexibleSpace, - .openInEditor, - ] + return [.flexibleSpace, .title, .flexibleSpace] } func toolbar(_ toolbar: NSToolbar, @@ -317,64 +241,30 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool switch itemIdentifier { case .title: let item = NSToolbarItem(itemIdentifier: .title) - item.view = NSHostingView(rootView: TitleItem(viewModel: viewModel)) + item.view = ClickThroughHostingView(rootView: TitleItem(viewModel: viewModel)) // Fix: https://github.com/ghostty-org/ghostty/discussions/9027 item.view?.setContentCompressionResistancePriority(.required, for: .horizontal) item.visibilityPriority = .user - item.isEnabled = true + item.isEnabled = false // This is the documented way to avoid the glass view on an item. // We don't want glass on our title. item.isBordered = false return item - case .toggleSidebar: - let item = NSToolbarItem(itemIdentifier: .toggleSidebar) - let button = NSButton(frame: NSRect(x: 0, y: 0, width: 38, height: 22)) - button.bezelStyle = .toolbar - button.image = NSImage(systemSymbolName: "sidebar.left", accessibilityDescription: "Toggle Sidebar") - button.imagePosition = .imageOnly - button.target = windowController as? TerminalController - button.action = #selector(TerminalController.toggleSidebar(_:)) - item.view = button - item.label = "Toggle Sidebar" - item.isNavigational = true - return item - case .openInEditor: - return makeOpenInEditorItem() default: return NSToolbarItem(itemIdentifier: itemIdentifier) } } - private func makeOpenInEditorItem() -> NSToolbarItem? { - let installed = ExternalEditor.installedEditors() - guard !installed.isEmpty else { return nil } - - let controller = windowController as? TerminalController - - let item = NSToolbarItem(itemIdentifier: .openInEditor) - item.label = "Open in Editor" - item.toolTip = "Open in Editor" - - let segmented = EditorSplitButton.make( - editors: installed, - target: controller - ) - - item.view = segmented - return item - } - // MARK: SwiftUI class ViewModel: ObservableObject { @Published var titleFont: NSFont? - @Published var title: String = "👻 Ghostree" + @Published var title: String = "👻 Ghostty" @Published var hasTabBar: Bool = false @Published var isMainWindow: Bool = true } - } extension NSToolbarItem.Identifier { @@ -418,3 +308,10 @@ extension TitlebarTabsTahoeTerminalWindow { } } } + +/// A "Ghosting" Hosting View, that acts like it's not there +private class ClickThroughHostingView: NSHostingView { + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } +} diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index 990554aceb2..bde8faa24d3 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -724,6 +724,11 @@ private class CenteredDynamicLabel: NSTextField { setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) } + /// Click through, so we can double click here to enlarge current window + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + // Vertically center the text override func draw(_ dirtyRect: NSRect) { guard let attributedString = self.attributedStringValue.mutableCopy() as? NSMutableAttributedString else { diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index 26c6756384e..3083d45d2f8 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -96,8 +96,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 let windowTheme = surfaceConfig.windowTheme.trimmingCharacters(in: .whitespacesAndNewlines) let usesTerminalBackgroundForWindow = windowTheme == "auto" || windowTheme == "ghostty" diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 82b3ad35c27..2f0644b9380 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 239f458e330..743ebfa2fe1 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -45,6 +45,10 @@ extension Ghostty { self.init(config: ghostty_config_clone(config)) } + func clone(config: ghostty_config_t) { + self.config = config + } + deinit { self.config = nil } @@ -53,7 +57,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 +358,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? 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 { @@ -725,6 +729,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 + } } } @@ -906,4 +918,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/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index 27f4d05ddb1..d90fb987e38 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -29,8 +29,11 @@ extension Ghostty { } case GHOSTTY_TRIGGER_UNICODE: - guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil } - key = KeyEquivalent(Character(scalar)) + guard + let scalar = UnicodeScalar(trigger.key.unicode), + let normalized = Character(scalar).lowercased().first + else { return nil } + key = KeyEquivalent(normalized) case GHOSTTY_TRIGGER_CATCH_ALL: // catch_all matches any key, so it can't be represented as a KeyboardShortcut @@ -89,7 +92,7 @@ extension Ghostty { GHOSTTY_KEY_ARROW_RIGHT: .rightArrow, GHOSTTY_KEY_HOME: .home, GHOSTTY_KEY_END: .end, - GHOSTTY_KEY_DELETE: .delete, + GHOSTTY_KEY_DELETE: .deleteForward, GHOSTTY_KEY_PAGE_UP: .pageUp, GHOSTTY_KEY_PAGE_DOWN: .pageDown, GHOSTTY_KEY_ESCAPE: .escape, diff --git a/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift b/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift new file mode 100644 index 00000000000..e97f71a6289 --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift @@ -0,0 +1,124 @@ +import AppKit + +extension Ghostty { + /// The manager that's responsible for updating shortcuts of Ghostty's app menu + @MainActor + class MenuShortcutManager { + + /// 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] = [:] + + /// Reset our shortcut index since we're about to rebuild all menu bindings. + func reset() { + menuItemsByShortcut.removeAll(keepingCapacity: true) + } + + /// Syncs a single menu shortcut for the given action. The action string is the same + /// action string used for the Ghostty configuration. + func syncMenuShortcut(_ config: Ghostty.Config, action: String?, menuItem: NSMenuItem?) { + guard let menu = menuItem else { return } + + guard let action, 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( + // We don't want to check missing `shift` for Ghostty configured shortcuts, + // because we know it's there when it needs to be + keyEquivalent: keyEquivalent.lowercased(), + 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 + } + } +} + +extension Ghostty.MenuShortcutManager { + /// Hashable key for a menu shortcut match, normalized for quick lookup. + struct MenuShortcutKey: Hashable { + private static let shortcutModifiers: NSEvent.ModifierFlags = [.shift, .control, .option, .command] + + let keyEquivalent: String + let modifiersRawValue: UInt + + init?(keyEquivalent: String, modifiers: NSEvent.ModifierFlags) { + let normalized = keyEquivalent.lowercased() + guard !normalized.isEmpty else { return nil } + var mods = modifiers.intersection(Self.shortcutModifiers) + if + keyEquivalent.lowercased() != keyEquivalent.uppercased(), + normalized.uppercased() == keyEquivalent { + // If key equivalent is case sensitive and + // it's originally uppercased, then we need to add `shift` to the modifiers + mods.insert(.shift) + } + self.keyEquivalent = normalized + self.modifiersRawValue = mods.rawValue + } + + init?(event: NSEvent) { + guard let keyEquivalent = event.charactersIgnoringModifiers else { return nil } + self.init(keyEquivalent: keyEquivalent, modifiers: event.modifierFlags) + } + } +} diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift index a8555e938a3..c5ab84124b1 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 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 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) + } } + .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.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index 47503dc0e80..a6ddf421915 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -446,18 +446,16 @@ extension Ghostty { } #endif .backport.onKeyPress(.return) { modifiers in - guard let surface = surfaceView.surface else { return .ignored } - let action = modifiers.contains(.shift) - ? "navigate_search:previous" - : "navigate_search:next" - ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) + if modifiers.contains(.shift) { + _ = surfaceView.navigateSearchToPrevious() + } else { + _ = surfaceView.navigateSearchToNext() + } return .handled } Button(action: { - guard let surface = surfaceView.surface else { return } - let action = "navigate_search:next" - ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) + _ = surfaceView.navigateSearchToNext() }, label: { Image(systemName: "chevron.up") }) @@ -623,8 +621,13 @@ extension Ghostty { } func updateOSView(_ scrollView: SurfaceScrollView, context: Context) { - // Nothing to do: SwiftUI automatically updates the frame size, and - // SurfaceScrollView handles the rest in response to that + // SwiftUI may defer frame updates under system load (e.g., memory + // pressure, heavy I/O) or when external window managers trigger rapid + // layout changes. When that happens, the scroll view's bounds can + // fall out of sync with the size reported by GeometryReader, causing + // the surface to render at stale dimensions. + guard scrollView.bounds.size != size else { return } + scrollView.needsLayout = true } #else func makeOSView(context: Context) -> SurfaceView { @@ -1277,4 +1280,28 @@ extension Ghostty.SurfaceView { self.needle = startSearch.needle ?? "" } } + + func navigateSearchToNext() -> Bool { + guard let surface = self.surface else { return false } + let action = "navigate_search:next" + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { +#if canImport(AppKit) + AppDelegate.logger.warning("action failed action=\(action)") +#endif + return false + } + return true + } + + func navigateSearchToPrevious() -> Bool { + guard let surface = self.surface else { return false } + let action = "navigate_search:previous" + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { +#if canImport(AppKit) + AppDelegate.logger.warning("action failed action=\(action)") +#endif + return false + } + return true + } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index ca4163c6dcb..f9448cd0d8e 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, @@ -646,11 +667,19 @@ extension Ghostty { // The clicked location in this window should be this view. let location = convert(event.locationInWindow, from: nil) - guard hitTest(location) == self else { return event } + // We should use window to perform hitTest here, + // because there could be some other overlays on top, like search bar + guard window.contentView?.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 +966,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 +985,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 +1005,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 +1080,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 +1275,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 } } @@ -1569,19 +1604,11 @@ extension Ghostty { } @IBAction func findNext(_ sender: Any?) { - guard let surface = self.surface else { return } - let action = "search:next" - if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { - AppDelegate.logger.warning("action failed action=\(action)") - } + _ = self.navigateSearchToNext() } @IBAction func findPrevious(_ sender: Any?) { - guard let surface = self.surface else { return } - let action = "search:previous" - if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { - AppDelegate.logger.warning("action failed action=\(action)") - } + _ = navigateSearchToPrevious() } @IBAction func findHide(_ sender: Any?) { @@ -1710,7 +1737,6 @@ extension Ghostty { let backgroundBlur: Ghostty.Config.BackgroundBlur let macosWindowShadow: Bool let windowTitleFontFamily: String? - let windowTheme: String let windowAppearance: NSAppearance? let scrollbar: Ghostty.Config.Scrollbar @@ -1720,7 +1746,6 @@ extension Ghostty { self.backgroundBlur = .disabled self.macosWindowShadow = true self.windowTitleFontFamily = nil - self.windowTheme = "auto" self.windowAppearance = nil self.scrollbar = .system } @@ -1731,7 +1756,6 @@ extension Ghostty { self.backgroundBlur = config.backgroundBlur self.macosWindowShadow = config.macosWindowShadow self.windowTitleFontFamily = config.windowTitleFontFamily - self.windowTheme = config.windowTheme ?? "auto" self.windowAppearance = .init(ghosttyConfig: config) self.scrollbar = config.scrollbar } @@ -1757,8 +1781,7 @@ extension Ghostty { let container = try decoder.container(keyedBy: CodingKeys.self) let uuid = UUID(uuidString: try container.decode(String.self, forKey: .uuid)) var config = Ghostty.SurfaceConfiguration() - let decodedPwd = try container.decode(String?.self, forKey: .pwd) - config.workingDirectory = RestorablePath.normalizedExistingDirectoryPath(decodedPwd) + config.workingDirectory = try container.decode(String?.self, forKey: .pwd) let savedTitle = try container.decodeIfPresent(String.self, forKey: .title) let isUserSetTitle = try container.decodeIfPresent(Bool.self, forKey: .isUserSetTitle) ?? false @@ -1776,8 +1799,7 @@ extension Ghostty { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - let persistedPwd = RestorablePath.normalizedExistingDirectoryPath(pwd) - try container.encode(persistedPwd, forKey: .pwd) + try container.encode(pwd, forKey: .pwd) try container.encode(id.uuidString, forKey: .uuid) try container.encode(title, forKey: .title) try container.encode(titleFromTerminal != nil, forKey: .isUserSetTitle) diff --git a/macos/Sources/Helpers/Extensions/KeyboardShortcut+Extension.swift b/macos/Sources/Helpers/Extensions/KeyboardShortcut+Extension.swift index 7891f12d70b..4c1b8dbcca2 100644 --- a/macos/Sources/Helpers/Extensions/KeyboardShortcut+Extension.swift +++ b/macos/Sources/Helpers/Extensions/KeyboardShortcut+Extension.swift @@ -22,6 +22,7 @@ extension KeyboardShortcut: @retroactive CustomStringConvertible { case .return: keyString = "⏎" case .escape: keyString = "⎋" case .delete: keyString = "⌫" + case .deleteForward: keyString = "⌦" case .space: keyString = "␣" case .tab: keyString = "⇥" case .upArrow: keyString = "▲" diff --git a/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift index ca338f10228..84553ed346c 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/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index 2546caa3810..6d055e5d4d7 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -14,6 +14,11 @@ extension NSView { return false } + + /// Returns true if this view is currently the first responder + var isFirstResponder: Bool { + window?.firstResponder === self + } } // MARK: Screenshot diff --git a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift index 3c5cbd23aea..46758a42db4 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/UserDefaults+Extension.swift b/macos/Sources/Helpers/Extensions/UserDefaults+Extension.swift new file mode 100644 index 00000000000..7cd0e12edce --- /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 5a9ce1d2c89..c7989b6faf3 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 29d1ab6d3f4..0308a02042e 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 0a1efae324e..1c114a2a502 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 @@ -94,6 +125,7 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { /// /// If this returns true then the event was handled by the coordinator. func handleRightMouseDown(_ event: NSEvent) -> Bool { + guard event.type == .rightMouseDown else { return false } if isMouseEventWithinEditor(event) { inlineTitleEditor?.rightMouseDown(with: event) return true @@ -126,10 +158,8 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { // Build the editor using title text and style derived from the tab's existing label. let editedTitle = delegate?.tabTitleEditor(self, titleFor: targetWindow) ?? targetWindow.title let sourceLabel = sourceTabTitleLabel(from: tabState.labels.map(\.label), matching: editedTitle) - let editorFrame = tabTitleEditorFrame(for: tabButton, sourceLabel: sourceLabel) - guard editorFrame.width >= 20, editorFrame.height >= 14 else { return false } - let editor = NSTextField(frame: editorFrame) + let editor = NSTextField(frame: .zero) editor.delegate = self editor.stringValue = editedTitle editor.alignment = sourceLabel?.alignment ?? .center @@ -161,13 +191,24 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { tabButton.layoutSubtreeIfNeeded() tabButton.displayIfNeeded() tabButton.addSubview(editor) + editor.translatesAutoresizingMaskIntoConstraints = false + let horizontalInset: CGFloat = 6 + let editorHeight = sourceLabel?.bounds.height ?? tabButton.bounds.height + NSLayoutConstraint.activate([ + editor.centerYAnchor.constraint(equalTo: tabButton.centerYAnchor), + editor.leadingAnchor.constraint(equalTo: tabButton.leadingAnchor, constant: horizontalInset), + editor.trailingAnchor.constraint(equalTo: tabButton.trailingAnchor, constant: -horizontalInset), + editor.heightAnchor.constraint(equalToConstant: editorHeight), + ]) CATransaction.commit() // 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 +239,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,25 +253,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) - } - - /// Chooses an editor frame that aligns with the tab title within the tab button. - private func tabTitleEditorFrame(for tabButton: NSView, sourceLabel: NSTextField?) -> NSRect { - let bounds = tabButton.bounds - let horizontalInset: CGFloat = 6 - var frame = bounds.insetBy(dx: horizontalInset, dy: 0) + guard let targetWindow else { return } - if let sourceLabel { - let labelFrame = tabButton.convert(sourceLabel.bounds, from: sourceLabel) - /// The `labelFrame.minY` value changes unexpectedly after double clicking selected text, - /// I don't know exactly why, but `tabButton.bounds` appears stable enough to calculate the correct position reliably. - frame.origin.y = bounds.midY - labelFrame.height * 0.5 - frame.size.height = labelFrame.height + if commit { + delegate?.tabTitleEditor(self, didCommitTitle: editedTitle, for: targetWindow) } - return frame.integral + // Notify delegate that editing is done so it can restore focus. + delegate?.tabTitleEditor(self, didFinishEditing: targetWindow) } /// Selects the best title label candidate from private tab button subviews. diff --git a/macos/Tests/Ghostty/ConfigTests.swift b/macos/Tests/Ghostty/ConfigTests.swift new file mode 100644 index 00000000000..a4b8472accd --- /dev/null +++ b/macos/Tests/Ghostty/ConfigTests.swift @@ -0,0 +1,249 @@ +import Testing +@testable import Ghostty +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 reloadConfig() throws { + let config = try TemporaryConfig("background-opacity = 0.5") + #expect(config.backgroundOpacity == 0.5) + + try config.reload("background-opacity = 0.7") + #expect(config.backgroundOpacity == 0.7) + } + + @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) + } + + // MARK: - Keybind + + @Test + func uppercasedLetterShouldBeNormalized() async throws { + let config = try TemporaryConfig(""" + keybind=cmd+L=goto_split:left + """) + let shortcut = try #require(config.keyboardShortcut(for: "goto_split:left")) + #expect(shortcut == .init("l", modifiers: [.command])) + + let config2 = try TemporaryConfig(""" + keybind=cmd+Ä=goto_split:left + """) + let shortcut2 = try #require(config2.keyboardShortcut(for: "goto_split:left")) + #expect(shortcut2 == .init("ä", modifiers: [.command])) + } + + @Test + func emptyConfigShouldBeHaveDefaultShortcut() async throws { + let config = try TemporaryConfig("") + let newWindow = try #require(config.keyboardShortcut(for: "new_window")) + #expect(newWindow == .init("n", modifiers: [.command])) + let gotoToNextSplit = try #require(config.keyboardShortcut(for: "goto_split:next")) + #expect(gotoToNextSplit == .init("]", modifiers: [.command])) + } +} diff --git a/macos/Tests/Ghostty/MenuShortcutManagerTests.swift b/macos/Tests/Ghostty/MenuShortcutManagerTests.swift new file mode 100644 index 00000000000..ab8806b9b16 --- /dev/null +++ b/macos/Tests/Ghostty/MenuShortcutManagerTests.swift @@ -0,0 +1,50 @@ +import AppKit +import Foundation +import Testing +@testable import Ghostty + +struct MenuShortcutManagerTests { + @Test(.bug("https://github.com/ghostty-org/ghostty/issues/779", id: 779)) + func unbindShouldDiscardDefault() async throws { + let config = try TemporaryConfig("keybind = super+d=unbind") + + let item = NSMenuItem(title: "Split Right", action: #selector(BaseTerminalController.splitRight(_:)), keyEquivalent: "d") + item.keyEquivalentModifierMask = .command + let manager = await Ghostty.MenuShortcutManager() + await manager.reset() + await manager.syncMenuShortcut(config, action: "new_split:right", menuItem: item) + + #expect(item.keyEquivalent.isEmpty) + #expect(item.keyEquivalentModifierMask.isEmpty) + + try config.reload("") + + await manager.reset() + await manager.syncMenuShortcut(config, action: "new_split:right", menuItem: item) + + #expect(item.keyEquivalent == "d") + #expect(item.keyEquivalentModifierMask == .command) + } + + @Test(.bug("https://github.com/ghostty-org/ghostty/issues/11396", id: 11396)) + func overrideDefault() async throws { + let config = try TemporaryConfig("keybind=super+h=goto_split:left") + + let hideItem = NSMenuItem(title: "Hide Ghostty", action: "hide:", keyEquivalent: "h") + hideItem.keyEquivalentModifierMask = .command + + let goToLeftItem = NSMenuItem(title: "Select Split Left", action: "splitMoveFocusLeft:", keyEquivalent: "") + + let manager = await Ghostty.MenuShortcutManager() + await manager.reset() + + await manager.syncMenuShortcut(config, action: nil, menuItem: hideItem) + await manager.syncMenuShortcut(config, action: "goto_split:left", menuItem: goToLeftItem) + + #expect(hideItem.keyEquivalent.isEmpty) + #expect(hideItem.keyEquivalentModifierMask.isEmpty) + + #expect(goToLeftItem.keyEquivalent == "h") + #expect(goToLeftItem.keyEquivalentModifierMask == .command) + } +} diff --git a/macos/Tests/Ghostty/NormalizedMenuShortcutKeyTests.swift b/macos/Tests/Ghostty/NormalizedMenuShortcutKeyTests.swift new file mode 100644 index 00000000000..5fb984a2f39 --- /dev/null +++ b/macos/Tests/Ghostty/NormalizedMenuShortcutKeyTests.swift @@ -0,0 +1,93 @@ +import AppKit +import Testing +@testable import Ghostty + +@Suite +struct NormalizedMenuShortcutKeyTests { + typealias Key = Ghostty.MenuShortcutManager.MenuShortcutKey + + // MARK: - Init from keyEquivalent + modifiers + + @Test func returnsNilForEmptyKeyEquivalent() { + let key = Key(keyEquivalent: "", modifiers: .command) + #expect(key == nil) + } + + @Test func lowercasesKeyEquivalent() { + let key = Key(keyEquivalent: "A", modifiers: .command) + #expect(key?.keyEquivalent == "a") + } + + @Test func stripsNonShortcutModifiers() { + // .capsLock and .function should be stripped + let key = Key(keyEquivalent: "c", modifiers: [.command, .capsLock, .function]) + let expected = Key(keyEquivalent: "c", modifiers: .command) + #expect(key == expected) + } + + @Test func preservesShortcutModifiers() { + let key = Key(keyEquivalent: "c", modifiers: [.shift, .control, .option, .command]) + let allMods: NSEvent.ModifierFlags = [.shift, .control, .option, .command] + #expect(key?.modifiersRawValue == allMods.rawValue) + } + + @Test func uppercaseLetterInsertsShift() { + // "A" is uppercase and case-sensitive, so .shift should be added + let key = Key(keyEquivalent: "A", modifiers: .command) + let expected = NSEvent.ModifierFlags([.command, .shift]).rawValue + #expect(key?.modifiersRawValue == expected) + } + + @Test func lowercaseLetterDoesNotInsertShift() { + let key = Key(keyEquivalent: "a", modifiers: .command) + let expected = NSEvent.ModifierFlags.command.rawValue + #expect(key?.modifiersRawValue == expected) + } + + @Test func nonCaseSensitiveCharacterDoesNotInsertShift() { + // "1" is not case-sensitive (uppercased == lowercased is false for digits, + // but "1".uppercased() == "1".lowercased() == "1" so isCaseSensitive is false) + let key = Key(keyEquivalent: "1", modifiers: .command) + let expected = NSEvent.ModifierFlags.command.rawValue + #expect(key?.modifiersRawValue == expected) + } + + // MARK: - Equality / Hashing + + @Test func sameKeyAndModsAreEqual() { + let a = Key(keyEquivalent: "c", modifiers: .command) + let b = Key(keyEquivalent: "c", modifiers: .command) + #expect(a == b) + } + + @Test func uppercaseAndLowercaseWithShiftAreEqual() { + // "C" with .command should equal "c" with [.command, .shift] + // because the uppercase init auto-inserts .shift + let fromUpper = Key(keyEquivalent: "C", modifiers: .command) + let fromLowerWithShift = Key(keyEquivalent: "c", modifiers: [.command, .shift]) + #expect(fromUpper == fromLowerWithShift) + } + + @Test func differentKeysAreNotEqual() { + let a = Key(keyEquivalent: "a", modifiers: .command) + let b = Key(keyEquivalent: "b", modifiers: .command) + #expect(a != b) + } + + @Test func differentModifiersAreNotEqual() { + let a = Key(keyEquivalent: "c", modifiers: .command) + let b = Key(keyEquivalent: "c", modifiers: .option) + #expect(a != b) + } + + @Test func canBeUsedAsDictionaryKey() { + let key = Key(keyEquivalent: "c", modifiers: .command)! + var dict: [Key: String] = [:] + dict[key] = "copy" + #expect(dict[key] == "copy") + + // Same key created separately should find the same entry + let key2 = Key(keyEquivalent: "c", modifiers: .command)! + #expect(dict[key2] == "copy") + } +} diff --git a/macos/Tests/Helpers/TemporaryConfig.swift b/macos/Tests/Helpers/TemporaryConfig.swift new file mode 100644 index 00000000000..f3e18dc5327 --- /dev/null +++ b/macos/Tests/Helpers/TemporaryConfig.swift @@ -0,0 +1,45 @@ +import Foundation +@testable import Ghostty +@testable import GhosttyKit + +/// Create a temporary config file and delete it when this is deallocated +class TemporaryConfig: Ghostty.Config { + enum Error: Swift.Error { + case failedToLoad + } + + 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)) + } + + func reload(_ newConfigText: String?, finalize: Bool = true) throws { + if let newConfigText { + try newConfigText.write(to: temporaryFile, atomically: true, encoding: .utf8) + } + guard let cfg = Self.loadConfig(at: temporaryFile.path(), finalize: finalize) else { + throw Error.failedToLoad + } + clone(config: cfg) + } + + var optionalAutoUpdateChannel: Ghostty.AutoUpdateChannel? { + guard let config = self.config else { return nil } + var v: UnsafePointer? + 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/Tests/Terminal/TerminalViewContainerTests.swift b/macos/Tests/Terminal/TerminalViewContainerTests.swift index 8d94bcd7198..e3df8483ec9 100644 --- a/macos/Tests/Terminal/TerminalViewContainerTests.swift +++ b/macos/Tests/Terminal/TerminalViewContainerTests.swift @@ -7,7 +7,7 @@ import SwiftUI import Testing -@testable import Ghostree +@testable import Ghostty class MockTerminalViewContainer: TerminalViewContainer { var _windowCornerRadius: CGFloat? diff --git a/nix/devShell.nix b/nix/devShell.nix index 709e22c0729..da8dfbadfe9 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -8,6 +8,7 @@ appstream, flatpak-builder, gdb, + cmake, #, glxinfo # unused ncurses, nodejs, @@ -100,6 +101,7 @@ in packages = [ # For builds + cmake doxygen jq llvmPackages_latest.llvm diff --git a/nix/libghostty-vt.nix b/nix/libghostty-vt.nix new file mode 100644 index 00000000000..35c3cbc3679 --- /dev/null +++ b/nix/libghostty-vt.nix @@ -0,0 +1,245 @@ +{ + callPackage, + git, + lib, + llvmPackages, + pkg-config, + runCommand, + stdenv, + testers, + versionCheckHook, + zig_0_15, + revision ? "dirty", + optimize ? "Debug", + simd ? true, +}: +stdenv.mkDerivation (finalAttrs: { + pname = "libghostty-vt"; + version = "0.1.0-dev+${revision}-nix"; + + # 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 + # + # NOTE: as of the current moment only linux files are provided, + # since darwin support is not finished + src = lib.fileset.toSource { + root = ../.; + fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) ( + lib.fileset.unions [ + ../include + ../pkg + ../src + ../vendor + ../build.zig + ../build.zig.zon + ../build.zig.zon.nix + ] + ); + }; + + deps = callPackage ../build.zig.zon.nix {name = "${finalAttrs.pname}-cache-${finalAttrs.version}";}; + + nativeBuildInputs = [ + git + pkg-config + zig_0_15 + ]; + + buildInputs = []; + + doCheck = false; + dontSetZigDefaultFlags = true; + + zigBuildFlags = [ + "--system" + "${finalAttrs.deps}" + "-Dlib-version-string=${finalAttrs.version}" + "-Dcpu=baseline" + "-Doptimize=${optimize}" + "-Dapp-runtime=none" + "-Demit-lib-vt=true" + "-Dsimd=${lib.boolToString simd}" + ]; + zigCheckFlags = finalAttrs.zigBuildFlags ++ ["test-lib-vt"]; + + outputs = [ + "out" + "dev" + ]; + + postInstall = '' + mkdir -p "$dev/lib" + mv "$out/lib/libghostty-vt.a" "$dev/lib" + rm "$out/lib/libghostty-vt.so" + mv "$out/include" "$dev" + mv "$out/share" "$dev" + + ln -sf "$out/lib/libghostty-vt.so.${lib.versions.major finalAttrs.version}" "$dev/lib/libghostty-vt.so" + ''; + + postFixup = '' + substituteInPlace "$dev/share/pkgconfig/libghostty-vt.pc" \ + --replace-fail "$out" "$dev" + substituteInPlace "$dev/share/pkgconfig/libghostty-vt-static.pc" \ + --replace-fail "$out" "$dev" + ''; + + passthru.tests = { + sanity-check = let + version = "${lib.versions.major finalAttrs.version}.${lib.versions.minor finalAttrs.version}.${lib.versions.patch finalAttrs.version}"; + in + runCommand "sanity-check" {} (builtins.concatStringsSep "\n" [ + '' + ${lib.getExe' stdenv.cc "nm"} "${finalAttrs.finalPackage}/lib/libghostty-vt.so.${version}" | grep -q 'T ghostty_terminal_new' + ${lib.getExe' stdenv.cc "nm"} "${finalAttrs.finalPackage.dev}/lib/libghostty-vt.a" | grep -q 'T ghostty_terminal_new' + '' + ( + lib.optionalString simd + '' + ${lib.getExe' stdenv.cc "nm"} "${finalAttrs.finalPackage.dev}/lib/libghostty-vt.a" | grep -q 'T .*simdutf' + ${lib.getExe' stdenv.cc "nm"} "${finalAttrs.finalPackage.dev}/lib/libghostty-vt.a" | grep -q 'T .*3hwy' + '' + ) + '' + touch "$out" + '' + ]); + pkg-config = testers.hasPkgConfigModules { + package = finalAttrs.finalPackage.dev; + }; + pkg-config-libs = + runCommand "pkg-config-libs" { + nativeBuildInputs = [pkg-config]; + } '' + export PKG_CONFIG_PATH="${finalAttrs.finalPackage.dev}/share/pkgconfig" + + pkg-config --libs --static libghostty-vt | grep -q -- '-lghostty-vt' + pkg-config --libs --static libghostty-vt-static | grep -q -- '${finalAttrs.finalPackage.dev}/lib/libghostty-vt.a' + + touch "$out" + ''; + build-with-shared = stdenv.mkDerivation { + name = "build-with-shared"; + src = ./test-src; + doInstallCheck = true; + nativeBuildInputs = [pkg-config]; + buildInputs = [finalAttrs.finalPackage]; + buildPhase = '' + runHook preBuildHooks + + cc -o test test_libghostty_vt.c \ + ''$(pkg-config --cflags --libs libghostty-vt) \ + -Wl,-rpath,"${finalAttrs.finalPackage}/lib" + + runHook postBuildHooks + ''; + installPhase = '' + runHook preInstallHooks + + mkdir -p "$out/bin"; + cp -a test "$out/bin/test"; + + runHook postInstallHooks + ''; + installCheckPhase = '' + runHook preInstallCheckHooks + + "$out/bin/test" | grep -q "SIMD: ${ + if simd + then "yes" + else "no" + }" + ldd "$out/bin/test" 2>/dev/null | grep -q libghostty-vt + + runHook postInstallCheckHooks + ''; + meta = { + mainProgram = "test"; + }; + }; + build-with-static = stdenv.mkDerivation { + name = "build-with-static"; + src = ./test-src; + doInstallCheck = true; + nativeBuildInputs = [pkg-config]; + buildInputs = [finalAttrs.finalPackage llvmPackages.libcxxClang]; + buildPhase = '' + runHook preBuildHooks + + cc -o test test_libghostty_vt.c \ + ''$(pkg-config --cflags --libs --static libghostty-vt-static) + + runHook postBuildHooks + ''; + installPhase = '' + runHook preInstallHooks + + mkdir -p "$out/bin"; + cp -a test "$out/bin/test"; + + runHook postInstallHooks + ''; + installCheckPhase = '' + runHook preInstallCheckHooks + + "$out/bin/test" | grep -q "SIMD: ${ + if simd + then "yes" + else "no" + }" + ! ldd "$out/bin/test" 2>/dev/null | grep -q libghostty-vt + + runHook postInstallCheckHooks + ''; + meta = { + mainProgram = "test"; + }; + }; + build-example-c-vt-build-info = stdenv.mkDerivation { + name = "build-example-c-vt-build-info"; + version = finalAttrs.version; + src = ../example/c-vt-build-info/src; + doInstallCheck = true; + nativeBuildInputs = [pkg-config]; + nativeInstallCheckInputs = [versionCheckHook]; + buildInputs = [finalAttrs.finalPackage]; + buildPhase = '' + runHook preBuildHooks + + cc -o test main.c \ + ''$(pkg-config --cflags --libs libghostty-vt) \ + -Wl,-rpath,"${finalAttrs.finalPackage}/lib" + + runHook postBuildHooks + ''; + installPhase = '' + runHook preInstallHooks + + mkdir -p "$out/bin"; + cp -a test "$out/bin/test"; + + runHook postInstallHooks + ''; + installCheckPhase = '' + runHook preInstallCheckHooks + + ldd "$out/bin/test" 2>/dev/null | grep -q libghostty-vt + + runHook postInstallCheckHooks + ''; + meta = { + mainProgram = "test"; + }; + }; + }; + + meta = { + homepage = "https://ghostty.org"; + license = lib.licenses.mit; + platforms = zig_0_15.meta.platforms; + pkgConfigModules = [ + "libghostty-vt" + "libghostty-vt-static" + ]; + }; +}) diff --git a/nix/package.nix b/nix/package.nix index 391c9da059b..fd952c9de1f 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -30,7 +30,7 @@ in stdenv.mkDerivation (finalAttrs: { pname = "ghostty"; - version = "1.3.0"; + version = "1.3.2-dev+${revision}-nix"; # 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 @@ -86,7 +86,7 @@ in zigBuildFlags = [ "--system" "${finalAttrs.deps}" - "-Dversion-string=${finalAttrs.version}-${revision}-nix" + "-Dversion-string=${finalAttrs.version}" "-Dgtk-x11=${lib.boolToString enableX11}" "-Dgtk-wayland=${lib.boolToString enableWayland}" "-Dcpu=baseline" diff --git a/nix/test-src/test_libghostty_vt.c b/nix/test-src/test_libghostty_vt.c new file mode 100644 index 00000000000..dc2586299d4 --- /dev/null +++ b/nix/test-src/test_libghostty_vt.c @@ -0,0 +1,9 @@ +#include +#include +int main(void) { + bool simd = false; + GhosttyResult r = ghostty_build_info(GHOSTTY_BUILD_INFO_SIMD, &simd); + if (r != GHOSTTY_SUCCESS) return 1; + printf("SIMD: %s\n", simd ? "yes" : "no"); + return 0; +} diff --git a/pkg/dcimgui/build.zig b/pkg/dcimgui/build.zig index 2a13898342a..01a5879d6ae 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 ecb22cb6c40..b85310a5b1c 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 d4f74b7ee80..cd949e357b1 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 c41e052177a..1dc82a6e304 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 f7848ea947f..a1531323135 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 8696c020346..6d8f3be70ad 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/bridge.cpp b/pkg/highway/bridge.cpp index 1e86c4934d3..8f607f3e633 100644 --- a/pkg/highway/bridge.cpp +++ b/pkg/highway/bridge.cpp @@ -1,8 +1,89 @@ +#include +#include #include + +#include #include +#include +#include + +namespace hwy { +namespace { + +// Highway's upstream abort.cc pulls in libc++ even when the rest of the +// library is compiled with HWY_NO_LIBCXX. Ghostty only needs Highway's dynamic +// dispatch/runtime target selection, so we provide the tiny Warn/Abort surface +// that targets.cc/per_target.cc expect and keep the package free of libc++. +WarnFunc g_warn_func = nullptr; +AbortFunc g_abort_func = nullptr; + +// Mirror the upstream behavior closely enough for Highway's internal callers: +// format into a fixed buffer, fall back to a generic error if formatting fails, +// and then dispatch to either the registered hook or stderr. +void format_message(const char* format, va_list args, char* buffer, size_t size) { + const int written = vsnprintf(buffer, size, format, args); + if (written < 0) { + snprintf(buffer, size, "%s", "failed to format highway message"); + } +} + +} // namespace + +WarnFunc& GetWarnFunc() { + return g_warn_func; +} + +AbortFunc& GetAbortFunc() { + return g_abort_func; +} + +WarnFunc SetWarnFunc(WarnFunc func) { + // Highway documents these setters as thread-safe. Using the compiler builtin + // keeps that guarantee without depending on std::atomic. + return __atomic_exchange_n(&g_warn_func, func, __ATOMIC_SEQ_CST); +} + +AbortFunc SetAbortFunc(AbortFunc func) { + return __atomic_exchange_n(&g_abort_func, func, __ATOMIC_SEQ_CST); +} + +void Warn(const char* file, int line, const char* format, ...) { + char message[1024]; + va_list args; + va_start(args, format); + format_message(format, args, message, sizeof(message)); + va_end(args); + + if (WarnFunc func = g_warn_func) { + func(file, line, message); + return; + } + + fprintf(stderr, "%s:%d: %s\n", file, line, message); +} + +HWY_NORETURN void Abort(const char* file, int line, const char* format, ...) { + char message[1024]; + va_list args; + va_start(args, format); + format_message(format, args, message, sizeof(message)); + va_end(args); + + if (AbortFunc func = g_abort_func) { + func(file, line, message); + } else { + fprintf(stderr, "%s:%d: %s\n", file, line, message); + } + + abort(); +} + +} // namespace hwy extern "C" { +// Zig reads HWY_SUPPORTED_TARGETS via this C shim so it can keep its target +// enum in sync with the vendored Highway build without parsing C++ headers. int64_t hwy_supported_targets() { return HWY_SUPPORTED_TARGETS; } diff --git a/pkg/highway/build.zig b/pkg/highway/build.zig index b6e188b13ae..6ed721562fc 100644 --- a/pkg/highway/build.zig +++ b/pkg/highway/build.zig @@ -20,7 +20,7 @@ pub fn build(b: *std.Build) !void { }), .linkage = .static, }); - lib.linkLibCpp(); + lib.linkLibC(); if (upstream_) |upstream| { lib.addIncludePath(upstream.path("")); module.addIncludePath(upstream.path("")); @@ -39,6 +39,10 @@ pub fn build(b: *std.Build) !void { var flags: std.ArrayList([]const u8) = .empty; defer flags.deinit(b.allocator); try flags.appendSlice(b.allocator, &.{ + // Highway can avoid libc++ entirely as long as all users compile + // against the headers with the same define. + "-DHWY_NO_LIBCXX", + // Avoid changing binaries based on the current time and date. "-Wno-builtin-macro-redefined", "-D__DATE__=\"redacted\"", @@ -95,13 +99,11 @@ pub fn build(b: *std.Build) !void { .root = upstream.path(""), .flags = flags.items, .files = &.{ - "hwy/abort.cc", - "hwy/aligned_allocator.cc", - "hwy/nanobenchmark.cc", + // These provide the runtime target selection used by + // HWY_DYNAMIC_DISPATCH. The benchmark, timer, print, and + // aligned allocator support files are unused by Ghostty. "hwy/per_target.cc", - "hwy/print.cc", "hwy/targets.cc", - "hwy/timer.cc", }, }); lib.installHeadersDirectory( diff --git a/pkg/oniguruma/build.zig b/pkg/oniguruma/build.zig index ea39b481457..efc013b4344 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/simdutf/build.zig b/pkg/simdutf/build.zig index 8dcd141c1f9..e132507a1a4 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 f85e74adf83..72ce61eb60f 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 08efb4ac862..da65f5058ad 100644 --- a/pkg/utfcpp/build.zig +++ b/pkg/utfcpp/build.zig @@ -12,7 +12,7 @@ pub fn build(b: *std.Build) !void { }), .linkage = .static, }); - lib.linkLibCpp(); + lib.linkLibC(); 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 246ab1bcbad..6bde60ec790 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/id.po b/po/id.po index e5660440ad7..1549aaf347f 100644 --- a/po/id.po +++ b/po/id.po @@ -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=2; plural=(n != 1);\n" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" diff --git a/po/lt.po b/po/lt.po index ba4995ddc43..bbcadd8b400 100644 --- a/po/lt.po +++ b/po/lt.po @@ -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" diff --git a/po/zh_CN.po b/po/zh_CN.po index 8e7e241fc7b..4a48df5d7da 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -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" diff --git a/po/zh_TW.po b/po/zh_TW.po index cacdc8acbb7..a26103911bb 100644 --- a/po/zh_TW.po +++ b/po/zh_TW.po @@ -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" diff --git a/snap/local/launcher b/snap/local/launcher index 6057881b3cb..306ee4d8cdb 100755 --- a/snap/local/launcher +++ b/snap/local/launcher @@ -45,9 +45,11 @@ export __EGL_VENDOR_LIBRARY_DIRS=${__EGL_VENDOR_LIBRARY_DIRS:+$__EGL_VENDOR_LIBR 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/ -export XDG_DATA_DIRS=${XDG_DATA_DIRS:+$XDG_DATA_DIRS:}${SNAP}/usr/share +export XDG_DATA_DIRS=${SNAP}/usr/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS} export XLOCALEDIR="${SNAP}/usr/share/X11/locale" export GTK_PATH="$SNAP/usr/lib/$ARCH/gtk-4.0" +export GIO_MODULE_DIR="$SNAP/usr/lib/$ARCH/gio/modules" +unset GIO_EXTRA_MODULES # Gdk-pixbuf loaders mkdir -p "$SNAP_USER_COMMON/.cache" diff --git a/src/App.zig b/src/App.zig index 33c8318dbce..5446f4bf273 100644 --- a/src/App.zig +++ b/src/App.zig @@ -524,6 +524,16 @@ fn hasSurface(self: *const App, surface: *const Surface) bool { return false; } +/// Search for a surface by a 64 bit unique ID. +pub fn findSurfaceByID(self: *const App, id: u64) ?*Surface { + for (self.surfaces.items) |v| { + const surface: *Surface = v.core(); + if (surface.id == id) return surface; + } + + return null; +} + fn hasRtSurface(self: *const App, surface: *apprt.Surface) bool { for (self.surfaces.items) |v| { if (v == surface) return true; diff --git a/src/Surface.zig b/src/Surface.zig index a3691b53e77..dfc3a50ea13 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); @@ -53,6 +54,13 @@ pub const min_window_height_cells: u32 = 4; /// given time. `activate_key_table` calls after this are ignored. const max_active_key_tables = 8; +/// Unique ID used to identify this surface for IPC purposes. It is +/// exposed to the commands running in surfaces as the environment variable +/// GHOSTTY_SURFACE_ID. It must not be zero as zero is used to incicate a null +/// value when communicating an ID over DBus as DBus does not allow null/maybe +/// values. +id: u64, + /// Allocator alloc: Allocator, @@ -324,7 +332,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 +544,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; } @@ -578,6 +586,13 @@ pub fn init( errdefer io_thread.deinit(); self.* = .{ + .id = id: { + while (true) { + const candidate = std.crypto.random.int(u64); + if (candidate == 0) continue; + break :id candidate; + } + }, .alloc = alloc, .app = app, .rt_app = rt_app, @@ -631,6 +646,12 @@ pub fn init( // don't leak GHOSTTY_LOG to any subprocesses env.remove("GHOSTTY_LOG"); + var buf: [18]u8 = undefined; + try env.put( + "GHOSTTY_SURFACE_ID", + std.fmt.bufPrint(&buf, "0x{x:0>16}", .{self.id}) catch unreachable, + ); + // Initialize our IO backend var io_exec = try termio.Exec.init(alloc, .{ .command = command, @@ -639,7 +660,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 +2483,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 @@ -3261,7 +3282,11 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { crash.sentry.thread_state = self.crashThreadState(); defer crash.sentry.thread_state = null; - // If our focus state is the same we do nothing. + // Always update the app focused surface, otherwise we miss + // the first surface created. + if (focused) self.app.focusSurface(self); + + // If our focus state is unchanged we do nothing else. if (self.focused == focused) return; self.focused = focused; @@ -3270,10 +3295,7 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { .focus = focused, }, .{ .forever = {} }); - if (focused) { - // Notify our app if we gained focus. - self.app.focusSurface(self); - } else unfocused: { + if (!focused) unfocused: { // If we lost focus and we have a keypress, then we want to send a key // release event for it. Depending on the apprt, this CAN result in // duplicate key release events, but that is better than not sending @@ -3518,7 +3540,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 +3548,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 +3598,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 +3607,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 +3617,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 - - // 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; + // 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, + ); - // 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; - } - } - - // This format reports X/Y - const viewport_point = self.posToViewport(pos.x, pos.y); - - // 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; - } + break :pressed false; + }; - // Motion adds another bit - if (action == .motion) acc += 32; + // Keep track of our last reported viewport cell for event + // deduplication. + opts.last_cell = &self.mouse.event_point; - 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); - }, - - .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); - }, - - .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); + 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, }, - - .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); + }, 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_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 +3859,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 +4596,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 +4824,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 +5338,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 +6364,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 55e80a70063..f6865af83dc 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 54d5472c621..519a35f2bd1 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; } @@ -1664,7 +1675,7 @@ pub const CAPI = struct { return true; } - export fn ghostty_surface_free_text(ptr: *Text) void { + export fn ghostty_surface_free_text(_: *Surface, ptr: *Text) void { ptr.deinit(); } diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index 36a9290fbc2..eb33c4e4db5 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 6c731033946..39c13c19d42 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 bcece4caabf..c50ea8cd5ca 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( \\ \\ - , .{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 { \\ {s} \\ , - .{ alias, app_id, source }, + .{ alias, build_info.base_application_id, source }, ); } @@ -199,7 +197,7 @@ fn genIcons(writer: *std.Io.Writer) !void { \\ {s} \\ , - .{ 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( \\ \\ - , .{prefix}); + , .{build_info.resource_path}); const cwd = std.fs.cwd(); inline for (css) |name| { @@ -249,7 +247,7 @@ fn genUi( try writer.print( \\ \\ - , .{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 00000000000..fc6478d8103 --- /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 c3ff51e0f76..873674cecd6 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(); @@ -1719,11 +1736,11 @@ pub const Application = extern struct { log.debug("new-window argument: {d} {s}", .{ i, str }); if (e_seen) { - const cpy = alloc.dupeZ(u8, str) catch |err| { + const duplicated = alloc.dupeZ(u8, str) catch |err| { log.warn("unable to duplicate argument {d} {s}: {t}", .{ i, str, err }); break :overrides; }; - args.append(alloc, cpy) catch |err| { + args.append(alloc, duplicated) catch |err| { log.warn("unable to append argument {d} {s}: {t}", .{ i, str, err }); break :overrides; }; @@ -1794,29 +1811,18 @@ pub const Application = extern struct { const t = glib.ext.VariantType.newFor(u64); defer glib.VariantType.free(t); - // Make sure that we've receiived a u64 from the system. + // Make sure that we've received a u64 from the system. if (glib.Variant.isOfType(parameter, t) == 0) { return; } - // Convert that u64 to pointer to a core surface. A value of zero - // means that there was no target surface for the notification so - // we don't focus any surface. - // - // This is admittedly SUPER SUS and we should instead do what we - // do on macOS which is generate a UUID per surface and then pass - // that around. But, we do validate the pointer below so at worst - // this may result in focusing the wrong surface if the pointer was - // reused for a surface. - const ptr_int = parameter.getUint64(); - if (ptr_int == 0) return; - const surface: *CoreSurface = @ptrFromInt(ptr_int); - - // Send a message through the core app mailbox rather than presenting the - // surface directly so that it can validate that the surface pointer is - // valid. We could get an invalid pointer if a desktop notification outlives - // a Ghostty instance and a new one starts up, or there are multiple Ghostty - // instances running. + // Convert the u64 to a core surface by using it as a surface ID. + // A value of zero means that there was no target surface for the + // notification so we don't focus any surface. + const surface_id = parameter.getUint64(); + if (surface_id == 0) return; + const surface = self.core().findSurfaceByID(surface_id) orelse return; + _ = self.core().mailbox.push( .{ .surface_message = .{ @@ -1871,6 +1877,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 +2332,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( @@ -2430,7 +2451,7 @@ const Action = struct { }; defer config.unref(); - // Update the proper target. This will trigger a `confige_change` + // Update the proper target. This will trigger a `config_change` // apprt action which will propagate the config properly to our // property system. switch (target) { @@ -2545,6 +2566,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 9c79f2712d8..6101e82e807 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 8ce9ac1d18e..179c779d7be 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); @@ -1013,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 @@ -1729,7 +1738,7 @@ pub const Surface = extern struct { defer icon.unref(); notification.setIcon(icon.as(gio.Icon)); - const pointer = glib.Variant.newUint64(@intFromPtr(core_surface)); + const pointer = glib.Variant.newUint64(core_surface.id); notification.setDefaultActionAndTargetValue( "app.present-surface", pointer, @@ -2449,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); } } @@ -2715,22 +2698,25 @@ pub const Surface = extern struct { } fn ecFocusEnter(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { - const priv = self.private(); - priv.focused = true; - priv.im_context.as(gtk.IMContext).focusIn(); - _ = glib.idleAddOnce(idleFocus, self.ref()); - self.as(gobject.Object).notifyByPspec(properties.focused.impl.param_spec); - - // Bell stops ringing as soon as we gain focus - self.setBellRinging(false); + self.updateFocus(true); } fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { + self.updateFocus(false); + } + + fn updateFocus(self: *Self, focused: bool) void { const priv = self.private(); - priv.focused = false; - priv.im_context.as(gtk.IMContext).focusOut(); + priv.focused = focused; + + const ctx = priv.im_context.as(gtk.IMContext); + if (focused) ctx.focusIn() else ctx.focusOut(); + _ = glib.idleAddOnce(idleFocus, self.ref()); self.as(gobject.Object).notifyByPspec(properties.focused.impl.param_spec); + + // Bell stops ringing as soon as we gain focus + if (focused) self.setBellRinging(false); } /// The focus callback must be triggered on an idle loop source because @@ -3309,10 +3295,13 @@ pub const Surface = extern struct { // Store our cached size const priv = self.private(); - priv.size = .{ + + const new_size: apprt.SurfaceSize = .{ .width = @intCast(width), .height = @intCast(height), }; + const changed = !priv.size.eql(&new_size); + priv.size = new_size; // If our surface is realize, we send callbacks. if (priv.core_surface) |surface| { @@ -3322,12 +3311,13 @@ pub const Surface = extern struct { log.warn("error in content scale callback err={}", .{err}); }; - surface.sizeCallback(priv.size) catch |err| { - log.warn("error in size callback err={}", .{err}); - }; - - // Setup our resize overlay if configured - self.resizeOverlaySchedule(); + if (changed) { + surface.sizeCallback(new_size) catch |err| { + log.warn("error in size callback err={}", .{err}); + }; + // Setup our resize overlay if configured + self.resizeOverlaySchedule(); + } return; } @@ -3381,12 +3371,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( @@ -3411,6 +3409,8 @@ pub const Surface = extern struct { .{}, null, ); + + self.updateFocus(priv.focused); } fn resizeOverlaySchedule(self: *Self) void { @@ -3465,35 +3465,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 "<>", - 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/surface_scrolled_window.zig b/src/apprt/gtk/class/surface_scrolled_window.zig index 488fdb3f4e6..dd4f17d1163 100644 --- a/src/apprt/gtk/class/surface_scrolled_window.zig +++ b/src/apprt/gtk/class/surface_scrolled_window.zig @@ -2,6 +2,7 @@ const std = @import("std"); const adw = @import("adw"); const gobject = @import("gobject"); const gtk = @import("gtk"); +const gtk_version = @import("../gtk_version.zig"); const gresource = @import("../build/gresource.zig"); const Common = @import("../class.zig").Common; @@ -59,11 +60,29 @@ pub const SurfaceScrolledWindow = extern struct { config: ?*Config = null, config_binding: ?*gobject.Binding = null, surface: ?*Surface = null, + scrolled_window: *gtk.ScrolledWindow, pub var offset: c_int = 0; }; fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); + if (gtk_version.runtimeUntil(4, 20, 1)) self.disableKineticScroll(); + } + + fn disableKineticScroll(self: *Self) void { + // Until gtk 4.20.1 trackpads have kinetic scrolling behavior regardless + // of `Gtk.ScrolledWindow.kinetic_scrolling`. As a workaround, disable + // EventControllerScroll.kinetic + const controllers = self.private().scrolled_window.as(gtk.Widget).observeControllers(); + defer controllers.unref(); + var i: c_uint = 0; + while (controllers.getObject(i)) |obj| : (i += 1) { + defer obj.unref(); + const controller = gobject.ext.cast(gtk.EventControllerScroll, obj) orelse continue; + var flags = controller.getFlags(); + flags.kinetic = false; + controller.setFlags(flags); + } } fn dispose(self: *Self) callconv(.c) void { @@ -189,6 +208,7 @@ pub const SurfaceScrolledWindow = extern struct { // Bindings class.bindTemplateCallback("scrollbar_policy", &closureScrollbarPolicy); class.bindTemplateCallback("notify_surface", &propSurface); + class.bindTemplateChildPrivate("scrolled_window", .{}); // Properties gobject.ext.registerProperties(class, &.{ diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index c01cad618d8..bf2a2fe7c82 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 00000000000..1015c933f0e --- /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 "<>", + 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 00000000000..3e51042d608 --- /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 00000000000..97aa013e55a --- /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/resize-overlay.blp b/src/apprt/gtk/ui/1.2/resize-overlay.blp index 5c4a94a8fd8..a05986b4a49 100644 --- a/src/apprt/gtk/ui/1.2/resize-overlay.blp +++ b/src/apprt/gtk/ui/1.2/resize-overlay.blp @@ -4,6 +4,10 @@ using Adw 1; // type in zig-gobject. template $GhosttyResizeOverlay: Adw.Bin { visible: false; + can-focus: false; + can-target: false; + focusable: false; + focus-on-click: false; duration: 750; first-delay: 250; overlay-halign: center; @@ -16,10 +20,12 @@ template $GhosttyResizeOverlay: Adw.Bin { "resize-overlay", ] + can-focus: false; + can-target: false; focusable: false; focus-on-click: false; - justify: center; selectable: false; + justify: center; halign: bind template.overlay-halign; valign: bind template.overlay-valign; } diff --git a/src/apprt/gtk/ui/1.5/surface-scrolled-window.blp b/src/apprt/gtk/ui/1.5/surface-scrolled-window.blp index 722c4427b3f..75582a89ff1 100644 --- a/src/apprt/gtk/ui/1.5/surface-scrolled-window.blp +++ b/src/apprt/gtk/ui/1.5/surface-scrolled-window.blp @@ -4,7 +4,7 @@ using Adw 1; template $GhostttySurfaceScrolledWindow: Adw.Bin { notify::surface => $notify_surface(); - Gtk.ScrolledWindow { + Gtk.ScrolledWindow scrolled_window { hscrollbar-policy: never; vscrollbar-policy: bind $scrollbar_policy(template.config) as ; } diff --git a/src/apprt/gtk/winproto.zig b/src/apprt/gtk/winproto.zig index 3c1da2b2185..d409d6c2688 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 00000000000..f3041dae087 --- /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 ed69736f81b..950ee0f373a 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 ec02fbee536..12c7fb8a218 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 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 wl = wayland.client.wl; -const org = wayland.client.org; -const xdg = wayland.client.xdg; +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 00000000000..83052cbebc7 --- /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 1e73c613908..8109959dafb 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; - } - 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; + 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 (self.last_applied_blur_region) |last| { - if (std.meta.eql(self.blur_region, last)) return; + if (region.eql(self.blur_region)) { + region.deinit(self.alloc); + return; } - log.debug("set blur={}, window xid={}, region={}", .{ - blur, - self.x11_surface.getXid(), - self.blur_region, - }); + if (region.slices.items.len > 0) { + log.debug("set blur={}, window xid={}, region={}", .{ + blur, + self.x11_surface.getXid(), + region, + }); + + 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); + } - 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/structs.zig b/src/apprt/structs.zig index bf14b65a9c4..2c37dbd5ee3 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -12,6 +12,10 @@ pub const ContentScale = struct { pub const SurfaceSize = struct { width: u32, height: u32, + + pub fn eql(self: *const SurfaceSize, other: *const SurfaceSize) bool { + return self.width == other.width and self.height == other.height; + } }; /// The position of the cursor in pixels. diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 5c25281c8d0..3cb0016fadf 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/Benchmark.zig b/src/benchmark/Benchmark.zig index 0ca1544147b..41c3695d40e 100644 --- a/src/benchmark/Benchmark.zig +++ b/src/benchmark/Benchmark.zig @@ -131,12 +131,17 @@ pub const VTable = struct { }; test Benchmark { - // This test fails on FreeBSD so skip: + // This test fails on FreeBSD and Windows so skip: // // /home/runner/work/ghostty/ghostty/src/benchmark/Benchmark.zig:165:5: 0x3cd2de1 in decltest.Benchmark (ghostty-test) // try testing.expect(result.duration > 0); // ^ - if (builtin.os.tag == .freebsd) return error.SkipZigTest; + switch (builtin.os.tag) { + .freebsd, + .windows, + => return error.SkipZigTest, + else => {}, + } const testing = std.testing; const Simple = struct { diff --git a/src/benchmark/ScreenClone.zig b/src/benchmark/ScreenClone.zig index 380379bc3e1..108eaa0c6a2 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 7cf28217f47..1cac656e278 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 3a8a4e0c746..797a00ddb68 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -38,6 +38,7 @@ wasm_shared: bool = true, /// Ghostty exe properties exe_entrypoint: ExeEntrypoint = .ghostty, version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, +lib_version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, /// Binary properties pie: bool = false, @@ -51,6 +52,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,10 +62,14 @@ 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, -pub fn init(b: *std.Build, appVersion: []const u8) !Config { +pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Config { // Setup our standard Zig target and optimize options, i.e. // `-Doptimize` and `-Dtarget`. const optimize = b.standardOptimizeOption(.{}); @@ -78,6 +84,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 +106,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 +132,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 +244,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, @@ -273,6 +295,20 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { }; }; + // libghostty-vt properties + + const lib_version_string = b.option( + []const u8, + "lib-version-string", + "A specific version string to use for the build of libghostty-vt. " ++ + "If not specified, git will be used. This must be a semantic version.", + ); + + config.lib_version = if (lib_version_string) |v| + try std.SemanticVersion.parse(v) + else + try std.SemanticVersion.parse(libVersion); + //--------------------------------------------------------------- // Binary Properties @@ -314,11 +350,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 +394,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 +444,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 +456,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 @@ -490,13 +534,20 @@ pub fn addOptions(self: *const Config, step: *std.Build.Step.Options) !void { // Our version. We also add the string version so we don't need // to do any allocations at runtime. This has to be long enough to // accommodate realistic large branch names for dev versions. - var buf: [1024]u8 = undefined; + var app_version_buf: [1024]u8 = undefined; step.addOption(std.SemanticVersion, "app_version", self.version); step.addOption([:0]const u8, "app_version_string", try std.fmt.bufPrintZ( - &buf, + &app_version_buf, "{f}", .{self.version}, )); + var lib_version_buf: [1024]u8 = undefined; + step.addOption(std.SemanticVersion, "lib_version", self.lib_version); + step.addOption([:0]const u8, "lib_version_string", try std.fmt.bufPrintZ( + &lib_version_buf, + "{f}", + .{self.lib_version}, + )); step.addOption( ReleaseChannel, "release_channel", @@ -510,12 +561,16 @@ pub fn addOptions(self: *const Config, step: *std.Build.Step.Options) !void { /// Returns the build options for the terminal module. This assumes a /// Ghostty executable being built. Callers should modify this as needed. -pub fn terminalOptions(self: *const Config) TerminalBuildOptions { +pub fn terminalOptions(self: *const Config, artifact: TerminalBuildOptions.Artifact) TerminalBuildOptions { return .{ - .artifact = .ghostty, + .artifact = artifact, .simd = self.simd, .oniguruma = true, .c_abi = false, + .version = switch (artifact) { + .ghostty => self.version, + .lib => self.lib_version, + }, .slow_runtime_safety = switch (self.optimize) { .Debug => true, .ReleaseSafe, diff --git a/src/build/GhosttyDist.zig b/src/build/GhosttyDist.zig index 600aa4883cc..448047f4b73 100644 --- a/src/build/GhosttyDist.zig +++ b/src/build/GhosttyDist.zig @@ -18,17 +18,23 @@ archive_step: *std.Build.Step, check_step: *std.Build.Step, pub fn init(b: *std.Build, cfg: *const Config) !GhosttyDist { + // The name prefix used for all paths in the archive. + const name = if (cfg.emit_lib_vt) "libghostty-vt" else "ghostty"; + // Get the resources we're going to inject into the source tarball. + // lib-vt doesn't need GTK resources or frame data. const alloc = b.allocator; var resources: std.ArrayListUnmanaged(Resource) = .empty; - { - const gtk = SharedDeps.gtkNgDistResources(b); - try resources.append(alloc, gtk.resources_c); - try resources.append(alloc, gtk.resources_h); - } - { - const framedata = GhosttyFrameData.distResources(b); - try resources.append(alloc, framedata.framedata); + if (!cfg.emit_lib_vt) { + { + const gtk = SharedDeps.gtkNgDistResources(b); + try resources.append(alloc, gtk.resources_c); + try resources.append(alloc, gtk.resources_h); + } + { + const framedata = GhosttyFrameData.distResources(b); + try resources.append(alloc, framedata.framedata); + } } // git archive to create the final tarball. "git archive" is the @@ -46,8 +52,8 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyDist { const version = b.addWriteFiles().add("VERSION", b.fmt("{f}", .{cfg.version})); // --add-file uses the most recent --prefix to determine the path // in the archive to copy the file (the directory only). - git_archive.addArg(b.fmt("--prefix=ghostty-{f}/", .{ - cfg.version, + git_archive.addArg(b.fmt("--prefix={s}-{f}/", .{ + name, cfg.version, })); git_archive.addPrefixedFileArg("--add-file=", version); } @@ -65,8 +71,8 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyDist { // --add-file uses the most recent --prefix to determine the path // in the archive to copy the file (the directory only). - git_archive.addArg(b.fmt("--prefix=ghostty-{f}/{s}/", .{ - cfg.version, + git_archive.addArg(b.fmt("--prefix={s}-{f}/{s}/", .{ + name, cfg.version, std.fs.path.dirname(resource.dist).?, })); git_archive.addPrefixedFileArg("--add-file=", copied); @@ -77,19 +83,28 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyDist { // This is important. Standard source tarballs extract into // a directory named `project-version`. This is expected by // standard tooling such as debhelper and rpmbuild. - b.fmt("--prefix=ghostty-{f}/", .{cfg.version}), + b.fmt("--prefix={s}-{f}/", .{ name, cfg.version }), "-o", }); const output = git_archive.addOutputFileArg(b.fmt( - "ghostty-{f}.tar.gz", - .{cfg.version}, + "{s}-{f}.tar.gz", + .{ name, cfg.version }, )); git_archive.addArg("HEAD"); + // When building for lib-vt only, exclude large directories that + // are not needed to build libghostty-vt. This significantly reduces + // the size of the resulting archive. + if (cfg.emit_lib_vt) { + for (lib_vt_excludes) |exclude| { + git_archive.addArg(b.fmt(":(exclude){s}", .{exclude})); + } + } + // The install step to put the dist into the build directory. const install = b.addInstallFile( output, - b.fmt("dist/ghostty-{f}.tar.gz", .{cfg.version}), + b.fmt("dist/{s}-{f}.tar.gz", .{ name, cfg.version }), ); // The check step to ensure the archive works. @@ -100,8 +115,8 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyDist { // This is the root Ghostty source dir of the extracted source tarball. // i.e. this is way `build.zig` is. const extract_dir = check - .addOutputDirectoryArg("ghostty") - .path(b, b.fmt("ghostty-{f}", .{cfg.version})); + .addOutputDirectoryArg(name) + .path(b, b.fmt("{s}-{f}", .{ name, cfg.version })); // Check that tests pass within the extracted directory. This isn't // a fully hermetic test because we're sharing the Zig cache. In @@ -109,7 +124,12 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyDist { // in the interest of speed we don't do that for now and hope other // CI catches any issues. const check_test = step: { - const step = b.addSystemCommand(&.{ "zig", "build", "test" }); + // For lib-vt, we run the lib-vt tests instead of the full test suite. + const check_cmd = if (cfg.emit_lib_vt) + &[_][]const u8{ "zig", "build", "test-lib-vt", "-Demit-lib-vt=true" } + else + &[_][]const u8{ "zig", "build", "test" }; + const step = b.addSystemCommand(check_cmd); step.setCwd(extract_dir); // Must be set so that Zig knows that this command doesn't @@ -133,6 +153,23 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyDist { check_test.step.dependOn(&check_path.step); } + // For lib-vt, also verify the CMake build works from the tarball. + if (cfg.emit_lib_vt) { + const cmake_build_dir = extract_dir.path(b, "cmake-build"); + const cmake_configure = b.addSystemCommand(&.{ "cmake", "-B" }); + cmake_configure.addDirectoryArg(cmake_build_dir); + cmake_configure.setCwd(extract_dir); + cmake_configure.expectExitCode(0); + cmake_configure.step.dependOn(&check.step); + + const cmake_build = b.addSystemCommand(&.{ "cmake", "--build" }); + cmake_build.addDirectoryArg(cmake_build_dir); + cmake_build.expectExitCode(0); + cmake_build.step.dependOn(&cmake_configure.step); + + check_test.step.dependOn(&cmake_build.step); + } + return .{ .archive = output, .install_step = &install.step, @@ -141,6 +178,36 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyDist { }; } +/// Paths to exclude from the dist archive when building for lib-vt only. +/// These are large files and directories that are not needed to build or +/// test libghostty-vt, specified as git pathspec exclude patterns. +const lib_vt_excludes = &[_][]const u8{ + // App and platform resources + "images", + "macos", + "dist/doxygen", + "dist/linux", + "dist/macos", + "dist/windows", + "flatpak", + "snap", + "po", + "example", + + // Test corpus (lib-vt tests use embedded testdata within src/terminal/) + "test", + + // Large binary assets + "src/font/res", + "src/crash/testdata", + "pkg/wuffs/src/too_big.jpg", + "pkg/wuffs/src/too_big.png", + "pkg/breakpad/vendor", + + // Vendored libraries not used by lib-vt + "vendor", +}; + /// A dist resource is a resource that is built and distributed as part /// of the source tarball with Ghostty. These aren't committed to the Git /// repository but are built as part of the `zig build dist` command. diff --git a/src/build/GhosttyExe.zig b/src/build/GhosttyExe.zig index 3e63b602630..caa564bf008 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 2ac383544ef..4e15fbbf486 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); @@ -88,6 +94,44 @@ pub fn initShared( }); _ = try deps.add(lib); + // On Windows with MSVC, building a DLL requires the full CRT library + // chain. linkLibC() (called via deps.add) provides msvcrt.lib, but + // that references symbols in vcruntime.lib and ucrt.lib. Zig's library + // search paths include the MSVC lib dir and the Windows SDK 'um' dir, + // but not the SDK 'ucrt' dir where ucrt.lib lives. + if (deps.config.target.result.os.tag == .windows and + deps.config.target.result.abi == .msvc) + { + // The CRT initialization code in msvcrt.lib calls __vcrt_initialize + // and __acrt_initialize, which are in the static CRT libraries. + lib.linkSystemLibrary("libvcruntime"); + + // ucrt.lib is in the Windows SDK 'ucrt' dir. Detect the SDK + // installation and add the UCRT library path. + const arch = deps.config.target.result.cpu.arch; + const sdk = std.zig.WindowsSdk.find(b.allocator, arch) catch null; + if (sdk) |s| { + if (s.windows10sdk) |w10| { + const arch_str: []const u8 = switch (arch) { + .x86_64 => "x64", + .x86 => "x86", + .aarch64 => "arm64", + else => "x64", + }; + const ucrt_lib_path = std.fmt.allocPrint( + b.allocator, + "{s}\\Lib\\{s}\\ucrt\\{s}", + .{ w10.path, w10.version, arch_str }, + ) catch null; + + if (ucrt_lib_path) |path| { + lib.addLibraryPath(.{ .cwd_relative = path }); + } + } + } + lib.linkSystemLibrary("libucrt"); + } + // Get our debug symbols const dsymutil: ?std.Build.LazyPath = dsymutil: { if (!deps.config.target.result.os.tag.isDarwin()) { diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index 6d44c62b614..4de1b0df92f 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -4,18 +4,35 @@ const std = @import("std"); const builtin = @import("builtin"); const assert = std.debug.assert; const RunStep = std.Build.Step.Run; +const Config = @import("Config.zig"); const GhosttyZig = @import("GhosttyZig.zig"); +const LibtoolStep = @import("LibtoolStep.zig"); +const LipoStep = @import("LipoStep.zig"); +const SharedDeps = @import("SharedDeps.zig"); +const XCFrameworkStep = @import("XCFrameworkStep.zig"); /// The step that generates the file. step: *std.Build.Step, -/// The artifact result -artifact: *std.Build.Step.InstallArtifact, +/// The install step for the library output. +artifact: *std.Build.Step, + +/// The kind of library +kind: Kind, /// The final library file output: std.Build.LazyPath, dsym: ?std.Build.LazyPath, pkg_config: ?std.Build.LazyPath, +pkg_config_static: ?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, @@ -27,34 +44,169 @@ pub fn initWasm( const exe = b.addExecutable(.{ .name = "ghostty-vt", .root_module = zig.vt_c, - .version = std.SemanticVersion{ .major = 0, .minor = 1, .patch = 0 }, + .version = zig.version, }); // Allow exported symbols to actually be exported. exe.rdynamic = true; + // Export the indirect function table so that embedders (e.g. JS in + // a browser) can insert callback entries for terminal effects. + exe.export_table = true; + // There is no entrypoint for this wasm module. exe.entry = .disabled; + // Zig's WASM linker doesn't support --growable-table, so the table + // is emitted with max == min and can't be grown from JS. Run a + // small Zig build tool that patches the binary's table section to + // remove the max limit. + const patch_run = patch: { + const patcher = b.addExecutable(.{ + .name = "wasm_patch_growable_table", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/build/wasm_patch_growable_table.zig"), + .target = b.graph.host, + }), + }); + break :patch b.addRunArtifact(patcher); + }; + patch_run.addFileArg(exe.getEmittedBin()); + const output = patch_run.addOutputFileArg("ghostty-vt.wasm"); + const artifact_install = b.addInstallFileWithDir( + output, + .bin, + "ghostty-vt.wasm", + ); + return .{ - .step = &exe.step, - .artifact = b.addInstallArtifact(exe, .{}), - .output = exe.getEmittedBin(), + .step = &patch_run.step, + .artifact = &artifact_install.step, + .kind = .wasm, + .output = output, .dsym = null, .pkg_config = null, + .pkg_config_static = 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); +} + +/// Apple platform targets for xcframework slices. +pub const ApplePlatform = enum { + macos_universal, + ios, + ios_simulator, + // tvOS, watchOS, and visionOS are not yet supported by Zig's + // standard library (missing PATH_MAX, mcontext fields, etc.). + + /// Platforms that have device + simulator pairs, gated on SDK detection. + const sdk_platforms = [_]struct { + os_tag: std.Target.Os.Tag, + device: ApplePlatform, + simulator: ApplePlatform, + }{ + .{ .os_tag = .ios, .device = .ios, .simulator = .ios_simulator }, + }; +}; + +/// Static libraries for each Apple platform, keyed by `ApplePlatform`. +pub const AppleLibs = std.EnumMap(ApplePlatform, GhosttyLibVt); + +/// Build static libraries for all available Apple platforms. +/// Always builds a macOS universal (arm64 + x86_64) fat binary. +/// Additional platforms are included if their SDK is detected. +pub fn initStaticAppleUniversal( + b: *std.Build, + cfg: *const Config, + deps: *const SharedDeps, + zig: *const GhosttyZig, +) !AppleLibs { + var result: AppleLibs = .{}; + + // macOS universal (arm64 + x86_64) + const aarch64_zig = try zig.retarget( + b, + cfg, + deps, + Config.genericMacOSTarget(b, .aarch64), + ); + const x86_64_zig = try zig.retarget( + b, + cfg, + deps, + Config.genericMacOSTarget(b, .x86_64), + ); + const aarch64 = try initStatic(b, &aarch64_zig); + const x86_64 = try initStatic(b, &x86_64_zig); + const universal = LipoStep.create(b, .{ + .name = "ghostty-vt", + .out_name = "libghostty-vt.a", + .input_a = aarch64.output, + .input_b = x86_64.output, + }); + result.put(.macos_universal, .{ + .step = universal.step, + .artifact = universal.step, + .kind = .static, + .output = universal.output, + .dsym = null, + .pkg_config = null, + .pkg_config_static = null, + }); + + // Additional Apple platforms, each gated on SDK availability. + for (ApplePlatform.sdk_platforms) |p| { + const target_query: std.Target.Query = .{ + .cpu_arch = .aarch64, + .os_tag = p.os_tag, + .os_version_min = Config.osVersionMin(p.os_tag), + }; + if (detectAppleSDK(b.resolveTargetQuery(target_query).result)) { + const dev_zig = try zig.retarget(b, cfg, deps, b.resolveTargetQuery(target_query)); + result.put(p.device, try initStatic(b, &dev_zig)); + + const sim_zig = try zig.retarget(b, cfg, deps, b.resolveTargetQuery(.{ + .cpu_arch = .aarch64, + .os_tag = p.os_tag, + .os_version_min = Config.osVersionMin(p.os_tag), + .abi = .simulator, + .cpu_model = .{ .explicit = &std.Target.aarch64.cpu.apple_a17 }, + })); + result.put(p.simulator, try initStatic(b, &sim_zig)); + } + } + + return result; +} + +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 }, + .version = zig.version, }); lib.installHeadersDirectory( b.path("include/ghostty"), @@ -62,6 +214,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 +253,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"}); @@ -97,9 +267,112 @@ pub fn initShared( }; // pkg-config - const pc: std.Build.LazyPath = pc: { - const wf = b.addWriteFiles(); - break :pc wf.add("libghostty-vt.pc", b.fmt( + // + // pkg-config's --static only expands Libs.private / Requires.private; + // it doesn't change -lghostty-vt into an archive-only reference when + // both shared and static libraries are installed. Install a dedicated + // static module so consumers can request the archive explicitly. + const pcs: ?PkgConfigFiles = if (kind == .shared) + pkgConfigFiles(b, zig, target.result.os.tag) + else + null; + + // For static libraries with vendored SIMD dependencies, combine + // all archives into a single fat archive so consumers only need + // to link one file. + if (kind == .static and + zig.simd_libs.items.len > 0) + { + var sources: SharedDeps.LazyPathList = .empty; + try sources.append(b.allocator, lib.getEmittedBin()); + try sources.appendSlice(b.allocator, zig.simd_libs.items); + + const combined = combineArchives(b, target, sources.items); + combined.step.dependOn(&lib.step); + + return .{ + .step = combined.step, + .artifact = &b.addInstallArtifact(lib, .{}).step, + .kind = kind, + .output = combined.output, + .dsym = dsymutil, + .pkg_config = if (pcs) |v| v.shared else null, + .pkg_config_static = if (pcs) |v| v.static else null, + }; + } + + return .{ + .step = &lib.step, + .artifact = &b.addInstallArtifact(lib, .{}).step, + .kind = kind, + .output = lib.getEmittedBin(), + .dsym = dsymutil, + .pkg_config = if (pcs) |v| v.shared else null, + .pkg_config_static = if (pcs) |v| v.static else null, + }; +} + +/// Combine multiple static archives into a single fat archive. +/// Uses libtool on Darwin and ar MRI scripts on other platforms. +fn combineArchives( + b: *std.Build, + target: std.Build.ResolvedTarget, + sources: []const std.Build.LazyPath, +) struct { step: *std.Build.Step, output: std.Build.LazyPath } { + if (target.result.os.tag.isDarwin()) { + const libtool = LibtoolStep.create(b, .{ + .name = "ghostty-vt", + .out_name = "libghostty-vt.a", + .sources = @constCast(sources), + }); + return .{ .step = libtool.step, .output = libtool.output }; + } + + // On non-Darwin, use a build tool that generates an MRI script and + // pipes it to `zig ar -M`. This works on all platforms including + // Windows (the previous /bin/sh approach did not). + const tool = b.addExecutable(.{ + .name = "combine_archives", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/build/combine_archives.zig"), + .target = b.graph.host, + }), + }); + const run = b.addRunArtifact(tool); + const output = run.addOutputFileArg("libghostty-vt.a"); + for (sources) |source| run.addFileArg(source); + + return .{ .step = &run.step, .output = output }; +} + +/// Returns the Libs.private value for the pkg-config file. +/// This includes the C++ standard library needed by SIMD code. +/// +/// Zig compiles C++ code with LLVM's libc++ (not GNU libstdc++), +/// so consumers linking the static library need a libc++-compatible +/// toolchain: `zig cc`, `clang`, or GCC with `-lc++` installed. +fn libsPrivate( + zig: *const GhosttyZig, +) []const u8 { + return if (zig.vt_c.link_libcpp orelse false) "-lc++" else ""; +} + +const PkgConfigFiles = struct { + shared: std.Build.LazyPath, + static: std.Build.LazyPath, +}; + +fn pkgConfigFiles( + b: *std.Build, + zig: *const GhosttyZig, + os_tag: std.Target.Os.Tag, +) PkgConfigFiles { + const wf = b.addWriteFiles(); + const libs_private = libsPrivate(zig); + const requires_private = requiresPrivate(b); + + return .{ + .shared = wf.add("libghostty-vt.pc", b.fmt( \\prefix={s} \\includedir=${{prefix}}/include \\libdir=${{prefix}}/lib @@ -107,19 +380,107 @@ pub fn initShared( \\Name: libghostty-vt \\URL: https://github.com/ghostty-org/ghostty \\Description: Ghostty VT library - \\Version: 0.1.0 + \\Version: {f} \\Cflags: -I${{includedir}} \\Libs: -L${{libdir}} -lghostty-vt - , .{b.install_prefix})); + \\Libs.private: {s} + \\Requires.private: {s} + , .{ b.install_prefix, zig.version, libs_private, requires_private })), + .static = wf.add("libghostty-vt-static.pc", b.fmt( + \\prefix={s} + \\includedir=${{prefix}}/include + \\libdir=${{prefix}}/lib + \\ + \\Name: libghostty-vt-static + \\URL: https://github.com/ghostty-org/ghostty + \\Description: Ghostty VT library (static) + \\Version: {f} + \\Cflags: -I${{includedir}} + \\Libs: ${{libdir}}/{s} + \\Libs.private: {s} + \\Requires.private: {s} + , .{ + b.install_prefix, + zig.version, + staticLibraryName(os_tag), + libs_private, + requires_private, + })), }; +} - return .{ - .step = &lib.step, - .artifact = b.addInstallArtifact(lib, .{}), - .output = lib.getEmittedBin(), - .dsym = dsymutil, - .pkg_config = pc, - }; +fn staticLibraryName(os_tag: std.Target.Os.Tag) []const u8 { + return if (os_tag == .windows) + "ghostty-vt-static.lib" + else + "libghostty-vt.a"; +} + +/// Returns the Requires.private value for the pkg-config file. +/// When SIMD dependencies are provided by the system (via +/// -Dsystem-integration), we reference their pkg-config names so +/// that downstream consumers pick them up transitively. +fn requiresPrivate(b: *std.Build) []const u8 { + const system_simdutf = b.systemIntegrationOption("simdutf", .{}); + const system_highway = b.systemIntegrationOption("highway", .{ .default = false }); + + if (system_simdutf and system_highway) return "simdutf, libhwy"; + if (system_simdutf) return "simdutf"; + if (system_highway) return "libhwy"; + return ""; +} + +/// Create an XCFramework bundle from Apple platform static libraries. +pub fn xcframework( + apple_libs: *const AppleLibs, + b: *std.Build, +) *XCFrameworkStep { + // Generate a headers directory with a module map for Swift PM. + // We can't use include/ directly because it contains a module map + // for GhosttyKit (the macOS app library). + const wf = b.addWriteFiles(); + _ = wf.addCopyDirectory( + b.path("include/ghostty"), + "ghostty", + .{ .include_extensions = &.{".h"} }, + ); + _ = wf.add("module.modulemap", + \\module GhosttyVt { + \\ umbrella header "ghostty/vt.h" + \\ export * + \\} + \\ + ); + const headers = wf.getDirectory(); + + var libraries: [AppleLibs.len]XCFrameworkStep.Library = undefined; + var lib_count: usize = 0; + for (std.enums.values(ApplePlatform)) |platform| { + if (apple_libs.get(platform)) |lib| { + libraries[lib_count] = .{ + .library = lib.output, + .headers = headers, + .dsym = null, + }; + lib_count += 1; + } + } + + return XCFrameworkStep.create(b, .{ + .name = "ghostty-vt", + .out_path = b.pathJoin(&.{ b.install_prefix, "lib/ghostty-vt.xcframework" }), + .libraries = libraries[0..lib_count], + }); +} + +/// Returns true if the Apple SDK for the given target is installed. +fn detectAppleSDK(target: std.Target) bool { + _ = std.zig.LibCInstallation.findNative(.{ + .allocator = std.heap.page_allocator, + .target = &target, + .verbose = false, + }) catch return false; + return true; } pub fn install( @@ -127,7 +488,7 @@ pub fn install( step: *std.Build.Step, ) void { const b = step.owner; - step.dependOn(&self.artifact.step); + step.dependOn(self.artifact); if (self.pkg_config) |pkg_config| { step.dependOn(&b.addInstallFileWithDir( pkg_config, @@ -135,4 +496,11 @@ pub fn install( "share/pkgconfig/libghostty-vt.pc", ).step); } + if (self.pkg_config_static) |pkg_config_static| { + step.dependOn(&b.addInstallFileWithDir( + pkg_config_static, + .prefix, + "share/pkgconfig/libghostty-vt-static.pc", + ).step); + } } diff --git a/src/build/GhosttyZig.zig b/src/build/GhosttyZig.zig index e63120e7467..8d5b78fb470 100644 --- a/src/build/GhosttyZig.zig +++ b/src/build/GhosttyZig.zig @@ -11,30 +11,80 @@ const TerminalBuildOptions = @import("../terminal/build_options.zig").Options; vt: *std.Build.Module, vt_c: *std.Build.Module, +/// The libghostty-vt version +version: std.SemanticVersion, + +/// Static library paths for vendored SIMD dependencies. Populated +/// only when the dependencies are built from source (not provided +/// by the system via -Dsystem-integration). Used to produce a +/// combined static archive for downstream consumers. +simd_libs: SharedDeps.LazyPathList, + pub fn init( b: *std.Build, cfg: *const Config, deps: *const SharedDeps, +) !GhosttyZig { + return initInner(b, cfg, deps, "ghostty-vt", "ghostty-vt-c"); +} + +/// Create a new GhosttyZig with modules retargeted to a different +/// architecture. Used to produce universal (fat) binaries on macOS. +pub fn retarget( + self: *const GhosttyZig, + b: *std.Build, + cfg: *const Config, + deps: *const SharedDeps, + target: std.Build.ResolvedTarget, +) !GhosttyZig { + _ = self; + const retargeted_config = try b.allocator.create(Config); + retargeted_config.* = cfg.*; + retargeted_config.target = target; + + const retargeted_deps = try b.allocator.create(SharedDeps); + retargeted_deps.* = try deps.retarget(b, target); + + // Use unique module names to avoid collisions with the original target. + const arch_name = @tagName(target.result.cpu.arch); + return initInner( + b, + retargeted_config, + retargeted_deps, + b.fmt("ghostty-vt-{s}", .{arch_name}), + b.fmt("ghostty-vt-c-{s}", .{arch_name}), + ); +} + +fn initInner( + b: *std.Build, + cfg: *const Config, + deps: *const SharedDeps, + vt_name: []const u8, + vt_c_name: []const u8, ) !GhosttyZig { // Terminal module build options - var vt_options = cfg.terminalOptions(); + var vt_options = cfg.terminalOptions(.lib); vt_options.artifact = .lib; // We presently don't allow Oniguruma in our Zig module at all. // We should expose this as a build option in the future so we can // conditionally do this. vt_options.oniguruma = false; + var simd_libs: SharedDeps.LazyPathList = .empty; + return .{ .vt = try initVt( - "ghostty-vt", + vt_name, b, cfg, deps, vt_options, + null, ), .vt_c = try initVt( - "ghostty-vt-c", + vt_c_name, b, cfg, deps, @@ -43,7 +93,12 @@ pub fn init( dup.c_abi = true; break :options dup; }, + &simd_libs, ), + + .version = cfg.lib_version, + + .simd_libs = simd_libs, }; } @@ -53,6 +108,7 @@ fn initVt( cfg: *const Config, deps: *const SharedDeps, vt_options: TerminalBuildOptions, + simd_libs: ?*SharedDeps.LazyPathList, ) !*std.Build.Module { // General build options const general_options = b.addOptions(); @@ -64,8 +120,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); @@ -78,7 +138,7 @@ fn initVt( // If SIMD is enabled, add all our SIMD dependencies. if (cfg.simd) { - try SharedDeps.addSimd(b, vt, null); + try SharedDeps.addSimd(b, vt, simd_libs); } return vt; diff --git a/src/build/GitVersion.zig b/src/build/GitVersion.zig index 8b368d2cd3f..41cc7f84f6a 100644 --- a/src/build/GitVersion.zig +++ b/src/build/GitVersion.zig @@ -29,11 +29,14 @@ pub fn detect(b: *std.Build) !Version { error.ExitCodeFailure => return error.GitNotRepository, else => return err, }; - // Replace any '/' with '-' as including slashes will mess up building - // the dist tarball - the tarball uses the branch as part of the - // name and including slashes means that the tarball will end up in - // subdirectories instead of where it's supposed to be. - std.mem.replaceScalar(u8, tmp, '/', '-'); + + // Replace characters that are not valid in semantic version + // pre-release identifiers (which only allow [0-9A-Za-z-]). + // Slashes would also mess up dist tarball paths. + for (tmp) |*c| { + if (!std.ascii.isAlphanumeric(c.*) and c.* != '-') c.* = '-'; + } + break :b tmp; }; diff --git a/src/build/LibtoolStep.zig b/src/build/LibtoolStep.zig index d2b5149275f..856a33aa853 100644 --- a/src/build/LibtoolStep.zig +++ b/src/build/LibtoolStep.zig @@ -33,7 +33,15 @@ pub fn create(b: *std.Build, opts: Options) *LibtoolStep { const run_step = RunStep.create(b, b.fmt("libtool {s}", .{opts.name})); run_step.addArgs(&.{ "libtool", "-static", "-o" }); const output = run_step.addOutputFileArg(opts.out_name); - for (opts.sources) |source| run_step.addFileArg(source); + for (opts.sources, 0..) |source, i| { + run_step.addFileArg(normalizeArchive( + b, + opts.name, + opts.out_name, + i, + source, + )); + } self.* = .{ .step = &run_step.step, @@ -42,3 +50,29 @@ pub fn create(b: *std.Build, opts: Options) *LibtoolStep { return self; } + +fn normalizeArchive( + b: *std.Build, + step_name: []const u8, + out_name: []const u8, + index: usize, + source: LazyPath, +) LazyPath { + // Newer Xcode libtool can drop 64-bit archive members if the input + // archive layout doesn't match what it expects. ranlib rewrites the + // archive without flattening members through the filesystem, so we + // normalize each source archive first. This is a Zig/toolchain + // interoperability workaround, not a Ghostty archive format change. + const run_step = RunStep.create( + b, + b.fmt("ranlib {s} #{d}", .{ step_name, index }), + ); + run_step.addArgs(&.{ + "/bin/sh", + "-c", + "/bin/cp \"$1\" \"$2\" && /usr/bin/ranlib \"$2\"", + "_", + }); + run_step.addFileArg(source); + return run_step.addOutputFileArg(b.fmt("{d}-{s}", .{ index, out_name })); +} diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 9276c99145f..70ff84b8cd0 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()) @@ -133,7 +133,52 @@ pub fn add( step.root_module.addOptions("build_options", self.options); // Every exe needs the terminal options - self.config.terminalOptions().add(b, step.root_module); + self.config.terminalOptions(.ghostty).add(b, step.root_module); + + // C imports for locale constants and functions + { + const c = b.addTranslateC(.{ + .root_source_file = b.path("src/os/locale.c"), + .target = target, + .optimize = optimize, + }); + if (target.result.os.tag.isDarwin()) { + const libc = try std.zig.LibCInstallation.findNative(.{ + .allocator = b.allocator, + .target = &target.result, + .verbose = false, + }); + c.addSystemIncludePath(.{ .cwd_relative = libc.sys_include_dir.? }); + } + step.root_module.addImport("locale-c", c.createModule()); + } + + // 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. @@ -372,8 +417,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 +678,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 +685,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 +711,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, @@ -699,6 +753,7 @@ pub fn addSimd( ) !void { const target = m.resolved_target.?; const optimize = m.optimize.?; + const system_highway = b.systemIntegrationOption("highway", .{ .default = false }); // Simdutf if (b.systemIntegrationOption("simdutf", .{})) { @@ -717,7 +772,7 @@ pub fn addSimd( } // Highway - if (b.systemIntegrationOption("highway", .{ .default = false })) { + if (system_highway) { m.linkSystemLibrary("libhwy", dynamic_link_opts); } else { if (b.lazyDependency("highway", .{ @@ -754,12 +809,43 @@ 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", + ); + + // Keep our SIMD sources in the same Highway header mode as the + // vendored package build so HWY's inline dispatch/runtime helpers + // have a consistent ABI. + if (!system_highway) try flags.append( + b.allocator, + "-DHWY_NO_LIBCXX", + ); + + // 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 +854,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/combine_archives.zig b/src/build/combine_archives.zig new file mode 100644 index 00000000000..04f2c0e4988 --- /dev/null +++ b/src/build/combine_archives.zig @@ -0,0 +1,54 @@ +//! Build tool that combines multiple static archives into a single fat +//! archive using an MRI script piped to `zig ar -M`. +//! +//! MRI scripts require stdin piping (`ar -M < script`), which can't be +//! expressed as a single command in the zig build system's RunStep. The +//! previous approach used `/bin/sh -c` to do the piping, but that isn't +//! available on Windows. This tool handles both the script generation +//! and the piping in a single cross-platform executable. +//! +//! Usage: combine_archives [input2.a ...] + +const std = @import("std"); + +pub fn main() !void { + var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; + const alloc = gpa.allocator(); + + const args = try std.process.argsAlloc(alloc); + if (args.len < 3) { + std.log.err("usage: combine_archives ", .{}); + std.process.exit(1); + } + + const output_path = args[1]; + const inputs = args[2..]; + + // Build the MRI script. + var script: std.ArrayListUnmanaged(u8) = .empty; + try script.appendSlice(alloc, "CREATE "); + try script.appendSlice(alloc, output_path); + try script.append(alloc, '\n'); + for (inputs) |input| { + try script.appendSlice(alloc, "ADDLIB "); + try script.appendSlice(alloc, input); + try script.append(alloc, '\n'); + } + try script.appendSlice(alloc, "SAVE\nEND\n"); + + var child: std.process.Child = .init(&.{ "zig", "ar", "-M" }, alloc); + child.stdin_behavior = .Pipe; + child.stdout_behavior = .Inherit; + child.stderr_behavior = .Inherit; + + try child.spawn(); + try child.stdin.?.writeAll(script.items); + child.stdin.?.close(); + child.stdin = null; + + const term = try child.wait(); + if (term.Exited != 0) { + std.log.err("zig ar -M exited with code {d}", .{term.Exited}); + std.process.exit(1); + } +} diff --git a/src/build/framegen/main.c b/src/build/framegen/main.c index 647768006ce..2139b15ddfa 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/build/wasm_patch_growable_table.zig b/src/build/wasm_patch_growable_table.zig new file mode 100644 index 00000000000..c40c0a9c823 --- /dev/null +++ b/src/build/wasm_patch_growable_table.zig @@ -0,0 +1,269 @@ +//! Build tool that patches a WASM binary to make the function table +//! growable by removing its maximum size limit. +//! +//! Zig's WASM linker doesn't support `--growable-table`, so the table +//! is emitted with max == min. This tool finds the table section (id 4) +//! and changes the limits flag from 0x01 (has max) to 0x00 (no max), +//! removing the max field. +//! +//! Usage: wasm_growable_table + +const std = @import("std"); +const testing = std.testing; +const Allocator = std.mem.Allocator; + +pub fn main() !void { + // This is a one-off patcher, so we leak all our memory on purpose + // and let the OS clean it up when we exit. + var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; + const alloc = gpa.allocator(); + + // Parse args: program input output + const args = try std.process.argsAlloc(alloc); + defer std.process.argsFree(alloc, args); + if (args.len != 3) { + std.log.err("usage: wasm_growable_table ", .{}); + std.process.exit(1); + unreachable; + } + + // Patch the file. + const output: []const u8 = try patchTableGrowable( + alloc, + try std.fs.cwd().readFileAlloc( + alloc, + args[1], + std.math.maxInt(usize), + ), + ); + + // Write our output + const out_file = try std.fs.cwd().createFile(args[2], .{}); + defer out_file.close(); + try out_file.writeAll(output); +} + +/// Patch the WASM binary's table section to remove the maximum size +/// limit, making the table growable. If the table already has no max +/// or no table section is found, the input is returned unchanged. +/// +/// The WASM table section (id=4) encodes table limits as: +/// - flags=0x00, min (LEB128) — no max, growable +/// - flags=0x01, min (LEB128), max (LEB128) — bounded, not growable +/// +/// This function rewrites the section to use flags=0x00, dropping the +/// max field entirely. +fn patchTableGrowable( + alloc: Allocator, + input: []const u8, +) (error{InvalidWasm} || std.Io.Writer.Error)![]const u8 { + if (input.len < 8) return error.InvalidWasm; + + // Start after the 8-byte WASM header (magic + version). + var pos: usize = 8; + + while (pos < input.len) { + const section_id = input[pos]; + pos += 1; + const section_size = readLeb128(input, &pos); + const section_start = pos; + + // We're looking for section 4 (the table section). + if (section_id != 4) { + pos = section_start + section_size; + continue; + } + + _ = readLeb128(input, &pos); // table count + pos += 1; // elem_type (0x70 = funcref) + const flags = input[pos]; + + // flags bit 0 indicates whether a max is present. + if (flags & 1 == 0) { + // Already no max, nothing to patch. + return input; + } + + // Record positions of each field so we can reconstruct + // the section without the max value. + const flags_pos = pos; + pos += 1; // skip flags byte + const min_start = pos; + _ = readLeb128(input, &pos); // min + const max_start = pos; + _ = readLeb128(input, &pos); // max + const max_end = pos; + const section_end = section_start + section_size; + + // Build the new section payload with the max removed: + // [table count + elem_type] [flags=0x00] [min] [trailing data] + var payload: std.Io.Writer.Allocating = .init(alloc); + try payload.writer.writeAll(input[section_start..flags_pos]); + try payload.writer.writeByte(0x00); // flags: no max + try payload.writer.writeAll(input[min_start..max_start]); + try payload.writer.writeAll(input[max_end..section_end]); + + // Reassemble the full binary: + // [everything before this section] [section id] [new size] [new payload] [everything after] + const before_section = input[0 .. section_start - 1 - uleb128Size(section_size)]; + var result: std.Io.Writer.Allocating = .init(alloc); + try result.writer.writeAll(before_section); + try result.writer.writeByte(4); // table section id + try result.writer.writeUleb128(@as(u32, @intCast(payload.written().len))); + try result.writer.writeAll(payload.written()); + try result.writer.writeAll(input[section_end..]); + return result.written(); + } + + // No table section found; return input unchanged. + return input; +} + +/// Decode an unsigned LEB128 value from `bytes` starting at `pos.*`, +/// advancing `pos` past the encoded bytes. +fn readLeb128(bytes: []const u8, pos: *usize) u32 { + var result: u32 = 0; + var shift: u5 = 0; + while (true) { + const byte = bytes[pos.*]; + pos.* += 1; + result |= @as(u32, byte & 0x7f) << shift; + if (byte & 0x80 == 0) return result; + shift +%= 7; + } +} + +/// Return the number of bytes needed to encode `value` as unsigned LEB128. +fn uleb128Size(value: u32) usize { + var v = value; + var size: usize = 0; + while (true) { + v >>= 7; + size += 1; + if (v == 0) return size; + } +} + +/// Minimal valid WASM module with a bounded table (min=1, max=1). +/// Sections: type(1), table(4), export(7). +const test_wasm_bounded_table = [_]u8{ + 0x00, 0x61, 0x73, 0x6d, // magic + 0x01, 0x00, 0x00, 0x00, // version + // Type section (id=1): 1 type, () -> () + 0x01, 0x04, 0x01, 0x60, + 0x00, 0x00, + // Table section (id=4): 1 table, funcref, flags=1, min=1, max=1 + 0x04, 0x05, + 0x01, 0x70, 0x01, 0x01, + 0x01, + // Export section (id=7): 0 exports + 0x07, 0x01, 0x00, +}; + +/// Same module but the table already has no max (flags=0). +const test_wasm_growable_table = [_]u8{ + 0x00, 0x61, 0x73, 0x6d, // magic + 0x01, 0x00, 0x00, 0x00, // version + // Type section (id=1) + 0x01, 0x04, 0x01, 0x60, + 0x00, 0x00, + // Table section (id=4): 1 table, funcref, flags=0, min=1 + 0x04, 0x04, + 0x01, 0x70, 0x00, 0x01, + // Export section (id=7): 0 exports + 0x07, 0x01, 0x00, +}; + +/// Module with no table section at all. +const test_wasm_no_table = [_]u8{ + 0x00, 0x61, 0x73, 0x6d, // magic + 0x01, 0x00, 0x00, 0x00, // version + // Type section (id=1) + 0x01, 0x04, 0x01, 0x60, + 0x00, 0x00, + // Export section (id=7): 0 exports + 0x07, 0x01, + 0x00, +}; + +test "patches bounded table to remove max" { + // We use a non-checking allocator because the patched result is + // intentionally leaked (matches the real main() usage). + const result = try patchTableGrowable( + std.heap.page_allocator, + &test_wasm_bounded_table, + ); + + // Result should differ from input (max was removed). + try testing.expect(!std.mem.eql( + u8, + result, + &test_wasm_bounded_table, + )); + + // Find the table section in the output and verify flags=0x00. + var pos: usize = 8; + while (pos < result.len) { + const id = result[pos]; + pos += 1; + const size = readLeb128(result, &pos); + if (id == 4) { + _ = readLeb128(result, &pos); // table count + pos += 1; // elem_type + // flags should now be 0x00 (no max). + try testing.expectEqual(@as(u8, 0x00), result[pos]); + return; + } + pos += size; + } + return error.TableSectionNotFound; +} + +test "already growable table is returned unchanged" { + const result = try patchTableGrowable( + testing.allocator, + &test_wasm_growable_table, + ); + try testing.expectEqual( + @as([*]const u8, &test_wasm_growable_table), + result.ptr, + ); +} + +test "no table section returns input unchanged" { + const result = try patchTableGrowable( + testing.allocator, + &test_wasm_no_table, + ); + try testing.expectEqual(@as([*]const u8, &test_wasm_no_table), result.ptr); +} + +test "too short input returns InvalidWasm" { + try testing.expectError( + error.InvalidWasm, + patchTableGrowable(testing.allocator, "short"), + ); +} + +test "readLeb128 single byte" { + const bytes = [_]u8{0x05}; + var pos: usize = 0; + try testing.expectEqual(@as(u32, 5), readLeb128(&bytes, &pos)); + try testing.expectEqual(@as(usize, 1), pos); +} + +test "readLeb128 multi byte" { + // 300 = 0b100101100 → LEB128: 0xAC 0x02 + const bytes = [_]u8{ 0xAC, 0x02 }; + var pos: usize = 0; + try testing.expectEqual(@as(u32, 300), readLeb128(&bytes, &pos)); + try testing.expectEqual(@as(usize, 2), pos); +} + +test "uleb128Size" { + try testing.expectEqual(@as(usize, 1), uleb128Size(0)); + try testing.expectEqual(@as(usize, 1), uleb128Size(0x7f)); + try testing.expectEqual(@as(usize, 2), uleb128Size(0x80)); + try testing.expectEqual(@as(usize, 2), uleb128Size(300)); + try testing.expectEqual(@as(usize, 5), uleb128Size(std.math.maxInt(u32))); +} diff --git a/src/cli/CommaSplitter.zig b/src/cli/CommaSplitter.zig index 3168c1ffaa8..d5093992d74 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/Pager.zig b/src/cli/Pager.zig new file mode 100644 index 00000000000..247c998e10e --- /dev/null +++ b/src/cli/Pager.zig @@ -0,0 +1,93 @@ +//! A pager wraps output to an external pager program (like `less`) when +//! stdout is a TTY. The pager command is resolved as: +//! +//! `$GHOSTTY_PAGER` > `$PAGER` > `less` +//! +//! Setting either env var to an empty string disables paging. +//! If stdout is not a TTY, writes go directly to stdout. +const Pager = @This(); +const std = @import("std"); +const Allocator = std.mem.Allocator; +const internal_os = @import("../os/main.zig"); + +/// The pager child process, if one was spawned. +child: ?std.process.Child = null, + +/// The buffered file writer used for both the pager pipe and direct +/// stdout paths. +file_writer: std.fs.File.Writer = undefined, + +/// Initialize the pager. If stdout is a TTY, this spawns the pager +/// process. Otherwise, output goes directly to stdout. +pub fn init(alloc: Allocator) Pager { + return .{ .child = initPager(alloc) }; +} + +/// Writes to the pager process if available; otherwise, stdout. +pub fn writer(self: *Pager, buffer: []u8) *std.Io.Writer { + if (self.child) |child| { + self.file_writer = child.stdin.?.writer(buffer); + } else { + self.file_writer = std.fs.File.stdout().writer(buffer); + } + return &self.file_writer.interface; +} + +/// Deinitialize the pager. Waits for the spawned process to exit. +pub fn deinit(self: *Pager) void { + if (self.child) |*child| { + // Flush any remaining buffered data, close the pipe so the + // pager sees EOF, then wait for it to exit. + self.file_writer.interface.flush() catch {}; + if (child.stdin) |stdin| { + stdin.close(); + child.stdin = null; + } + _ = child.wait() catch {}; + } + + self.* = undefined; +} + +fn initPager(alloc: Allocator) ?std.process.Child { + const stdout_file: std.fs.File = .stdout(); + if (!stdout_file.isTty()) return null; + + // Resolve the pager command: $GHOSTTY_PAGER > $PAGER > `less`. + // An empty value for either env var disables paging. + const ghostty_var = internal_os.getenv(alloc, "GHOSTTY_PAGER") catch null; + defer if (ghostty_var) |v| v.deinit(alloc); + const pager_var = internal_os.getenv(alloc, "PAGER") catch null; + defer if (pager_var) |v| v.deinit(alloc); + + const cmd: ?[]const u8 = cmd: { + if (ghostty_var) |v| break :cmd if (v.value.len > 0) v.value else null; + if (pager_var) |v| break :cmd if (v.value.len > 0) v.value else null; + break :cmd "less"; + }; + + if (cmd == null) return null; + + var child: std.process.Child = .init(&.{cmd.?}, alloc); + child.stdin_behavior = .Pipe; + child.stdout_behavior = .Inherit; + child.stderr_behavior = .Inherit; + + child.spawn() catch return null; + return child; +} + +test "pager: non-tty" { + var pager: Pager = .init(std.testing.allocator); + defer pager.deinit(); + try std.testing.expect(pager.child == null); +} + +test "pager: default writer" { + var pager: Pager = .{}; + defer pager.deinit(); + try std.testing.expect(pager.child == null); + var buf: [4096]u8 = undefined; + const w = pager.writer(&buf); + try w.writeAll("hello"); +} diff --git a/src/cli/edit_config.zig b/src/cli/edit_config.zig index 056aecc0d44..c08651a06cf 100644 --- a/src/cli/edit_config.zig +++ b/src/cli/edit_config.zig @@ -136,19 +136,31 @@ fn runInner(alloc: Allocator, stderr: *std.Io.Writer) !u8 { return 1; } + const command = command: { + var buffer: std.io.Writer.Allocating = .init(alloc); + defer buffer.deinit(); + const writer = &buffer.writer; + try writer.writeAll(editor); + try writer.writeByte(' '); + { + var sh: internal_os.ShellEscapeWriter = .init(writer); + try sh.writer.writeAll(path); + try sh.writer.flush(); + } + try writer.flush(); + break :command try buffer.toOwnedSliceSentinel(0); + }; + defer alloc.free(command); + // We require libc because we want to use std.c.environ for envp // and not have to build that ourselves. We can remove this // limitation later but Ghostty already heavily requires libc // so this is not a big deal. comptime assert(builtin.link_libc); - const editorZ = try alloc.dupeZ(u8, editor); - defer alloc.free(editorZ); - const pathZ = try alloc.dupeZ(u8, path); - defer alloc.free(pathZ); const err = std.posix.execvpeZ( - editorZ, - &.{ editorZ, pathZ }, + "/bin/sh", + &.{ "/bin/sh", "-c", command }, std.c.environ, ); diff --git a/src/cli/explain_config.zig b/src/cli/explain_config.zig new file mode 100644 index 00000000000..4f034afef6b --- /dev/null +++ b/src/cli/explain_config.zig @@ -0,0 +1,151 @@ +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; +const Pager = @import("Pager.zig"); + +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. +/// * `--no-pager`: Disable automatic paging of output. +pub fn run(alloc: Allocator) !u8 { + var option_name: ?[]const u8 = null; + var keybind_name: ?[]const u8 = null; + var positional: ?[]const u8 = null; + var no_pager: bool = false; + + var iter = try args.argsIterator(alloc); + defer iter.deinit(); + defer if (option_name) |s| alloc.free(s); + defer if (keybind_name) |s| alloc.free(s); + defer if (positional) |s| alloc.free(s); + + while (iter.next()) |arg| { + if (std.mem.startsWith(u8, arg, "--option=")) { + option_name = try alloc.dupe(u8, arg["--option=".len..]); + } else if (std.mem.startsWith(u8, arg, "--keybind=")) { + keybind_name = try alloc.dupe(u8, arg["--keybind=".len..]); + } else if (std.mem.eql(u8, arg, "--no-pager")) { + no_pager = true; + } 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 = try alloc.dupe(u8, 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